diff --git a/src/generate-api-types.test.ts b/src/generate-api-types.test.ts index 4cab905..587c5a9 100644 --- a/src/generate-api-types.test.ts +++ b/src/generate-api-types.test.ts @@ -25,6 +25,11 @@ describe('generate', () => { }, }, }, + 'DELETE /posts/:id': { + Name: 'deletePost', + Response: {}, + // Test no Request field + }, 'PUT /posts/:id': { Name: 'putPost', Request: { @@ -60,6 +65,13 @@ export type Endpoints = { output?: string; }; }; + "DELETE /posts/:id": { + Request: unknown; + PathParams: { + id: string; + }; + Response: unknown; + }; "PUT /posts/:id": { Request: { message: string; @@ -90,6 +102,7 @@ export const Schema: OneSchema = { properties: { output: { type: "string" } }, }, }, + "DELETE /posts/:id": { Name: "deletePost", Response: {} }, "PUT /posts/:id": { Name: "putPost", Request: { diff --git a/src/generate-endpoints.ts b/src/generate-endpoints.ts index 31de83e..0e313a4 100644 --- a/src/generate-endpoints.ts +++ b/src/generate-endpoints.ts @@ -23,7 +23,7 @@ export const generateEndpointTypes = async ({ additionalProperties: false, required: ['Request', 'PathParams', 'Response'], properties: { - Request, + Request: Request ?? {}, PathParams: { type: 'object', additionalProperties: false, diff --git a/src/koa.ts b/src/koa.ts index ceb0a45..2af783d 100644 --- a/src/koa.ts +++ b/src/koa.ts @@ -80,15 +80,18 @@ export const implementSchema = >( /** A shared route handler. */ const handler: Router.IMiddleware = async (ctx, next) => { // 1. Validate the input data. - ctx.request.body = parse( - ctx, - endpoint, - { ...Endpoints[endpoint].Request, definitions: Resources }, - // 1a. For GET and DELETE, use the query parameters. Otherwise, use the body. - ['GET', 'DELETE'].includes(method) - ? ctx.request.query - : ctx.request.body, - ); + const requestSchema = Endpoints[endpoint].Request; + if (requestSchema) { + ctx.request.body = parse( + ctx, + endpoint, + { ...requestSchema, definitions: Resources }, + // 1a. For GET and DELETE, use the query parameters. Otherwise, use the body. + ['GET', 'DELETE'].includes(method) + ? ctx.request.query + : ctx.request.body, + ); + } // 2. Run the provided route handler. const response = await routeHandler(ctx); diff --git a/src/meta-schema.test.ts b/src/meta-schema.test.ts index 9929c60..5ff2bab 100644 --- a/src/meta-schema.test.ts +++ b/src/meta-schema.test.ts @@ -173,6 +173,19 @@ describe('loadSchemaFromFile', () => { }); describe('validateSchema', () => { + test('allows undefined Request schemas', () => { + expect(() => + validateSchema({ + Endpoints: { + 'POST /posts': { + Name: 'something', + Response: {}, + }, + }, + }), + ).not.toThrow(); + }); + test('checks for object types in Request schemas', () => { expect(() => validateSchema({ diff --git a/src/meta-schema.ts b/src/meta-schema.ts index 29182e7..32be422 100644 --- a/src/meta-schema.ts +++ b/src/meta-schema.ts @@ -53,23 +53,49 @@ const ONE_SCHEMA_META_SCHEMA: JSONSchema4 = { export const validateSchema = (spec: OneSchemaDefinition) => { for (const [name, { Request }] of Object.entries(spec.Endpoints)) { - // Requests must be object type. - if (Request.type && Request.type !== 'object') { - throw new Error( - `Detected a non-object Request schema for ${name}. Request schemas must be objects.`, + if (Request) { + // Requests must be object type. + if (Request.type && Request.type !== 'object') { + throw new Error( + `Detected a non-object Request schema for ${name}. Request schemas must be objects.`, + ); + } + + const collidingParam = getPathParams(name).find( + (param) => param in (Request.properties ?? {}), ); + + if (collidingParam) { + throw new Error( + `The ${collidingParam} parameter was declared as a path parameter and a Request property for ${name}. Rename either the path parameter or the request property to avoid a collision.`, + ); + } } + } +}; - const collidingParam = getPathParams(name).find( - (param) => param in (Request.properties ?? {}), - ); +const transformOneSchema = ( + spec: OneSchemaDefinition, + transform: (schema: JSONSchema4) => JSONSchema4, +): OneSchemaDefinition => { + const copy = deepCopy(spec); - if (collidingParam) { - throw new Error( - `The ${collidingParam} parameter was declared as a path parameter and a Request property for ${name}. Rename either the path parameter or the request property to avoid a collision.`, - ); - } + for (const key in copy.Resources) { + copy.Resources[key] = transformJSONSchema(copy.Resources[key], transform); } + + for (const key in copy.Endpoints) { + copy.Endpoints[key].Request = transformJSONSchema( + copy.Endpoints[key].Request ?? {}, + transform, + ); + copy.Endpoints[key].Response = transformJSONSchema( + copy.Endpoints[key].Response, + transform, + ); + } + + return copy; }; export type SchemaAssumptions = { @@ -101,33 +127,20 @@ export const withAssumptions = ( overrides: SchemaAssumptions = DEFAULT_ASSUMPTIONS, ): OneSchemaDefinition => { // Deep copy, then apply assumptions. - const copy = deepCopy(spec); + let copy = deepCopy(spec); const assumptions = { ...DEFAULT_ASSUMPTIONS, ...overrides }; if (assumptions.noAdditionalPropertiesOnObjects) { - const transform = (schema: JSONSchema4) => + copy = transformOneSchema(copy, (schema) => schema.type === 'object' ? { additionalProperties: false, ...schema } - : schema; - - for (const key in copy.Resources) { - copy.Resources[key] = transformJSONSchema(copy.Resources[key], transform); - } - for (const key in copy.Endpoints) { - copy.Endpoints[key].Request = transformJSONSchema( - copy.Endpoints[key].Request, - transform, - ); - copy.Endpoints[key].Response = transformJSONSchema( - copy.Endpoints[key].Response, - transform, - ); - } + : schema, + ); } if (assumptions.objectPropertiesRequiredByDefault) { - const transform = (schema: JSONSchema4) => + copy = transformOneSchema(copy, (schema) => schema.type === 'object' && schema.properties ? { required: Object.keys(schema.properties).filter( @@ -136,21 +149,8 @@ export const withAssumptions = ( ), ...schema, } - : schema; - - for (const key in copy.Resources) { - copy.Resources[key] = transformJSONSchema(copy.Resources[key], transform); - } - for (const key in copy.Endpoints) { - copy.Endpoints[key].Request = transformJSONSchema( - copy.Endpoints[key].Request, - transform, - ); - copy.Endpoints[key].Response = transformJSONSchema( - copy.Endpoints[key].Response, - transform, - ); - } + : schema, + ); } return copy; diff --git a/src/openapi.test.ts b/src/openapi.test.ts index 2a1abb3..df99e22 100644 --- a/src/openapi.test.ts +++ b/src/openapi.test.ts @@ -69,6 +69,12 @@ const TEST_SPEC: OneSchemaDefinition = withAssumptions({ $ref: '#/definitions/Post', }, }, + 'DELETE /posts/:id': { + Name: 'deletePost', + Response: { + $ref: '#/definitions/Post', + }, + }, }, }); @@ -177,6 +183,31 @@ describe('toOpenAPISpec', () => { }, }, '/posts/{id}': { + delete: { + operationId: 'deletePost', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Post', + }, + }, + }, + description: 'TODO', + }, + }, + }, get: { operationId: 'getPostById', parameters: [ diff --git a/src/openapi.ts b/src/openapi.ts index 2e4ac91..e32e940 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -77,29 +77,32 @@ export const toOpenAPISpec = ( required: true, })); - if (['GET', 'DELETE'].includes(method)) { - // Add the query parameters for GET/DELETE methods - for (const [name, schema] of Object.entries(Request.properties ?? {})) - parameters.push({ - in: 'query', - name, - description: schema.description, - schema: { type: 'string' }, - required: - Array.isArray(Request.required) && Request.required.includes(name), - }); - } else { - // Add the body spec parameters for non-GET/DELETE methods - operation.requestBody = { - content: { - 'application/json': { - // @ts-expect-error TS detects a mismatch between the JSONSchema types - // between openapi-types and json-schema. Ignore and assume everything - // is cool. - schema: Request, + if (Request) { + if (['GET', 'DELETE'].includes(method)) { + // Add the query parameters for GET/DELETE methods + for (const [name, schema] of Object.entries(Request.properties ?? {})) + parameters.push({ + in: 'query', + name, + description: schema.description, + schema: { type: 'string' }, + required: + Array.isArray(Request.required) && + Request.required.includes(name), + }); + } else { + // Add the body spec parameters for non-GET/DELETE methods + operation.requestBody = { + content: { + 'application/json': { + // @ts-expect-error TS detects a mismatch between the JSONSchema types + // between openapi-types and json-schema. Ignore and assume everything + // is cool. + schema: Request, + }, }, - }, - }; + }; + } } if (parameters.length) { diff --git a/src/types.ts b/src/types.ts index fde947e..4326043 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import type { JSONSchema4 } from 'json-schema'; export type EndpointDefinition = { Name: string; - Request: JSONSchema4; + Request?: JSONSchema4; Response: JSONSchema4; }; @@ -28,7 +28,7 @@ export type OneSchema = OneSchemaDefinition & { Endpoints: { [K in keyof Endpoints]: { - Request: JSONSchema4; + Request?: JSONSchema4; Response: JSONSchema4; }; };