From 175f4185566d8ab6e18411904988c8e3f04883c0 Mon Sep 17 00:00:00 2001 From: Tony133 Date: Wed, 23 Sep 2026 11:47:46 +0200 Subject: [PATCH] fix: convert nullable and keep examples for OpenAPI 3.1+ --- lib/spec/openapi/utils.js | 55 +++++++++++++++++++------- test/spec/openapi/schema.test.js | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index 06d56fd8..cc8f9331 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -224,21 +224,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) } } @@ -262,9 +266,9 @@ function schemaToMedia (schema) { return media } -function schemaToMediaRecursive (schema) { +function schemaToMediaRecursive (opts, schema) { const media = schemaToMedia(schema) - resolveSchemaExamplesRecursive(schema) + resolveSchemaExamplesRecursive(opts, schema) return media } @@ -273,14 +277,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 }) @@ -374,7 +378,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 @@ -602,6 +606,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 @@ -681,6 +706,8 @@ function convertJsonSchemaToOpenapi3 (opts, jsonSchema) { if (isOpenapi30(opts)) { convertNullTypeToNullable(openapiSchema) + } else { + convertNullableToNullType(openapiSchema) } return openapiSchema @@ -694,7 +721,7 @@ function prepareOpenapiSchemas (opts, jsonSchemas, ref) { const resolvedJsonSchema = ref.resolve(jsonSchema, { externalSchemas: [jsonSchemas] }) const openapiSchema = convertJsonSchemaToOpenapi3(opts, resolvedJsonSchema) - resolveSchemaExamplesRecursive(openapiSchema) + resolveSchemaExamplesRecursive(opts, openapiSchema) openapiSchemas[schemaName] = openapiSchema } diff --git a/test/spec/openapi/schema.test.js b/test/spec/openapi/schema.test.js index b755765b..f514c58c 100644 --- a/test/spec/openapi/schema.test.js +++ b/test/spec/openapi/schema.test.js @@ -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] } } + }) +})