Skip to content

Commit

Permalink
feat: optional openapi schema introspection
Browse files Browse the repository at this point in the history
You can now optionally configure an OpenAPI introspection endpoint for your
`one-schema` service. Specifically, you can add an `openapi` field to your
`introspection` config in this way:

```typescript
OneSchemaRouter.create({
  using: new Router(),
  introspection: {
    route: "/private/introspection",
    serviceVersion: "123",
    openApi: {
      route: "/private/openapi",
      info: {
        title: "My Service",
      },
    },
  },
});
```

The above code will allow a consumer to fetch the OpenAPI schema for this
service by calling the `/private/openapi` route. The `info` block is the OpenAPI
[schema metadata](https://swagger.io/specification/#info-object) for the
service.
  • Loading branch information
epeters3 committed Jul 13, 2023
1 parent 8bae395 commit b1760e9
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 45 deletions.
50 changes: 50 additions & 0 deletions src/introspection.ts
Original file line number Diff line number Diff line change
@@ -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<any, any>,
) => {
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();
});
}
};
33 changes: 3 additions & 30 deletions src/koa.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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,
EndpointImplementation,
implementRoute,
StateOfRouter,
} from './koa-utils';
import { addIntrospection } from './introspection';

export type ImplementationOf<
Schema extends OneSchema<any>,
Expand All @@ -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<any, any>;
};

/**
* An implementation configuration for an API.
*/
Expand Down Expand Up @@ -127,17 +110,7 @@ export const implementSchema = <
}: ImplementationConfig<Schema, RouterType>,
): 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
Expand Down
139 changes: 139 additions & 0 deletions src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 7 additions & 15 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: Name;
Expand Down Expand Up @@ -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,
);
}
}

Expand Down
36 changes: 36 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -40,6 +42,40 @@ export type EndpointsOf<Schema extends OneSchema<any>> = 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<OpenAPIV3.InfoObject, 'version'>;
};
/**
* 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<any, any>;
};

export type IntrospectionResponse = {
schema: OneSchemaDefinition;
serviceVersion: string;
Expand Down

0 comments on commit b1760e9

Please sign in to comment.