diff --git a/src/introspection.ts b/src/introspection.ts new file mode 100644 index 0000000..f0d06ff --- /dev/null +++ b/src/introspection.ts @@ -0,0 +1,50 @@ +import { + IntrospectionConfig, + IntrospectionResponse, + OneSchemaDefinition, +} from './types'; +import type Router from '@koa/router'; +import { toOpenAPISpec } from './openapi'; + +export const addIntrospection = ( + introspection: IntrospectionConfig, + /** + * A function that returns the latest schema for the service. We need + * to use a function because a `OneSchemaRouter`'s schema does not + * fully exist yet during its constructor call, which is when this function + * is executed. So any time the introspect route is called, the most + * up-to-date schema will be returned. + */ + getSchema: () => OneSchemaDefinition, + /** + * The service's default router that all the endpoints are + * declared on. + */ + router: Router, +) => { + const introRouter = introspection.router || router; + introRouter.get(introspection.route, (ctx, next) => { + const response: IntrospectionResponse = { + schema: getSchema(), + serviceVersion: introspection.serviceVersion, + }; + + ctx.body = response; + ctx.status = 200; + return next(); + }); + if (introspection.openApi) { + const { info } = introspection.openApi; + introRouter.get(introspection.openApi.route, (ctx, next) => { + const response = toOpenAPISpec(getSchema(), { + info: { + ...info, + version: introspection.serviceVersion, + }, + }); + ctx.body = response; + ctx.status = 200; + return next(); + }); + } +}; diff --git a/src/koa.ts b/src/koa.ts index ad34422..54ef06e 100644 --- a/src/koa.ts +++ b/src/koa.ts @@ -1,7 +1,7 @@ import { JSONSchema4 } from 'json-schema'; import type { ParameterizedContext } from 'koa'; import type Router from '@koa/router'; -import type { EndpointsOf, IntrospectionResponse, OneSchema } from './types'; +import type { EndpointsOf, IntrospectionConfig, OneSchema } from './types'; import Ajv from 'ajv'; import { ContextOfRouter, @@ -9,6 +9,7 @@ import { implementRoute, StateOfRouter, } from './koa-utils'; +import { addIntrospection } from './introspection'; export type ImplementationOf< Schema extends OneSchema, @@ -22,24 +23,6 @@ export type ImplementationOf< >; }; -export type IntrospectionConfig = { - /** - * A route at which to serve the introspection request on the implementing - * Router object. - * - * A GET method will be supported on this route, and will return introspection data. - */ - route: string; - /** - * The current version of the service, served as part of introspection. - */ - serviceVersion: string; - /** - * An optional alternative router to use for the introspection route. - */ - router?: Router; -}; - /** * An implementation configuration for an API. */ @@ -127,17 +110,7 @@ export const implementSchema = < }: ImplementationConfig, ): void => { if (introspection) { - const introRouter = introspection.router || router; - introRouter.get(introspection.route, (ctx, next) => { - const response: IntrospectionResponse = { - schema, - serviceVersion: introspection.serviceVersion, - }; - - ctx.body = response; - ctx.status = 200; - return next(); - }); + addIntrospection(introspection, () => schema, router); } // Iterate through every handler, and add a route for it based on diff --git a/src/router.test.ts b/src/router.test.ts index 3af4035..e095671 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -638,6 +638,145 @@ describe('introspection', () => { }, }); }); + + it('can generate an OpenAPI schema', async () => { + const { client } = setup(() => + OneSchemaRouter.create({ + using: new Router(), + introspection: { + route: '/private/introspection', + serviceVersion: '123', + openApi: { + route: '/private/openapi', + info: { + title: 'My Service', + }, + }, + }, + }) + .declare({ + name: 'getSomething', + route: 'GET /something/:id', + description: 'it gets something', + request: z.object({ filter: z.string() }), + response: z.object({ message: z.string(), id: z.string() }), + }) + .implement('GET /something/:id', () => ({ id: '', message: '' })) + .declare({ + name: 'createSomething', + route: 'POST /something', + description: 'it creates something', + request: z.object({ message: z.string() }), + response: z.object({ message: z.string(), id: z.string() }), + }) + .implement('POST /something', () => ({ id: '', message: '' })), + ); + + const result = await client.get('/private/openapi'); + expect(result.data).toStrictEqual({ + openapi: '3.0.0', + info: { + title: 'My Service', + version: '123', + }, + components: {}, + paths: { + '/something/{id}': { + get: { + operationId: 'getSomething', + description: 'it gets something', + responses: { + '200': { + description: 'A successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + }, + id: { + type: 'string', + }, + }, + required: ['message', 'id'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + }, + }, + }, + parameters: [ + { + name: 'id', + in: 'path', + schema: { + type: 'string', + }, + required: true, + }, + { + in: 'query', + name: 'filter', + schema: { + type: 'string', + }, + required: true, + }, + ], + }, + }, + '/something': { + post: { + operationId: 'createSomething', + description: 'it creates something', + responses: { + '200': { + description: 'A successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + }, + id: { + type: 'string', + }, + }, + required: ['message', 'id'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + }, + }, + }, + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + }, + }, + required: ['message'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + }, + }, + }, + }, + }, + }); + }); }); test('declaring multiple routes with the same name results in an error', () => { diff --git a/src/router.ts b/src/router.ts index 4b2a621..14639be 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,14 +3,14 @@ import { z } from 'zod'; import { fromZodError } from 'zod-validation-error'; import zodToJsonSchema from 'zod-to-json-schema'; import compose = require('koa-compose'); -import { IntrospectionConfig } from './koa'; import { EndpointImplementation, implementRoute, PathParamsOf, } from './koa-utils'; -import { IntrospectionResponse, OneSchemaDefinition } from './types'; import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { addIntrospection } from './introspection'; +import { IntrospectionConfig, OneSchemaDefinition } from './types'; export type RouterEndpointDefinition = { name: Name; @@ -57,20 +57,12 @@ export class OneSchemaRouter< ) { const { introspection, using: router } = config; this.router = router; - if (introspection) { - const introRouter = introspection.router || router; - - introRouter.get(introspection.route, (ctx, next) => { - const response: IntrospectionResponse = { - serviceVersion: introspection.serviceVersion, - schema: convertRouterSchemaToJSONSchemaStyle(this.schema), - }; - - ctx.body = response; - ctx.status = 200; - return next(); - }); + addIntrospection( + introspection, + () => convertRouterSchemaToJSONSchemaStyle(this.schema), + router, + ); } } diff --git a/src/types.ts b/src/types.ts index 9f1b105..5258fe2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ +import type Router from '@koa/router'; import type { JSONSchema4 } from 'json-schema'; +import type { OpenAPIV3 } from 'openapi-types'; export type EndpointDefinition = { Name: string; @@ -40,6 +42,40 @@ export type EndpointsOf> = NonNullable< Schema['__endpoints_type__'] >; +export type IntrospectionConfig = { + /** + * A route at which to serve the introspection request on the implementing + * Router object. + * + * A GET method will be supported on this route, and will return introspection data. + */ + route: string; + /** + * If provided, an endpoint for returning the OpenAPI schema for the implementing router + * will be set up. + */ + openApi?: { + /** + * A route at which to serve the OpenAPI schema for the implementing router object. + * + * A GET method will be supported on this route, and will return the OpenAPI schema. + */ + route: string; + /** + * API metadata info to include in the returned OpenAPI schema. + */ + info: Omit; + }; + /** + * The current version of the service, served as part of introspection. + */ + serviceVersion: string; + /** + * An optional alternative router to use for the introspection routes. + */ + router?: Router; +}; + export type IntrospectionResponse = { schema: OneSchemaDefinition; serviceVersion: string;