Skip to content

Commit

Permalink
Merge pull request #50 from lifeomic/optional-parse
Browse files Browse the repository at this point in the history
feat: support a built-in parse
  • Loading branch information
swain authored Aug 22, 2022
2 parents 9e7db8a + 4605042 commit 65256cc
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 9 deletions.
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -176,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`.
Expand Down
79 changes: 79 additions & 0 deletions src/integration.koa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,82 @@ test('introspection', async () => {
},
);
});

test('default parsing for POST fails invalid data', 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 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,
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',
});
},
);
});

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,
});
},
);
});
34 changes: 31 additions & 3 deletions src/koa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,14 +109,16 @@ 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.
* @param params.data The payload to validate.
*
* @returns A validated payload.
*/
parse: <Endpoint extends keyof EndpointsOf<Schema>>(
parse?: <Endpoint extends keyof EndpointsOf<Schema>>(
ctx: ParameterizedContext<
StateOfRouter<RouterType>,
ContextOfRouter<RouterType>
Expand All @@ -127,6 +130,29 @@ export type ImplementationConfig<
introspection: IntrospectionConfig | undefined;
};

const ajv = new Ajv();

const defaultParse: ImplementationConfig<any, any>['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.
*
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 65256cc

Please sign in to comment.