diff --git a/examples/dynamic-openapi.js b/examples/dynamic-openapi.js index f0725400..220fe865 100644 --- a/examples/dynamic-openapi.js +++ b/examples/dynamic-openapi.js @@ -112,6 +112,59 @@ fastify.register(async function (fastify) { }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) }) }) +fastify.post('/subscribe', { + schema: { + description: 'subscribe for webhooks', + summary: 'webhook example', + security: [], + response: { + 201: { + description: 'Succesful response' + } + }, + body: { + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + }, + callbacks: { + myEvent: { + '{$request.body#/callbackUrl}': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Some event happened' + } + }, + required: [ + 'message' + ] + } + } + } + }, + responses: { + 200: { + description: 'Success' + } + } + } + } + } + } + } +}) + fastify.listen({ port: 3000 }, err => { if (err) throw err }) diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index 477d2c8e..e0058278 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -388,6 +388,51 @@ function resolveResponse (fastifyResponseJson, produces, ref) { return responsesContainer } +function resolveCallbacks (schema, ref) { + const callbacksContainer = {} + + for (const eventName in schema) { + if (!schema[eventName]) { + continue + } + + const eventSchema = schema[eventName] + const [callbackUrl] = Object.keys(eventSchema) + + if (!callbackUrl || !eventSchema[callbackUrl]) { + continue + } + + const callbackSchema = schema[eventName][callbackUrl] + const [httpMethodName] = Object.keys(callbackSchema) + + if (!httpMethodName || !callbackSchema[httpMethodName]) { + continue + } + + const httpMethodSchema = callbackSchema[httpMethodName] + const httpMethodContainer = {} + + if (httpMethodSchema.requestBody) { + httpMethodContainer.requestBody = convertJsonSchemaToOpenapi3( + ref.resolve(httpMethodSchema.requestBody) + ) + } + + httpMethodContainer.responses = httpMethodSchema.responses + ? convertJsonSchemaToOpenapi3(ref.resolve(httpMethodSchema.responses)) + : { '2XX': { description: 'Default Response' } } + + callbacksContainer[eventName] = { + [callbackUrl]: { + [httpMethodName]: httpMethodContainer + } + } + } + + return callbacksContainer +} + function prepareOpenapiMethod (schema, ref, openapiObject, url) { const openapiMethod = {} const parameters = [] @@ -432,6 +477,7 @@ function prepareOpenapiMethod (schema, ref, openapiObject, url) { if (schema.deprecated) openapiMethod.deprecated = schema.deprecated if (schema.security) openapiMethod.security = schema.security if (schema.servers) openapiMethod.servers = schema.servers + if (schema.callbacks) { openapiMethod.callbacks = resolveCallbacks(schema.callbacks, ref) } for (const key of Object.keys(schema)) { if (key.startsWith('x-')) { openapiMethod[key] = schema[key] diff --git a/test/spec/openapi/refs.js b/test/spec/openapi/refs.js index b4f4d01a..5cd7cd83 100644 --- a/test/spec/openapi/refs.js +++ b/test/spec/openapi/refs.js @@ -377,3 +377,56 @@ test('renders $ref schema with additional keywords', async (t) => { t.match(res.statusCode, 400) t.match(openapiObject.paths['/url1'].get.parameters[0].schema, cookie) }) + +test('support $ref in callbacks', async (t) => { + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.addSchema({ $id: 'Subscription', type: 'object', properties: { callbackUrl: { type: 'string', examples: ['https://example.com'] } } }) + instance.addSchema({ $id: 'Event', type: 'object', properties: { message: { type: 'string', examples: ['Some event happened'] } } }) + instance.post('/subscribe', { + schema: { + body: { + $ref: 'Subscription#' + }, + response: { + 200: { + $ref: 'Subscription#' + } + }, + callbacks: { + myEvent: { + '{$request.body#/callbackUrl}': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: 'Event#' } + } + } + }, + responses: { + 200: { + description: 'Success' + } + } + } + } + } + } + } + }, () => {}) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + + t.equal(typeof openapiObject, 'object') + t.match(Object.keys(openapiObject.components.schemas), ['Subscription', 'Event']) + t.equal(openapiObject.components.schemas.Subscription.properties.callbackUrl.example, 'https://example.com') + t.equal(openapiObject.components.schemas.Event.properties.message.example, 'Some event happened') + + await Swagger.validate(openapiObject) +}) diff --git a/test/spec/openapi/schema.js b/test/spec/openapi/schema.js index 0d388722..4d6c9f24 100644 --- a/test/spec/openapi/schema.js +++ b/test/spec/openapi/schema.js @@ -1136,3 +1136,430 @@ test('support multiple content types as request', async t => { } }) }) + +test('support callbacks', async () => { + test('includes callbacks in openapiObject', async t => { + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.post( + '/subscribe', + { + schema: { + body: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + }, + response: { + 200: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + } + }, + callbacks: { + myEvent: { + '{$request.body#/callbackUrl}': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Some event happened' + } + }, + required: ['message'] + } + } + } + }, + responses: { + 200: { + description: 'Success' + } + } + } + } + }, + myOtherEvent: { + '{$request.body#/callbackUrl}': { + post: { + responses: { + 200: { + description: 'Success' + }, + 500: { + description: 'Error' + } + } + } + } + } + } + } + }, + () => {} + ) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + + t.equal(typeof openapiObject, 'object') + t.equal(typeof openapiObject.paths['/subscribe'].post.callbacks, 'object') + + const definedPath = openapiObject.paths['/subscribe'].post.callbacks + + t.strictSame( + definedPath.myEvent['{$request.body#/callbackUrl}'].post.requestBody + .content['application/json'].schema.properties, + { + message: { + type: 'string', + example: 'Some event happened' + } + } + ) + + t.same( + definedPath.myOtherEvent['{$request.body#/callbackUrl}'].post.requestBody, + null + ) + + await Swagger.validate(openapiObject) + }) + + test('sets callback response default if not included', async t => { + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.post( + '/subscribe', + { + schema: { + body: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + }, + response: { + 200: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + } + }, + callbacks: { + myEvent: { + '{$request.body#/callbackUrl}': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Some event happened' + } + }, + required: ['message'] + } + } + } + } + } + } + } + } + } + }, + () => {} + ) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + + t.equal(typeof openapiObject, 'object') + t.equal(typeof openapiObject.paths['/subscribe'].post.callbacks, 'object') + + const definedPath = openapiObject.paths['/subscribe'].post + + t.equal( + definedPath.callbacks.myEvent['{$request.body#/callbackUrl}'].post + .responses['2XX'].description, + 'Default Response' + ) + + await Swagger.validate(openapiObject) + }) + + test('skips callbacks if badly formatted', async t => { + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.post( + '/subscribe', + { + schema: { + body: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + }, + response: { + 200: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + } + }, + callbacks: { + myEvent: null + } + } + }, + () => {} + ) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + + t.equal(typeof openapiObject, 'object') + t.strictSame(openapiObject.paths['/subscribe'].post.callbacks, {}) + + await Swagger.validate(openapiObject) + }) + + test('skips callback if event is badly formatted', async t => { + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.post( + '/subscribe', + { + schema: { + body: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + }, + response: { + 200: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + } + }, + callbacks: { + myEvent: { + '{$request.body#/callbackUrl}': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Some event happened' + } + }, + required: ['message'] + } + } + } + }, + responses: { + 200: { + description: 'Success' + } + } + } + } + }, + myOtherEvent: { + '{$request.body#/callbackUrl}': {} + } + } + } + }, + () => {} + ) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + + t.equal(typeof openapiObject, 'object') + t.equal(typeof openapiObject.paths['/subscribe'].post.callbacks, 'object') + t.match(Object.keys(openapiObject.paths['/subscribe'].post.callbacks), [ + 'myEvent' + ]) + + const definedPath = openapiObject.paths['/subscribe'].post.callbacks + + t.strictSame( + definedPath.myEvent['{$request.body#/callbackUrl}'].post.requestBody + .content['application/json'].schema.properties, + { + message: { + type: 'string', + example: 'Some event happened' + } + } + ) + + await Swagger.validate(openapiObject) + }) + + test('skips callback if method is badly formatted', async t => { + const fastify = Fastify() + + await fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.post( + '/subscribe', + { + schema: { + body: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + }, + response: { + 200: { + $id: 'Subscription', + type: 'object', + properties: { + callbackUrl: { + type: 'string', + examples: ['https://example.com'] + } + } + } + }, + callbacks: { + myEvent: { + '{$request.body#/callbackUrl}': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Some event happened' + } + }, + required: ['message'] + } + } + } + }, + responses: { + 200: { + description: 'Success' + } + } + } + } + }, + myOtherEvent: {} + } + } + }, + () => {} + ) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + + t.equal(typeof openapiObject, 'object') + t.equal(typeof openapiObject.paths['/subscribe'].post.callbacks, 'object') + t.match(Object.keys(openapiObject.paths['/subscribe'].post.callbacks), [ + 'myEvent' + ]) + + const definedPath = openapiObject.paths['/subscribe'].post.callbacks + + t.strictSame( + definedPath.myEvent['{$request.body#/callbackUrl}'].post.requestBody + .content['application/json'].schema.properties, + { + message: { + type: 'string', + example: 'Some event happened' + } + } + ) + + await Swagger.validate(openapiObject) + }) +})