From 67d3ceee7f7b3099da77aad6fd87f18a5e258de3 Mon Sep 17 00:00:00 2001 From: David Brown Date: Tue, 2 May 2023 16:58:21 +0200 Subject: [PATCH 1/4] feat: support using a custom router for introspection --- README.md | 37 +++++++++++++++++++++++++ src/koa.test.ts | 2 +- src/koa.ts | 12 ++++++-- src/router.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++-- src/router.ts | 22 ++++++++++----- 5 files changed, 128 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ae63b75..b8f9640 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,43 @@ const app = new Koa().use(router.middleware()); app.listen(); ``` +In case the main router requires authorization headers, and you want to query the introspection route without them, you can expose it on a custom router +like so: + +```typescript +const router = OneSchemaRouter.create({ + using: new Router(), + introspection: { + route: '/introspection', + router: new Router({ prefix: '/private' }), + serviceVersion: process.env.LIFEOMIC_SERVICE_VERSION, + }, +}) + .declare({ + route: 'POST /items', + name: 'createItem', + request: z.object({ message: z.string() }), + response: z.object({ id: z.string(), message: z.string() }), + }) + .declare({ + route: 'GET /items/:id', + name: 'getItemById', + request: z.object({ filter: z.string() }), + response: z.object({ id: z.string(), message: z.string() }), + }); + +// Be sure to expose your router's routes on a Koa app. +import Koa from 'koa'; + +const app = new Koa().use(router.middleware()); + +if (router.config.instrospection.router) { + app.use(router.config.introspection.router.middleware()); +} + +app.listen(); +``` + Once you have routes declared, add implementations for each route. Enjoy perfect type inference and auto-complete for path parameters, query parameters, and the request body. ```typescript diff --git a/src/koa.test.ts b/src/koa.test.ts index 9cf1399..7f8e0c5 100644 --- a/src/koa.test.ts +++ b/src/koa.test.ts @@ -118,7 +118,7 @@ test('router typing is inferred correctly', () => { parse: () => null as any, on: router, implementation: { - 'GET /dummy-route': (ctx) => { + 'GET /dummy-route': (ctx: any) => { // assert state is extended correctly ctx.state.dummyStateProperty; diff --git a/src/koa.ts b/src/koa.ts index 513a576..918b79b 100644 --- a/src/koa.ts +++ b/src/koa.ts @@ -22,7 +22,7 @@ export type ImplementationOf< >; }; -export type IntrospectionConfig = { +export type IntrospectionConfig> = { /** * A route at which to serve the introspection request on the implementing * Router object. @@ -34,6 +34,10 @@ export type IntrospectionConfig = { * The current version of the service, served as part of introspection. */ serviceVersion: string; + /** + * An optional alernative router to use for the introspection route. + */ + router?: R; }; /** @@ -42,6 +46,7 @@ export type IntrospectionConfig = { export type ImplementationConfig< Schema extends OneSchema, RouterType extends Router, + IntrospectionRouterType extends Router = RouterType, > = { /** * The implementation of the API. @@ -78,7 +83,7 @@ export type ImplementationConfig< ) => Schema['Endpoints'][Endpoint]['Request']; /** A configuration for supporting introspection. */ - introspection: IntrospectionConfig | undefined; + introspection: IntrospectionConfig | undefined; }; const ajv = new Ajv(); @@ -123,7 +128,8 @@ export const implementSchema = < }: ImplementationConfig, ): void => { if (introspection) { - router.get(introspection.route, (ctx, next) => { + const introRouter = introspection.router || router; + introRouter.get(introspection.route, (ctx, next) => { const response: IntrospectionResponse = { schema, serviceVersion: introspection.serviceVersion, diff --git a/src/router.test.ts b/src/router.test.ts index a866c0d..435dd6b 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -14,7 +14,7 @@ afterEach(() => { server?.close(); }); -const setup = >( +const setup = >( expose: (router: OneSchemaRouter<{}, Router>) => T, ): { client: AxiosInstance } => { const router = expose( @@ -26,7 +26,13 @@ const setup = >( const serve = ( router: OneSchemaRouter<{}, Router>, ): { client: AxiosInstance } => { - server = new Koa().use(bodyparser()).use(router.middleware()).listen(); + const koa = new Koa().use(bodyparser()).use(router.middleware()); + + if (router.config.introspection?.router) { + koa.use(router.config.introspection.router.middleware()); + } + + server = koa.listen(); const { port } = server.address() as any; @@ -120,6 +126,63 @@ test('introspection', async () => { }); }); +test('introspection with custom router', async () => { + const { client } = setup(() => + OneSchemaRouter.create({ + using: new Router(), + introspection: { + route: '/private/introspection', + serviceVersion: '123', + router: new Router({ prefix: '/custom' }), + }, + }) + .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: '' })), + ); + + const wrongIntroRouter = await client.get('/private/introspection'); + expect(wrongIntroRouter.status).toStrictEqual(404); + + const result = await client.get('/custom/private/introspection'); + + expect(result.data).toStrictEqual({ + serviceVersion: '123', + schema: { + Endpoints: { + 'GET /something/:id': { + Description: 'it gets something', + Name: 'getSomething', + Request: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + filter: { type: 'string' }, + }, + required: ['filter'], + type: 'object', + }, + Response: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + id: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['message', 'id'], + type: 'object', + }, + }, + }, + }, + }); +}); + describe('type inference', () => { test('type inference for implementation return type', () => { setup((router) => @@ -311,7 +374,7 @@ describe('implementations', () => { response: z.object({ message: z.string() }), }) .implement(`${method} /items`, (ctx) => ({ - message: ctx.request.query.message + '-response', + message: (ctx.request.query.message as string) + '-response', })), ); diff --git a/src/router.ts b/src/router.ts index 60437e9..cd01549 100644 --- a/src/router.ts +++ b/src/router.ts @@ -21,24 +21,32 @@ type ZodSchema = { [route: string]: RouterEndpointDefinition; }; -export type OneSchemaRouterConfig> = { +export type OneSchemaRouterConfig< + R extends Router, + IR extends Router = R, +> = { using: R; - introspection: IntrospectionConfig | undefined; + introspection: IntrospectionConfig | undefined; }; export class OneSchemaRouter< Schema extends ZodSchema, R extends Router, + IR extends Router = R, > { private router: R; private constructor( private schema: Schema, - { introspection, using: router }: OneSchemaRouterConfig, + public config: OneSchemaRouterConfig, ) { + const { introspection, using: router } = config; this.router = router; + if (introspection) { - router.get(introspection.route, (ctx, next) => { + const introRouter = introspection.router || router; + + introRouter.get(introspection.route, (ctx, next) => { const response: IntrospectionResponse = { serviceVersion: introspection.serviceVersion, schema: convertRouterSchemaToJSONSchemaStyle(this.schema), @@ -51,9 +59,9 @@ export class OneSchemaRouter< } } - static create>( - config: OneSchemaRouterConfig, - ): OneSchemaRouter<{}, R> { + static create, IR extends Router = R>( + config: OneSchemaRouterConfig, + ): OneSchemaRouter<{}, R, IR> { return new OneSchemaRouter({}, config); } From 6b37919cbea2c2724b17b7539363a8b53f04ab4c Mon Sep 17 00:00:00 2001 From: David Brown Date: Tue, 2 May 2023 17:11:09 +0200 Subject: [PATCH 2/4] fix: simplify typing --- src/koa.ts | 7 +++---- src/router.test.ts | 2 +- src/router.ts | 16 ++++++---------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/koa.ts b/src/koa.ts index 918b79b..e82c283 100644 --- a/src/koa.ts +++ b/src/koa.ts @@ -22,7 +22,7 @@ export type ImplementationOf< >; }; -export type IntrospectionConfig> = { +export type IntrospectionConfig = { /** * A route at which to serve the introspection request on the implementing * Router object. @@ -37,7 +37,7 @@ export type IntrospectionConfig> = { /** * An optional alernative router to use for the introspection route. */ - router?: R; + router?: Router; }; /** @@ -46,7 +46,6 @@ export type IntrospectionConfig> = { export type ImplementationConfig< Schema extends OneSchema, RouterType extends Router, - IntrospectionRouterType extends Router = RouterType, > = { /** * The implementation of the API. @@ -83,7 +82,7 @@ export type ImplementationConfig< ) => Schema['Endpoints'][Endpoint]['Request']; /** A configuration for supporting introspection. */ - introspection: IntrospectionConfig | undefined; + introspection: IntrospectionConfig | undefined; }; const ajv = new Ajv(); diff --git a/src/router.test.ts b/src/router.test.ts index 435dd6b..1eb5a43 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -14,7 +14,7 @@ afterEach(() => { server?.close(); }); -const setup = >( +const setup = >( expose: (router: OneSchemaRouter<{}, Router>) => T, ): { client: AxiosInstance } => { const router = expose( diff --git a/src/router.ts b/src/router.ts index cd01549..a7a3b2c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -21,24 +21,20 @@ type ZodSchema = { [route: string]: RouterEndpointDefinition; }; -export type OneSchemaRouterConfig< - R extends Router, - IR extends Router = R, -> = { +export type OneSchemaRouterConfig> = { using: R; - introspection: IntrospectionConfig | undefined; + introspection: IntrospectionConfig | undefined; }; export class OneSchemaRouter< Schema extends ZodSchema, R extends Router, - IR extends Router = R, > { private router: R; private constructor( private schema: Schema, - public config: OneSchemaRouterConfig, + public config: OneSchemaRouterConfig, ) { const { introspection, using: router } = config; this.router = router; @@ -59,9 +55,9 @@ export class OneSchemaRouter< } } - static create, IR extends Router = R>( - config: OneSchemaRouterConfig, - ): OneSchemaRouter<{}, R, IR> { + static create>( + config: OneSchemaRouterConfig, + ): OneSchemaRouter<{}, R> { return new OneSchemaRouter({}, config); } From 55902da9a8a3a297560b59db321260fb5605cdda Mon Sep 17 00:00:00 2001 From: David Brown Date: Tue, 2 May 2023 18:13:08 +0200 Subject: [PATCH 3/4] fix: update the middlewares method --- src/koa.ts | 2 +- src/router.test.ts | 8 +------- src/router.ts | 12 ++++++++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/koa.ts b/src/koa.ts index e82c283..ad34422 100644 --- a/src/koa.ts +++ b/src/koa.ts @@ -35,7 +35,7 @@ export type IntrospectionConfig = { */ serviceVersion: string; /** - * An optional alernative router to use for the introspection route. + * An optional alternative router to use for the introspection route. */ router?: Router; }; diff --git a/src/router.test.ts b/src/router.test.ts index 1eb5a43..45de1c6 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -26,13 +26,7 @@ const setup = >( const serve = ( router: OneSchemaRouter<{}, Router>, ): { client: AxiosInstance } => { - const koa = new Koa().use(bodyparser()).use(router.middleware()); - - if (router.config.introspection?.router) { - koa.use(router.config.introspection.router.middleware()); - } - - server = koa.listen(); + server = new Koa().use(bodyparser()).use(router.middleware()).listen(); const { port } = server.address() as any; diff --git a/src/router.ts b/src/router.ts index a7a3b2c..e4ac861 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,7 +34,7 @@ export class OneSchemaRouter< private constructor( private schema: Schema, - public config: OneSchemaRouterConfig, + private config: OneSchemaRouterConfig, ) { const { introspection, using: router } = config; this.router = router; @@ -115,7 +115,15 @@ export class OneSchemaRouter< } middleware(): Router.Middleware { - return compose([this.router.routes(), this.router.allowedMethods()]); + const middlewares = [this.router.routes(), this.router.allowedMethods()]; + + if (this.config.introspection?.router) { + middlewares.push( + this.config.introspection.router.routes(), + this.config.introspection.router.allowedMethods(), + ); + } + return compose(middlewares); } } From b4295516080ede9ddc7c31bf9c410704393c1574 Mon Sep 17 00:00:00 2001 From: David Brown Date: Tue, 2 May 2023 18:14:44 +0200 Subject: [PATCH 4/4] chore: update readme --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index b8f9640..3c811d2 100644 --- a/README.md +++ b/README.md @@ -77,17 +77,6 @@ const router = OneSchemaRouter.create({ request: z.object({ filter: z.string() }), response: z.object({ id: z.string(), message: z.string() }), }); - -// Be sure to expose your router's routes on a Koa app. -import Koa from 'koa'; - -const app = new Koa().use(router.middleware()); - -if (router.config.instrospection.router) { - app.use(router.config.introspection.router.middleware()); -} - -app.listen(); ``` Once you have routes declared, add implementations for each route. Enjoy perfect type inference and auto-complete for path parameters, query parameters, and the request body.