diff --git a/README.md b/README.md index ffbd858e..585676ae 100644 --- a/README.md +++ b/README.md @@ -726,6 +726,140 @@ You can integration this plugin with ```@fastify/helmet``` with some little work }) ``` + +### Add examples to the schema + +Note: [OpenAPI](https://swagger.io/specification/#example-object) and [JSON Schema](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.9.5) have different examples field formats. + +Array with examples from JSON Schema converted to OpenAPI `example` or `examples` field automatically with generated names (example1, example2...): + +```js +fastify.route({ + method: 'POST', + url: '/', + schema: { + querystring: { + type: 'object', + required: ['filter'], + properties: { + filter: { + type: 'object', + required: ['foo'], + properties: { + foo: { type: 'string' }, + bar: { type: 'string' } + }, + examples: [ + { foo: 'bar', bar: 'baz' }, + { foo: 'foo', bar: 'bar' } + ] + } + }, + examples: [ + { filter: { foo: 'bar', bar: 'baz' } } + ] + } + }, + handler (request, reply) { + reply.send(request.query.filter) + } +}) +``` + +Will generate this in the OpenAPI v3 schema's `path`: + +```json +"/": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["filter"], + "properties": { + "filter": { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { "type": "string" }, + "bar": { "type": "string" } + }, + "example": { "foo": "bar", "bar": "baz" } + } + } + }, + "examples": { + "example1": { + "value": { "filter": { "foo": "bar", "bar": "baz" } } + }, + "example2": { + "value": { "filter": { "foo": "foo", "bar": "bar" } } + } + } + } + }, + "required": true + }, + "responses": { "200": { "description": "Default Response" } } + } +} +``` + +If you want to set your own names or add descriptions to the examples of schemas, you can use `x-examples` field to set examples in [OpenAPI format](https://swagger.io/specification/#example-object): + +```js +// Need to add a new allowed keyword to ajv in fastify instance +const fastify = Fastify({ + ajv: { + plugins: [ + function (ajv) { + ajv.addKeyword({ keyword: 'x-examples' }) + } + ] + } +}) + +fastify.route({ + method: 'POST', + url: '/feed-animals', + schema: { + body: { + type: 'object', + required: ['animals'], + properties: { + animals: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1, + } + }, + "x-examples": { + Cats: { + summary: "Feed cats", + description: + "A longer **description** of the options with cats", + value: { + animals: ["Tom", "Garfield", "Felix"] + } + }, + Dogs: { + summary: "Feed dogs", + value: { + animals: ["Spike", "Odie", "Snoopy"] + } + } + } + } + }, + handler (request, reply) { + reply.send(request.body.animals) + } +}) +``` + ## `$id` and `$ref` usage diff --git a/lib/constants.js b/lib/constants.js index 00c76c8d..d35a9549 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -2,8 +2,10 @@ const xConsume = 'x-consume' const xResponseDescription = 'x-response-description' +const xExamples = 'x-examples' module.exports = { xConsume, - xResponseDescription + xResponseDescription, + xExamples } diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index a19466d4..5583022d 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -1,7 +1,7 @@ 'use strict' const { readPackageJson, formatParamUrl, resolveLocalRef } = require('../../util/common') -const { xResponseDescription, xConsume } = require('../../constants') +const { xResponseDescription, xConsume, xExamples } = require('../../constants') const { rawRequired } = require('../../symbols') function prepareDefaultOptions (opts) { @@ -136,26 +136,26 @@ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas, secu case 'cookie': case 'query': toOpenapiProp = function (propertyName, jsonSchemaElement) { - const result = { + let result = { in: container, name: propertyName, required: jsonSchemaElement.required } + + const media = schemaToMedia(jsonSchemaElement) + // complex serialization in query or cookie, eg. JSON // https://swagger.io/docs/specification/describing-parameters/#schema-vs-content if (jsonSchemaElement[xConsume]) { + media.schema.required = jsonSchemaElement[rawRequired] + result.content = { - [jsonSchemaElement[xConsume]]: { - schema: { - ...jsonSchemaElement, - required: jsonSchemaElement[rawRequired] - } - } + [jsonSchemaElement[xConsume]]: media } delete result.content[jsonSchemaElement[xConsume]].schema[xConsume] } else { - result.schema = jsonSchemaElement + result = { ...media, ...result } } // description should be optional if (jsonSchemaElement.description) result.description = jsonSchemaElement.description @@ -167,12 +167,15 @@ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas, secu break case 'path': toOpenapiProp = function (propertyName, jsonSchemaElement) { + const media = schemaToMedia(jsonSchemaElement) + const result = { + ...media, in: container, name: propertyName, - required: true, - schema: jsonSchemaElement + required: true } + // description should be optional if (jsonSchemaElement.description) result.description = jsonSchemaElement.description return result @@ -180,7 +183,7 @@ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas, secu break case 'header': toOpenapiProp = function (propertyName, jsonSchemaElement) { - return { + const result = { in: 'header', name: propertyName, required: jsonSchemaElement.required, @@ -189,6 +192,17 @@ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas, secu type: jsonSchemaElement.type } } + + const media = schemaToMedia(jsonSchemaElement) + if (media.example) { + result.example = media.example + } + + if (media.examples) { + result.examples = media.examples + } + + return result } break } @@ -207,28 +221,39 @@ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas, secu }) } +function schemaToMedia (schema) { + const media = { schema } + + if (schema.examples) { + media.examples = schema.examples + + // examples is invalid property of media object schema + delete schema.examples + } + + if (schema.example) { + media.example = schema.example + } + + if (schema[xExamples]) { + media.examples = schema[xExamples] + delete schema[xExamples] + } + + return media +} + function resolveBodyParams (body, schema, consumes, ref) { const resolved = transformDefsToComponents(ref.resolve(schema)) if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { consumes = ['application/json'] } + const media = schemaToMedia(resolved) consumes.forEach((consume) => { - // examples and example fields should be on the top level of the media object - const mediaObject = { schema: resolved } - if (resolved.examples) { - mediaObject.examples = resolved.examples - - // examples is invalid property of media object schema - delete resolved.examples - } - - if (resolved.example) { - mediaObject.example = resolved.example - } - - body.content[consume] = mediaObject + body.content[consume] = media }) + if (resolved && resolved.required && resolved.required.length) { body.required = true } @@ -305,10 +330,10 @@ function resolveResponse (fastifyResponseJson, produces, ref) { } delete resolved[xResponseDescription] + + const media = schemaToMedia(resolved) produces.forEach((produce) => { - content[produce] = { - schema: resolved - } + content[produce] = media }) response.content = content diff --git a/test/spec/openapi/option.js b/test/spec/openapi/option.js index a79c5a6d..349f08af 100644 --- a/test/spec/openapi/option.js +++ b/test/spec/openapi/option.js @@ -393,7 +393,64 @@ test('transforms examples in example if single object example', async (t) => { t.same(schema.properties.hello.example, { lorem: 'ipsum' }) }) -test('copy example from component to media', async (t) => { +test('move examples from "x-examples" to examples field', async (t) => { + t.plan(3) + const fastify = Fastify({ + ajv: { + plugins: [ + function (ajv) { + ajv.addKeyword({ keyword: 'x-examples' }) + } + ] + } + }) + + await fastify.register(fastifySwagger, openapiOption) + + const opts = { + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { + type: 'object', + properties: { + lorem: { + type: 'string' + } + } + } + }, + 'x-examples': { + 'lorem ipsum': { + summary: 'Roman statesman', + value: { lorem: 'ipsum' } + } + } + } + } + } + + fastify.post('/', opts, () => {}) + + await fastify.ready() + + const openapiObject = fastify.swagger() + const content = openapiObject.paths['/'].post.requestBody.content['application/json'] + const schema = content.schema + + t.ok(schema) + t.notOk(schema['x-examples']) + t.same(content.examples, { + 'lorem ipsum': { + summary: 'Roman statesman', + value: { lorem: 'ipsum' } + } + }) +}) + +test('copy example of body from component to media', async (t) => { t.plan(4) const fastify = Fastify() @@ -429,7 +486,92 @@ test('copy example from component to media', async (t) => { t.same(content.example, { hello: 'world' }) }) -test('move examples from component to media', async (t) => { +test('copy example of response from component to media', async (t) => { + t.plan(4) + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + + const response = { + type: 'object', + properties: { + hello: { + type: 'string' + } + }, + examples: [{ hello: 'world' }] + } + + const opts = { + schema: { + response: { 200: response } + } + } + + fastify.post('/', opts, () => {}) + + await fastify.ready() + + const openapiObject = fastify.swagger() + const content = openapiObject.paths['/'].post.responses['200'].content['application/json'] + const schema = content.schema + + t.ok(schema) + t.ok(schema.properties) + t.same(schema.example, { hello: 'world' }) + t.same(content.example, { hello: 'world' }) +}) + +test('copy example of parameters from component to media', async (t) => { + t.plan(7) + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + + const portSchema = { + type: 'number', + examples: [8080] + } + + const opts = { + schema: { + headers: { + 'X-Port': portSchema + }, + querystring: { + port: portSchema + }, + params: { + port: portSchema + } + } + } + + fastify.post('/:port', opts, () => {}) + + await fastify.ready() + + const openapiObject = fastify.swagger() + const parameters = openapiObject.paths['/{port}'].post.parameters + + t.ok(parameters) + + const paramsMap = new Map(parameters.map(param => [param.in, param])) + + const headerParam = paramsMap.get('header') + t.ok(headerParam) + t.same(headerParam.example, 8080) + + const queryParam = paramsMap.get('query') + t.ok(queryParam) + t.same(queryParam.example, 8080) + + const pathParam = paramsMap.get('path') + t.ok(pathParam) + t.same(pathParam.example, 8080) +}) + +test('move examples of body from component to media', async (t) => { t.plan(4) const fastify = Fastify() @@ -465,6 +607,96 @@ test('move examples from component to media', async (t) => { t.same(content.examples, { example1: { value: { hello: 'world' } }, example2: { value: { hello: 'lorem' } } }) }) +test('move examples of response from component to media', async (t) => { + t.plan(4) + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + + const response = { + type: 'object', + properties: { + hello: { + type: 'string' + } + }, + examples: [{ hello: 'world' }, { hello: 'lorem' }] + } + + const opts = { + schema: { + response: { 200: response } + } + } + + fastify.post('/', opts, () => {}) + + await fastify.ready() + + const openapiObject = fastify.swagger() + const content = openapiObject.paths['/'].post.responses['200'].content['application/json'] + const schema = content.schema + + t.ok(schema) + t.ok(schema.properties) + t.notOk(schema.examples) + t.same(content.examples, { example1: { value: { hello: 'world' } }, example2: { value: { hello: 'lorem' } } }) +}) + +test('move examples of parameters from component to media', async (t) => { + t.plan(7) + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + + const portSchema = { + type: 'number', + examples: [8080, 80] + } + + const opts = { + schema: { + headers: { + 'X-Port': portSchema + }, + querystring: { + port: portSchema + }, + params: { + port: portSchema + } + } + } + + fastify.post('/:port', opts, () => {}) + + await fastify.ready() + + const openapiObject = fastify.swagger() + const parameters = openapiObject.paths['/{port}'].post.parameters + + t.ok(parameters) + + const paramsMap = new Map(parameters.map(param => [param.in, param])) + + const expectedExamples = { + 80: { value: 80 }, + 8080: { value: 8080 } + } + + const headerParam = paramsMap.get('header') + t.ok(headerParam) + t.same(headerParam.examples, expectedExamples) + + const queryParam = paramsMap.get('query') + t.ok(queryParam) + t.same(queryParam.examples, expectedExamples) + + const pathParam = paramsMap.get('path') + t.ok(pathParam) + t.same(pathParam.examples, expectedExamples) +}) + test('uses examples if has multiple string examples', async (t) => { t.plan(3) const fastify = Fastify()