mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(rest-api): add array type to FieldDefinition (#10055)
This commit is contained in:
@@ -1,7 +1,29 @@
|
||||
import type { OpenAPIV3 } from 'openapi-types'
|
||||
import type { FieldDefinition } from '../../router/types.mjs'
|
||||
|
||||
// Build OpenApi schema from our FieldDefinition
|
||||
function buildOpenApiField(field: FieldDefinition): OpenAPIV3.SchemaObject {
|
||||
let property: OpenAPIV3.SchemaObject
|
||||
|
||||
if (field.type === 'array') {
|
||||
property = { type: 'array', items: buildOpenApiField(field.items) }
|
||||
} else if (field.type === 'enum') {
|
||||
property = { type: 'string', enum: field.enum }
|
||||
} else if (field.type === 'object') {
|
||||
property = { type: 'object' }
|
||||
const nested = buildOpenApiSchema(field.fields)
|
||||
property.properties = nested.properties
|
||||
if (nested.required?.length) property.required = nested.required
|
||||
} else {
|
||||
property = { type: field.type }
|
||||
}
|
||||
|
||||
if ('example' in field && field.example !== undefined) {
|
||||
property.example = field.example
|
||||
}
|
||||
|
||||
return property
|
||||
}
|
||||
|
||||
export function buildOpenApiSchema(def: Record<string, FieldDefinition>): OpenAPIV3.SchemaObject {
|
||||
const schema: OpenAPIV3.SchemaObject = {
|
||||
type: 'object',
|
||||
@@ -11,25 +33,7 @@ export function buildOpenApiSchema(def: Record<string, FieldDefinition>): OpenAP
|
||||
const required: string[] = []
|
||||
|
||||
for (const [key, field] of Object.entries(def)) {
|
||||
const property: OpenAPIV3.SchemaObject = {}
|
||||
|
||||
if (field.type === 'enum') {
|
||||
property.type = 'string'
|
||||
property.enum = field.enum
|
||||
} else if (field.type === 'object') {
|
||||
property.type = 'object'
|
||||
const nested = buildOpenApiSchema(field.fields)
|
||||
property.properties = nested.properties
|
||||
if (nested.required?.length) property.required = nested.required
|
||||
} else {
|
||||
property.type = field.type
|
||||
}
|
||||
|
||||
if ('example' in field && field.example !== undefined) {
|
||||
property.example = field.example
|
||||
}
|
||||
|
||||
schema.properties![key] = property
|
||||
schema.properties![key] = buildOpenApiField(field)
|
||||
|
||||
if (!field.optional) {
|
||||
required.push(key)
|
||||
|
||||
@@ -258,38 +258,46 @@ function resolveMiddleware(descriptor: MiddlewareDescriptor): RequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a zod schema for a single FieldDefinition
|
||||
function buildZodField(field: FieldDefinition): z.ZodTypeAny {
|
||||
let schema: z.ZodTypeAny
|
||||
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
schema = z.string()
|
||||
break
|
||||
case 'boolean':
|
||||
schema = z.boolean()
|
||||
break
|
||||
case 'number':
|
||||
schema = z.number()
|
||||
break
|
||||
case 'enum':
|
||||
schema = z.enum(field.enum as [string, ...string[]])
|
||||
break
|
||||
case 'object':
|
||||
schema = buildZodSchema(field.fields)
|
||||
break
|
||||
case 'array':
|
||||
schema = z.array(buildZodField(field.items))
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported type: ${(field as { type: unknown }).type}`)
|
||||
}
|
||||
|
||||
if ('example' in field && field.example !== undefined) schema = schema.meta({ example: field.example })
|
||||
|
||||
if (field.optional) schema = schema.optional()
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
// Build zod schema to allow easy input validation
|
||||
function buildZodSchema(def: Record<string, FieldDefinition>): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
|
||||
for (const [key, field] of Object.entries(def)) {
|
||||
let schema: z.ZodTypeAny
|
||||
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
schema = z.string()
|
||||
break
|
||||
case 'boolean':
|
||||
schema = z.boolean()
|
||||
break
|
||||
case 'number':
|
||||
schema = z.number()
|
||||
break
|
||||
case 'enum':
|
||||
schema = z.enum(field.enum as [string, ...string[]])
|
||||
break
|
||||
case 'object':
|
||||
schema = buildZodSchema(field.fields)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported type: ${(field as { type: unknown }).type}`)
|
||||
}
|
||||
|
||||
if ('example' in field && field.example !== undefined) schema = schema.meta({ example: field.example })
|
||||
|
||||
if (field.optional) schema = schema.optional()
|
||||
|
||||
shape[key] = schema
|
||||
shape[key] = buildZodField(field)
|
||||
}
|
||||
|
||||
return z.object(shape)
|
||||
|
||||
@@ -53,10 +53,19 @@ export type FieldDefinition =
|
||||
fields: Record<string, FieldDefinition>
|
||||
optional?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'array'
|
||||
items: FieldDefinition
|
||||
example?: unknown[]
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
export type ParamFieldDefinition = Exclude<FieldDefinition, { type: 'boolean' } | { type: 'object' }>
|
||||
export type ParamFieldDefinition = Exclude<
|
||||
FieldDefinition,
|
||||
{ type: 'boolean' } | { type: 'object' } | { type: 'array' }
|
||||
>
|
||||
|
||||
export type QueryFieldDefinition = Exclude<FieldDefinition, { type: 'object' }>
|
||||
export type QueryFieldDefinition = Exclude<FieldDefinition, { type: 'object' } | { type: 'array' }>
|
||||
|
||||
export interface RouteDefinition {
|
||||
method: 'get' | 'post' | 'put' | 'delete' | 'patch'
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/rest-api minor
|
||||
- @xen-orchestra/web patch
|
||||
- xo-server patch
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -812,6 +812,138 @@ describe('RestApi', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('array type', () => {
|
||||
before(() => {
|
||||
restApi.registerRestRoutes(
|
||||
[
|
||||
{
|
||||
endpoint: '/body-array-validation',
|
||||
method: 'post',
|
||||
middlewares: [{ name: 'json' }],
|
||||
body: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
callback: ({ req }) => ({ tags: req.body.tags }),
|
||||
},
|
||||
{
|
||||
endpoint: '/body-array-object-validation',
|
||||
method: 'post',
|
||||
middlewares: [{ name: 'json' }],
|
||||
body: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
key: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
callback: ({ req }) => ({ items: req.body.items }),
|
||||
},
|
||||
],
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts valid array of scalars in body', async () => {
|
||||
const response = await post(port, '/body-array-validation', { body: { tags: ['a', 'b'] } })
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { tags: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('accepts empty array in body', async () => {
|
||||
const response = await post(port, '/body-array-validation', { body: { tags: [] } })
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { tags: [] })
|
||||
})
|
||||
|
||||
it('rejects invalid element type in array', async () => {
|
||||
const response = await post(port, '/body-array-validation', { body: { tags: ['a', 123] } })
|
||||
assert.equal(response.status, 422)
|
||||
})
|
||||
|
||||
it('rejects non-array value for array field', async () => {
|
||||
const response = await post(port, '/body-array-validation', { body: { tags: 'a' } })
|
||||
assert.equal(response.status, 422)
|
||||
})
|
||||
|
||||
it('rejects missing required array field', async () => {
|
||||
const response = await post(port, '/body-array-validation', { body: {} })
|
||||
assert.equal(response.status, 422)
|
||||
})
|
||||
|
||||
it('accepts valid array of objects in body', async () => {
|
||||
const response = await post(port, '/body-array-object-validation', {
|
||||
body: { items: [{ key: 'hello', count: 42 }] },
|
||||
})
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { items: [{ key: 'hello', count: 42 }] })
|
||||
})
|
||||
|
||||
it('rejects invalid nested field in array of objects', async () => {
|
||||
const response = await post(port, '/body-array-object-validation', {
|
||||
body: { items: [{ key: 123, count: 42 }] },
|
||||
})
|
||||
assert.equal(response.status, 422)
|
||||
})
|
||||
|
||||
it('accepts optional array field absent from body', async () => {
|
||||
const unregister = restApi.registerRestRoutes(
|
||||
[
|
||||
{
|
||||
endpoint: '/body-optional-array-test',
|
||||
method: 'post',
|
||||
middlewares: [{ name: 'json' }],
|
||||
body: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
callback: ({ req }) => ({ hasTags: req.body.tags !== undefined }),
|
||||
},
|
||||
],
|
||||
'/'
|
||||
)
|
||||
try {
|
||||
const response = await post(port, '/body-optional-array-test', { body: {} })
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { hasTags: false })
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects array type in query params at runtime', async () => {
|
||||
const unregister = restApi.registerRestRoutes(
|
||||
[
|
||||
{
|
||||
endpoint: '/runtime-query-array-test',
|
||||
method: 'get',
|
||||
query: {
|
||||
data: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
callback: ({ req }) => ({ data: req.query.data }),
|
||||
},
|
||||
],
|
||||
'/'
|
||||
)
|
||||
try {
|
||||
const response = await get(port, '/runtime-query-array-test?data=hello')
|
||||
assert.equal(response.status, 422)
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('middlewares', () => {
|
||||
@@ -1382,6 +1514,47 @@ describe('RestApi', () => {
|
||||
assert.ok(responseSchema?.properties?.data?.properties?.id !== undefined)
|
||||
assert.ok(responseSchema?.properties?.data?.properties?.count !== undefined)
|
||||
})
|
||||
|
||||
it('array field in response schema appears in swagger spec with typed items', async () => {
|
||||
restApi.registerRestRoutes(
|
||||
[
|
||||
{
|
||||
endpoint: '/swagger-response-array',
|
||||
method: 'get',
|
||||
responses: [
|
||||
{
|
||||
status: 200,
|
||||
description: 'A response with an array',
|
||||
schema: {
|
||||
sensors: {
|
||||
type: 'array',
|
||||
example: [{ name: 'CPU1 Temp', dataType: 'cpuTemp' }],
|
||||
items: {
|
||||
type: 'object',
|
||||
fields: {
|
||||
name: { type: 'string' },
|
||||
dataType: { type: 'enum', enum: ['cpuTemp', 'unknown'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
callback: () => ({}),
|
||||
},
|
||||
],
|
||||
'/'
|
||||
)
|
||||
const spec = await (await fetchSwagger(port)).json()
|
||||
const pathEntry = spec.paths['/swagger-response-array']
|
||||
assert.ok(pathEntry !== undefined)
|
||||
const responseSchema = pathEntry.get.responses['200'].content?.['application/json']?.schema
|
||||
assert.equal(responseSchema?.properties?.sensors?.type, 'array')
|
||||
assert.equal(responseSchema?.properties?.sensors?.items?.type, 'object')
|
||||
assert.ok(responseSchema?.properties?.sensors?.items?.properties?.name !== undefined)
|
||||
assert.deepEqual(responseSchema?.properties?.sensors?.items?.properties?.dataType?.enum, ['cpuTemp', 'unknown'])
|
||||
assert.deepEqual(responseSchema?.properties?.sensors?.example, [{ name: 'CPU1 Temp', dataType: 'cpuTemp' }])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user