From d78ad1d68f8d742cbed3a9b65f634bf95a1911af Mon Sep 17 00:00:00 2001 From: Swain Molster Date: Thu, 18 Aug 2022 10:47:38 -0400 Subject: [PATCH 1/3] feat: support a built-in parse --- src/integration.koa.test.ts | 40 +++++++++++++++++++++++++++++++++++++ src/koa.ts | 34 ++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/integration.koa.test.ts b/src/integration.koa.test.ts index d3572d8..0a1a3dd 100644 --- a/src/integration.koa.test.ts +++ b/src/integration.koa.test.ts @@ -256,3 +256,43 @@ test('introspection', async () => { }, ); }); + +test('default parsing for POST', async () => { + await executeTest( + { + parse: undefined, + implementation: { + 'POST /posts': (ctx) => ctx.request.body, + }, + }, + async (client) => { + const result = await client.post('/posts'); + + expect(result).toMatchObject({ + status: 400, + data: "The request did not conform to the required schema: payload must have required property 'id'", + }); + }, + ); +}); + +test('default parsing for GET', async () => { + await executeTest( + { + parse: undefined, + implementation: { + 'GET /posts': (ctx) => ctx.request.body, + }, + }, + async (client) => { + const result = await client.get( + '/posts?input=something&input=something-else', + ); + + expect(result).toMatchObject({ + status: 400, + data: 'The request did not conform to the required schema: query parameters/input must be string', + }); + }, + ); +}); diff --git a/src/koa.ts b/src/koa.ts index 62813b3..caf26df 100644 --- a/src/koa.ts +++ b/src/koa.ts @@ -2,6 +2,7 @@ import { JSONSchema4 } from 'json-schema'; import type { ExtendableContext, ParameterizedContext } from 'koa'; import type Router from '@koa/router'; import type { EndpointsOf, IntrospectionResponse, OneSchema } from './types'; +import Ajv from 'ajv'; /** * We use this type to very cleanly remove these fields from the Koa context, so @@ -108,6 +109,8 @@ export type ImplementationConfig< * If the `data` does not conform to the schema, this function * should `throw`. * + * If not provided, a default parser will be used. + * * @param ctx The current context. * @param params.endpoint The endpoint being requested. * @param params.schema The request JSON Schema. @@ -115,7 +118,7 @@ export type ImplementationConfig< * * @returns A validated payload. */ - parse: >( + parse?: >( ctx: ParameterizedContext< StateOfRouter, ContextOfRouter @@ -127,6 +130,29 @@ export type ImplementationConfig< introspection: IntrospectionConfig | undefined; }; +const ajv = new Ajv(); + +const defaultParse: ImplementationConfig['parse'] = ( + ctx, + { endpoint, data, schema }, +) => { + if (!ajv.validate(schema, data)) { + const method = (endpoint as string).split(' ')[0]; + const dataVar = ['GET', 'DELETE'].includes(method) + ? 'query parameters' + : 'payload'; + + return ctx.throw( + 400, + `The request did not conform to the required schema: ${ajv.errorsText( + undefined, + { dataVar }, + )}`, + ); + } + return data as any; +}; + /** * Implements the specified `schema` on the provided router object. * @@ -172,17 +198,19 @@ export const implementSchema = < // 1. Validate the input data. const requestSchema = schema.Endpoints[endpoint].Request; if (requestSchema) { + const parser: typeof parse = parse ?? defaultParse; + // 1a. For GET and DELETE, validate the query params. if (['GET', 'DELETE'].includes(method)) { // @ts-ignore - ctx.request.query = parse(ctx, { + ctx.request.query = parser(ctx, { endpoint, schema: { ...requestSchema, definitions: schema.Resources }, data: ctx.request.query, }); } else { // 1b. Otherwise, use the body. - ctx.request.body = parse(ctx, { + ctx.request.body = parser(ctx, { endpoint, schema: { ...requestSchema, definitions: schema.Resources }, data: ctx.request.body, From a50653de5cb1fefa5c2cc09286291152853fed10 Mon Sep 17 00:00:00 2001 From: Swain Molster Date: Fri, 19 Aug 2022 14:46:09 -0400 Subject: [PATCH 2/3] update docs --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 542d5c5..32bb216 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,6 @@ const router = new Router(); implementSchema(Schema, { on: router, - parse: (ctx, { schema, data }) => { - // validate that `data` matches `schema`, using whatever - // library you like, and return the parsed response. - - return data; - }, implementation: { 'POST /items': (ctx) => { // `ctx.request.body` is well-typed and has been run-time validated. From 46050422027fab1bcf5810368dcbdcb65bd3571e Mon Sep 17 00:00:00 2001 From: Swain Molster Date: Mon, 22 Aug 2022 09:35:57 -0400 Subject: [PATCH 3/3] update docs and improve tests --- README.md | 13 +++++++++++ src/integration.koa.test.ts | 45 ++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 32bb216..e2986fa 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,19 @@ const server = new Koa() .listen(); ``` +By default, `implementSchema` will perform input validation on all of your routes, using the defined `Request` schemas. +To customize this input validation, specify a `parse` function: + +```typescript +implementSchema(Schema, { + // ... + parse: (ctx, { endpoint, schema, data }) => { + // Validate `data` against the `schema`. + // If the data is valid, return it, otherwise throw. + }, +}); +``` + ### Axios Client Generation Projects that want to safely consume a service that uses `one-schema` can perform introspection using `fetch-remote-schema`. diff --git a/src/integration.koa.test.ts b/src/integration.koa.test.ts index 0a1a3dd..37fedcd 100644 --- a/src/integration.koa.test.ts +++ b/src/integration.koa.test.ts @@ -257,7 +257,7 @@ test('introspection', async () => { ); }); -test('default parsing for POST', async () => { +test('default parsing for POST fails invalid data', async () => { await executeTest( { parse: undefined, @@ -266,7 +266,7 @@ test('default parsing for POST', async () => { }, }, async (client) => { - const result = await client.post('/posts'); + const result = await client.post('/posts', {}); expect(result).toMatchObject({ status: 400, @@ -276,7 +276,28 @@ test('default parsing for POST', async () => { ); }); -test('default parsing for GET', async () => { +test('default parsing for POST allows valid data', async () => { + await executeTest( + { + parse: undefined, + implementation: { + 'POST /posts': (ctx) => ctx.request.body, + }, + }, + async (client) => { + const result = await client.post('/posts', { + id: 'some-id', + message: 'some-message', + }); + + expect(result).toMatchObject({ + status: 200, + }); + }, + ); +}); + +test('default parsing for GET fails invalid data', async () => { await executeTest( { parse: undefined, @@ -296,3 +317,21 @@ test('default parsing for GET', async () => { }, ); }); + +test('default parsing for GET allows valid data', async () => { + await executeTest( + { + parse: undefined, + implementation: { + 'GET /posts': (ctx) => ctx.request.body, + }, + }, + async (client) => { + const result = await client.get('/posts?input=something'); + + expect(result).toMatchObject({ + status: 200, + }); + }, + ); +});