Skip to content

Commit

Permalink
Merge pull request #12 from lifeomic/undefined-request
Browse files Browse the repository at this point in the history
feat: add support for undefined Request schema
  • Loading branch information
swain authored May 31, 2022
2 parents c10f7d9 + a2fd0e9 commit 1c93eb6
Show file tree
Hide file tree
Showing 8 changed files with 142 additions and 79 deletions.
13 changes: 13 additions & 0 deletions src/generate-api-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ describe('generate', () => {
},
},
},
'DELETE /posts/:id': {
Name: 'deletePost',
Response: {},
// Test no Request field
},
'PUT /posts/:id': {
Name: 'putPost',
Request: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +102,7 @@ export const Schema: OneSchema<Endpoints> = {
properties: { output: { type: "string" } },
},
},
"DELETE /posts/:id": { Name: "deletePost", Response: {} },
"PUT /posts/:id": {
Name: "putPost",
Request: {
Expand Down
2 changes: 1 addition & 1 deletion src/generate-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const generateEndpointTypes = async ({
additionalProperties: false,
required: ['Request', 'PathParams', 'Response'],
properties: {
Request,
Request: Request ?? {},
PathParams: {
type: 'object',
additionalProperties: false,
Expand Down
21 changes: 12 additions & 9 deletions src/koa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,18 @@ export const implementSchema = <State, Context, Schema extends OneSchema<any>>(
/** A shared route handler. */
const handler: Router.IMiddleware<State, Context> = 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);
Expand Down
13 changes: 13 additions & 0 deletions src/meta-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
90 changes: 45 additions & 45 deletions src/meta-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ const TEST_SPEC: OneSchemaDefinition = withAssumptions({
$ref: '#/definitions/Post',
},
},
'DELETE /posts/:id': {
Name: 'deletePost',
Response: {
$ref: '#/definitions/Post',
},
},
},
});

Expand Down Expand Up @@ -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: [
Expand Down
47 changes: 25 additions & 22 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { JSONSchema4 } from 'json-schema';

export type EndpointDefinition = {
Name: string;
Request: JSONSchema4;
Request?: JSONSchema4;
Response: JSONSchema4;
};

Expand All @@ -28,7 +28,7 @@ export type OneSchema<Endpoints extends GeneratedEndpointsType> =
OneSchemaDefinition & {
Endpoints: {
[K in keyof Endpoints]: {
Request: JSONSchema4;
Request?: JSONSchema4;
Response: JSONSchema4;
};
};
Expand Down

0 comments on commit 1c93eb6

Please sign in to comment.