Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 41 additions & 14 deletions lib/spec/openapi/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,21 +225,25 @@ const schemaTypeToNestedSchemas = {
}
}

function resolveSchemaExamples (schema) {
const example = schema[xExamples] ?? schema.examples?.[0]
function resolveSchemaExamples (opts, schema) {
// OpenAPI 3.1+ Schema Objects are JSON Schema 2020-12, where `examples`
// is a valid array keyword: keep it instead of downgrading to `example`
const keepExamplesArray = !isOpenapi30(opts) && Array.isArray(schema.examples)
const example = schema[xExamples] ?? (keepExamplesArray ? undefined : schema.examples?.[0])
if (typeof example !== 'undefined') {
schema.example = example
}
delete schema[xExamples]
delete schema.examples
if (!keepExamplesArray) delete schema.examples
}

function resolveSchemaExamplesRecursive (schema) {
resolveSchemaExamples(schema)
const getNestedSchemas = schemaTypeToNestedSchemas[schema.type]
const nestedSchemas = getNestedSchemas?.(schema) ?? []
function resolveSchemaExamplesRecursive (opts, schema) {
resolveSchemaExamples(opts, schema)
// in OpenAPI 3.1+ `type` can be an array, eg. ['object', 'null']
const types = Array.isArray(schema.type) ? schema.type : [schema.type]
const nestedSchemas = types.flatMap(t => schemaTypeToNestedSchemas[t]?.(schema) ?? [])
for (const nestedSchema of nestedSchemas) {
resolveSchemaExamplesRecursive(nestedSchema)
resolveSchemaExamplesRecursive(opts, nestedSchema)
}
}

Expand All @@ -263,9 +267,9 @@ function schemaToMedia (schema) {
return media
}

function schemaToMediaRecursive (schema) {
function schemaToMediaRecursive (opts, schema) {
const media = schemaToMedia(schema)
resolveSchemaExamplesRecursive(schema)
resolveSchemaExamplesRecursive(opts, schema)
return media
}

Expand All @@ -274,14 +278,14 @@ function resolveBodyParams (opts, body, schema, consumes, ref) {

if (resolved.content?.[Object.keys(resolved.content)[0]].schema) {
for (const contentType in schema.content) {
body.content[contentType] = schemaToMediaRecursive(resolved.content[contentType].schema)
body.content[contentType] = schemaToMediaRecursive(opts, resolved.content[contentType].schema)
}
} else {
if ((Array.isArray(consumes) && consumes.length === 0) || consumes === undefined) {
consumes = ['application/json']
}

const media = schemaToMediaRecursive(resolved)
const media = schemaToMediaRecursive(opts, resolved)
consumes.forEach((consume) => {
body.content[consume] = media
})
Expand Down Expand Up @@ -375,7 +379,7 @@ function resolveResponse (opts, fastifyResponseJson, produces, ref) {

delete resolved[xResponseDescription]

const media = schemaToMediaRecursive(resolved)
const media = schemaToMediaRecursive(opts, resolved)

for (const produce of produces) {
content[produce] = media
Expand Down Expand Up @@ -603,6 +607,27 @@ function convertNullTypeToNullable (openapiSchema) {
return openapiSchema
}

// Ajv supports the OpenAPI 3.0 `nullable` keyword, but it does not exist in
// JSON Schema 2020-12 (OpenAPI 3.1+): convert it to a `null` type.
// Like in OpenAPI 3.0, `nullable` only applies to an explicit `type`
// (Ajv refuses to compile `nullable` without `type` anyway).
function convertNullableToNullType (openapiSchema) {
if (!Object.hasOwn(openapiSchema, 'nullable')) return openapiSchema

const { nullable, type } = openapiSchema
delete openapiSchema.nullable
if (nullable !== true || type === undefined) return openapiSchema

const types = Array.isArray(type) ? type : [type]
if (!types.includes('null')) {
openapiSchema.type = [...types, 'null']
}
// `enum` is deliberately left untouched: Ajv only accepts `null` for a
// nullable schema if `enum` lists it explicitly, and so does JSON Schema

return openapiSchema
}

function convertJsonSchemaToOpenapi3 (opts, jsonSchema) {
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
return jsonSchema
Expand Down Expand Up @@ -682,6 +707,8 @@ function convertJsonSchemaToOpenapi3 (opts, jsonSchema) {

if (isOpenapi30(opts)) {
convertNullTypeToNullable(openapiSchema)
} else {
convertNullableToNullType(openapiSchema)
}

return openapiSchema
Expand All @@ -701,7 +728,7 @@ function prepareOpenapiSchemas (opts, jsonSchemas, ref, hoisted = new Map()) {

for (const [name, schema] of [[schemaName, resolvedJsonSchema], ...hoistedSchemas]) {
const openapiSchema = convertJsonSchemaToOpenapi3(opts, schema)
resolveSchemaExamplesRecursive(openapiSchema)
resolveSchemaExamplesRecursive(opts, openapiSchema)
openapiSchemas[name] = openapiSchema
}
}
Expand Down
66 changes: 66 additions & 0 deletions test/spec/openapi/schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2364,3 +2364,69 @@ test('openapi 3.0: `type: null` conversion does not mutate the route schema', as
t.assert.deepStrictEqual(body, bodyBefore)
t.assert.deepStrictEqual(response, responseBefore)
})

test('openapi 3.1+: `nullable` is converted to a `null` type', async (t) => {
const cases = [
[{ type: 'string', nullable: true }, { type: ['string', 'null'] }],
[{ type: ['string', 'integer'], nullable: true }, { type: ['string', 'integer', 'null'] }],
[{ type: ['null', 'string'], nullable: true }, { type: ['null', 'string'] }],
// Ajv (and JSON Schema) only accept `null` if `enum` lists it explicitly
[{ type: 'string', enum: ['a', 'b'], nullable: true }, { type: ['string', 'null'], enum: ['a', 'b'] }],
[{ type: 'string', enum: ['a', null], nullable: true }, { type: ['string', 'null'], enum: ['a', null] }],
[{ type: 'string', nullable: false }, { type: 'string' }],
// like in OpenAPI 3.0, `nullable` without `type` has no effect
[{ nullable: true }, {}]
]

for (const openapi of ['3.1.0', '3.2.0']) {
for (const [input, expected] of cases) {
const fastify = Fastify()
await fastify.register(fastifySwagger, { openapi: { openapi } })

fastify.post('/', {
schema: { response: { 200: { type: 'object', properties: { value: input } } } }
}, () => ({}))

await fastify.ready()

const openapiObject = fastify.swagger()
if (openapi === '3.1.0') await Swagger.validate(structuredClone(openapiObject))

const value = openapiObject.paths['/'].post.responses['200'].content['application/json'].schema.properties.value
t.assert.deepStrictEqual(value, expected)
}
}
})

test('openapi 3.1: `examples` array is kept in nested schemas', async (t) => {
const fastify = Fastify()
await fastify.register(fastifySwagger, { openapi: { openapi: '3.1.0' } })

fastify.post('/', {
schema: {
body: {
type: 'object',
properties: {
multipleExamples: { type: 'string', examples: ['foo', 'bar'] },
nullableObject: {
type: 'object',
nullable: true,
properties: { x: { type: 'integer', examples: [1, 2] } }
}
}
}
}
}, () => ({}))

await fastify.ready()

const openapiObject = fastify.swagger()
await Swagger.validate(structuredClone(openapiObject))

const { properties } = openapiObject.paths['/'].post.requestBody.content['application/json'].schema
t.assert.deepStrictEqual(properties.multipleExamples, { type: 'string', examples: ['foo', 'bar'] })
t.assert.deepStrictEqual(properties.nullableObject, {
type: ['object', 'null'],
properties: { x: { type: 'integer', examples: [1, 2] } }
})
})
Loading