Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: path validation and typing #39

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
node_modules

dist

*.tgz

tmp
coverage

*.tgz
*.tsbuildinfo
*.test.js
*.test.d.ts
*.log
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

yarn typecheck
yarn test
yarn embed
2 changes: 1 addition & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"recommendations": ["gruntfuggly.todo-tree"]
"recommendations": ["gruntfuggly.todo-tree", "orta.vscode-jest"]
}
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ npx compeller@alpha new
- [x] Support for request body validation to type guard (ajv)
- [x] Support for header response types
- [ ] Support for response type mapping
- [ ] Support for path validation
- [ ] Support header validation
- [ ] Return the response statusCode
- [ ] Return the response headers
- [ ] Return the response body
- [ ] Support Parameter validation of request parameters within the OpenAPI specification.
- [ ] Support path validation
- [ ] Support header validation
- [ ] Support query validation
- [ ] Support cookie validation

### Usage

Expand Down
4 changes: 2 additions & 2 deletions __tests__/integration/aws/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ const { response, request } = API('/pets', 'post');
export const handler = (data: Record<string, unknown>) => {
let body = data;

if (request.validator(body)) {
if (request.validateBody(body)) {
console.info('Type-safe object destructured from post request', {
name: body.name,
});

return response('201', {});
} else {
const { errors } = request.validator;
const { errors } = request.validateBody;

if (errors) {
return response('422', {
Expand Down
20 changes: 20 additions & 0 deletions examples/parameters/api-gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { APIGatewayV1Responder, compeller } from '../../src';
import { OpenAPISpecification } from './openapi/spec';

const apiGatewayV1Compeller = compeller(OpenAPISpecification, {
responder: APIGatewayV1Responder,
});

console.info(
apiGatewayV1Compeller('v1/users/{id}', 'post').response(
'201',
{
version: '1.0.0',
},
{
'x-request-id': '<uuid>',
'x-rate-limit': 120,
'Content-Type': 'application/json',
}
)
);
24 changes: 24 additions & 0 deletions examples/parameters/custom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { compeller } from '../../src';
import { OpenAPISpecification } from './openapi/spec';

const customerCompeller = compeller(OpenAPISpecification, {
responder: (statusCode, body) => {
return typeof statusCode === 'string'
? {
statusCode: parseInt(statusCode),
body: JSON.stringify(body),
}
: {
statusCode,
body: JSON.stringify(body),
};
},
});

const body: Record<string, unknown> = {};
const headers: Record<string, unknown> = {};
const queryObject: Record<string, unknown> = {};

console.info(
customerCompeller('v1/users/{id}', 'post').request.validateBody({})
);
27 changes: 27 additions & 0 deletions examples/parameters/default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { compeller } from '../../src';
import { OpenAPISpecification } from './openapi/spec';

const defaultCompeller = compeller(OpenAPISpecification);

const { response, request } = defaultCompeller('v1/users/{id}', 'post');

// JSON Schema body validation
request.validateBody({});
// Validate path and query object
console.info(request.validateParameters);
// Validate headers
// request.validateHeaders({ 'x-api-key': '123aef-231' });

const res = response(
'201',
{
version: '1.0.0',
},
{
'x-rate-limit': 123,
'x-request-id': 'uuid',
'Content-Type': 'application/json',
}
);

console.info('Formatted default response', res);
84 changes: 84 additions & 0 deletions examples/parameters/openapi/spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { JSONSchema } from 'json-schema-to-ts';
import { ParameterObject } from 'openapi3-ts';

export const OpenAPISpecification = {
info: {
title: 'New API generated with compeller',
version: '1.0.0',
},
openapi: '3.1.0',
paths: {
'v1/users/{id}': {
post: {
parameters: [
{
name: 'id',
in: 'path',
description: 'user id to lookup',
required: true,
schema: {
type: 'number',
} as const,
} as const,
{
name: 'tags',
in: 'query',
description: 'tags to filter by',
required: false,
style: 'form',
schema: {
type: 'array',
items: {
type: 'string',
},
} as const,
} as const,
{
name: 'limit',
in: 'query',
description: 'maximum number of results to return',
required: false,
schema: {
type: 'integer',
format: 'int32',
},
} as const,
] as const,
responses: {
'201': {
description: 'Get the current API version',
headers: {
'x-rate-limit': {
description:
'The number of allowed requests in the current period',
schema: {
type: 'number',
} as const,
},
'x-request-id': {
description: 'The unique request id header',
schema: {
type: 'string',
} as const,
},
},
content: {
'application/json': {
schema: {
type: 'object',
required: ['version'],
additionalProperties: false,
properties: {
version: {
type: 'string',
},
},
} as const,
},
},
},
},
},
},
},
};
33 changes: 29 additions & 4 deletions src/compeller/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ const spec = {
paths: {
'/test': {
get: {
parameters: [
{
name: 'limit',
in: 'query',
description: 'How many items to return at one time (max 100)',
required: false,
schema: {
type: 'integer',
maximum: 10,
minimum: 0
} as const,
},
],
responses: {
'200': {
description: 'Test response',
Expand Down Expand Up @@ -38,9 +51,9 @@ const spec = {
describe('API Compiler tests', () => {
describe('get requests', () => {
it('requires a valid API document', () => {
const stuff = compeller(spec);
const compelled = compeller(spec);

const { response } = stuff('/test', 'get');
const { response } = compelled('/test', 'get');

const resp = response('200', { name: 'Type-safe reply' });

Expand All @@ -51,12 +64,12 @@ describe('API Compiler tests', () => {
});

it('keeps a local specification json when true', () => {
const stuff = compeller(spec, {
const compelled = compeller(spec, {
jsonSpecFile: join(__dirname, 'tmp', 'openapi.json'),
responder: defaultResponder,
});

const { response } = stuff('/test', 'get');
const { response } = compelled('/test', 'get');

const resp = response('200', { name: 'Type-safe reply' });

Expand All @@ -66,4 +79,16 @@ describe('API Compiler tests', () => {
});
});
});

describe('parameter validation', () => {
it('has schema validation for each parameter', () => {
const compelled = compeller(spec);

const { request } = compelled('/test', 'get');

expect(request.validateParameters({
limit: 200
})).toEqual(false);
});
});
});
70 changes: 42 additions & 28 deletions src/compeller/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import Ajv, { JSONSchemaType } from 'ajv';
import { FromSchema } from 'json-schema-to-ts';
import { OpenAPIObject } from 'openapi3-ts';

import { defaultResponder } from './responders';
import { writeSpecification } from './file-utils/write-specification';
import { defaultResponder } from './responders';
import { requestBodyValidator } from './validators';

export interface ICompellerOptions {
/**
Expand Down Expand Up @@ -83,15 +82,21 @@ export const compeller = <
RequestPath extends keyof T['paths'],
RequestMethod extends keyof T['paths'][RequestPath],
Responses extends T['paths'][RequestPath][RequestMethod]['responses'],
Request extends T['paths'][RequestPath][RequestMethod]
Request extends T['paths'][RequestPath][RequestMethod],
Parameters extends T['paths'][RequestPath][RequestMethod]['parameters']
>(
route: RequestPath,
method: RequestMethod
) => {
const path = route as string;
const {
requestBody: {
content: { [contentType]: { schema = undefined } = {} } = {},
} = {},
} = spec.paths[path][method];
const parameters = spec.paths[path][method].parameters as Parameters;

/**
*
* Build a response object for the API with the required status and body
* format
*
Expand Down Expand Up @@ -128,43 +133,52 @@ export const compeller = <
};

/**
* TODO - Validators need to be abstracted like responders
*
* The request validator attaches request body validation to the request
* handler for a path.
*
* @returns Ajv validation function for the inferred schema
*/
const validateRequestBody = <
SC extends Request['requestBody']['content'][ContentType]['schema']
>() => {
const {
requestBody: {
content: { [contentType]: { schema = undefined } = {} } = {},
} = {},
} = spec.paths[path][method];

// TODO: We need to handle the request which do not have a requestBody
//
// Some users might abstract the functional components into a generic
// wrapper, therefore gets might hit the validator path
//
// We don't want to lose type safety
const unsafeSchema = (schema || {}) as JSONSchemaType<FromSchema<SC>>;

const ajv = new Ajv({
allErrors: true,
});
>(
schema: Record<string, unknown>
) => {
return requestBodyValidator<SC>(schema);
};

return ajv.compile<FromSchema<SC>>(unsafeSchema);
/**
* The parameters validator validates the parameters section of the template
* and returns the parameters object, or a schema with errors
[
{
name: 'limit',
in: 'query',
required: false,
schema: {
type: 'integer',
format: 'int32',
},
}
]
*/
const validateRequestParameters = <
Parameters extends Request['parameters']
>(
parameters: Parameters
) => {
return parameters as {
[key in Parameters[number]['name']]: Parameters[number]['schema'];
};
};

const validator = validateRequestBody();
const validateBody = validateRequestBody(schema);
const validateParameters = validateRequestParameters(parameters);

return {
response,
request: {
validator,
validateBody,
validateParameters,
},
};
};
Expand Down
1 change: 1 addition & 0 deletions src/compeller/validators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './request-body';
Loading