diff --git a/.gitignore b/.gitignore index db5f764..c6490f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ node_modules dist - -*.tgz - tmp +coverage +*.tgz *.tsbuildinfo *.test.js *.test.d.ts +*.log \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 36e1003..49e40dc 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,6 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" +yarn typecheck yarn test yarn embed diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3ebe77e..fc0146c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["gruntfuggly.todo-tree"] + "recommendations": ["gruntfuggly.todo-tree", "orta.vscode-jest"] } diff --git a/README.md b/README.md index 0c64ee6..4531424 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/integration/aws/index.test.ts b/__tests__/integration/aws/index.test.ts index 6e1e0f1..4b4b46d 100644 --- a/__tests__/integration/aws/index.test.ts +++ b/__tests__/integration/aws/index.test.ts @@ -9,14 +9,14 @@ const { response, request } = API('/pets', 'post'); export const handler = (data: Record) => { 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', { diff --git a/examples/parameters/api-gateway.ts b/examples/parameters/api-gateway.ts new file mode 100644 index 0000000..aed9e61 --- /dev/null +++ b/examples/parameters/api-gateway.ts @@ -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': '', + 'x-rate-limit': 120, + 'Content-Type': 'application/json', + } + ) +); diff --git a/examples/parameters/custom.ts b/examples/parameters/custom.ts new file mode 100644 index 0000000..f6dc0a0 --- /dev/null +++ b/examples/parameters/custom.ts @@ -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 = {}; +const headers: Record = {}; +const queryObject: Record = {}; + +console.info( + customerCompeller('v1/users/{id}', 'post').request.validateBody({}) +); diff --git a/examples/parameters/default.ts b/examples/parameters/default.ts new file mode 100644 index 0000000..2fd64ca --- /dev/null +++ b/examples/parameters/default.ts @@ -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); diff --git a/examples/parameters/openapi/spec.ts b/examples/parameters/openapi/spec.ts new file mode 100644 index 0000000..60ffd16 --- /dev/null +++ b/examples/parameters/openapi/spec.ts @@ -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, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/compeller/index.test.ts b/src/compeller/index.test.ts index 982f409..984bcdf 100644 --- a/src/compeller/index.test.ts +++ b/src/compeller/index.test.ts @@ -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', @@ -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' }); @@ -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' }); @@ -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); + }); + }); }); diff --git a/src/compeller/index.ts b/src/compeller/index.ts index a7d6f97..07f6d5a 100644 --- a/src/compeller/index.ts +++ b/src/compeller/index.ts @@ -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 { /** @@ -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 * @@ -128,8 +133,6 @@ 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. * @@ -137,34 +140,45 @@ export const compeller = < */ 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>; - - const ajv = new Ajv({ - allErrors: true, - }); + >( + schema: Record + ) => { + return requestBodyValidator(schema); + }; - return ajv.compile>(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, }, }; }; diff --git a/src/compeller/validators/index.ts b/src/compeller/validators/index.ts new file mode 100644 index 0000000..49996f3 --- /dev/null +++ b/src/compeller/validators/index.ts @@ -0,0 +1 @@ +export * from './request-body'; diff --git a/src/compeller/validators/request-body.test.ts b/src/compeller/validators/request-body.test.ts new file mode 100644 index 0000000..6a63e68 --- /dev/null +++ b/src/compeller/validators/request-body.test.ts @@ -0,0 +1,64 @@ +import { requestBodyValidator } from './request-body'; + +describe('validateRequestBody', () => { + it('errors are null for empty body', () => { + const validator = requestBodyValidator({}); + + validator({}); + + expect(validator.errors).toEqual(null); + }); + + it('infers the return type from a JSON schema', () => { + const testSchema = { + type: 'object', + required: ['name', 'meta'], + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + age: { + type: 'number', + maximum: 200, + minimum: 100, + }, + meta: { + type: 'object', + required: ['createdAt'], + additionalProperties: false, + properties: { + createdAt: { + type: 'string', + }, + }, + }, + }, + } as const; + + const validator = requestBodyValidator(testSchema); + + let data = {}; + + if (validator(data)) { + data.name; + } + + expect(validator.errors).toEqual([ + { + instancePath: '', + keyword: 'required', + message: "must have required property 'name'", + params: { missingProperty: 'name' }, + schemaPath: '#/required', + }, + { + instancePath: '', + keyword: 'required', + message: "must have required property 'meta'", + params: { missingProperty: 'meta' }, + schemaPath: '#/required', + }, + ]); + }); +}); diff --git a/src/compeller/validators/request-body.ts b/src/compeller/validators/request-body.ts new file mode 100644 index 0000000..4c24c94 --- /dev/null +++ b/src/compeller/validators/request-body.ts @@ -0,0 +1,25 @@ +import Ajv, { JSONSchemaType, ValidateFunction } from 'ajv'; +import { FromSchema } from 'json-schema-to-ts'; + +/** + * The request body wraps AJV schema validation with a safe failure to using an + * empty object (get, options, head requests etc.) + * + * @param schema A JSON Schema + * @returns {ValidateFunction} A validator function that holds schema errors + */ +export const requestBodyValidator = ( + schema: Record +) => { + const unsafeSchema = (schema || {}) as JSONSchemaType>; + + // TODO: We have an AJV instance here, if we make the body, path and headers + // validator share a single AJV instance we will have a faster code, with a + // smaller memory allocation. However, we will need to have all the same error + // handling, and configuration + const ajv = new Ajv({ + allErrors: true, + }); + + return ajv.compile(unsafeSchema); +};