diff --git a/@xen-orchestra/rest-api/src/open-api/schema/build-openapi-schema.mts b/@xen-orchestra/rest-api/src/open-api/schema/build-openapi-schema.mts index 362e093155..187f418bce 100644 --- a/@xen-orchestra/rest-api/src/open-api/schema/build-openapi-schema.mts +++ b/@xen-orchestra/rest-api/src/open-api/schema/build-openapi-schema.mts @@ -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): OpenAPIV3.SchemaObject { const schema: OpenAPIV3.SchemaObject = { type: 'object', @@ -11,25 +33,7 @@ export function buildOpenApiSchema(def: Record): 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) diff --git a/@xen-orchestra/rest-api/src/router/external-router.mts b/@xen-orchestra/rest-api/src/router/external-router.mts index 1ee794f9ae..5efc35c4a2 100644 --- a/@xen-orchestra/rest-api/src/router/external-router.mts +++ b/@xen-orchestra/rest-api/src/router/external-router.mts @@ -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): z.ZodObject> { const shape: Record = {} 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) diff --git a/@xen-orchestra/rest-api/src/router/types.mts b/@xen-orchestra/rest-api/src/router/types.mts index 357ef142c6..16c5f4ef38 100644 --- a/@xen-orchestra/rest-api/src/router/types.mts +++ b/@xen-orchestra/rest-api/src/router/types.mts @@ -53,10 +53,19 @@ export type FieldDefinition = fields: Record optional?: boolean } + | { + type: 'array' + items: FieldDefinition + example?: unknown[] + optional?: boolean + } -export type ParamFieldDefinition = Exclude +export type ParamFieldDefinition = Exclude< + FieldDefinition, + { type: 'boolean' } | { type: 'object' } | { type: 'array' } +> -export type QueryFieldDefinition = Exclude +export type QueryFieldDefinition = Exclude export interface RouteDefinition { method: 'get' | 'post' | 'put' | 'delete' | 'patch' diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 8911d92733..9354d672be 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -33,6 +33,8 @@ +- @xen-orchestra/rest-api minor - @xen-orchestra/web patch +- xo-server patch diff --git a/packages/xo-server/src/xo-mixins/_rest-api.test.mjs b/packages/xo-server/src/xo-mixins/_rest-api.test.mjs index 00c6ec1995..8630cf4f5a 100644 --- a/packages/xo-server/src/xo-mixins/_rest-api.test.mjs +++ b/packages/xo-server/src/xo-mixins/_rest-api.test.mjs @@ -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' }]) + }) }) }) })