feat(rest-api): add array type to FieldDefinition (#10055)

This commit is contained in:
Grandalf
2026-07-10 14:33:00 +02:00
committed by GitHub
parent f3f8519b7a
commit b5f5e39ddc
5 changed files with 245 additions and 49 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'

View File

@@ -33,6 +33,8 @@
<!--packages-start-->
- @xen-orchestra/rest-api minor
- @xen-orchestra/web patch
- xo-server patch
<!--packages-end-->

View File

@@ -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' }])
})
})
})
})