Skip to content

Commit

Permalink
feat(core): append responses from additional YAML to swagger.json
Browse files Browse the repository at this point in the history
  • Loading branch information
IceHe committed Jul 6, 2022
1 parent 78407fc commit 87dedd4
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 293 deletions.
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"iconv-lite": "0.6.3",
"inquirer": "^8.2.2",
"jose": "^4.0.0",
"js-yaml": "^4.1.0",
"koa": "^2.13.1",
"koa-body": "^5.0.0",
"koa-compose": "^4.1.0",
Expand Down Expand Up @@ -81,6 +82,7 @@
"@types/etag": "^1.8.1",
"@types/inquirer": "^8.2.1",
"@types/jest": "^27.4.1",
"@types/js-yaml": "^4.0.5",
"@types/koa": "^2.13.3",
"@types/koa-compose": "^3.2.5",
"@types/koa-logger": "^3.1.1",
Expand Down
65 changes: 63 additions & 2 deletions packages/core/src/routes/swagger.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { load } from 'js-yaml';
import Koa from 'koa';
import Router from 'koa-router';
import request from 'supertest';
Expand All @@ -7,9 +8,13 @@ import koaGuard from '@/middleware/koa-guard';
import koaPagination from '@/middleware/koa-pagination';
import { AnonymousRouter } from '@/routes/types';

import swaggerRoutes, { paginationParameters } from './swagger';
import swaggerRoutes, { defaultResponses, paginationParameters } from './swagger';

const createSwaggerRequest = (
jest.mock('js-yaml', () => ({
load: jest.fn().mockReturnValue({ paths: {} }),
}));

export const createSwaggerRequest = (
allRouters: Array<Router<unknown, any>>,
swaggerRouter: AnonymousRouter = new Router()
) => {
Expand Down Expand Up @@ -233,4 +238,60 @@ describe('GET /swagger.json', () => {
})
);
});

describe('should use correct responses', () => {
it('should use "defaultResponses" if there is no custom "responses" from the additional swagger', async () => {
(load as jest.Mock).mockReturnValueOnce({
paths: { '/api/mock': { delete: {} } },
});

const swaggerRequest = createSwaggerRequest([mockRouter]);
const response = await swaggerRequest.get('/swagger.json');
expect(response.body.paths).toMatchObject({
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
'/api/mock': expect.objectContaining({
delete: expect.objectContaining({ responses: defaultResponses }),
}),
/* eslint-enable @typescript-eslint/no-unsafe-assignment */
});
});

it('should use custom "responses" from the additional swagger if it exists', async () => {
(load as jest.Mock).mockReturnValueOnce({
paths: {
'/api/mock': {
get: {
responses: {
'204': { description: 'No Content' },
},
},
patch: {
responses: {
'202': { description: 'Accepted' },
},
},
},
},
});

const swaggerRequest = createSwaggerRequest([mockRouter]);
const response = await swaggerRequest.get('/swagger.json');
expect(response.body.paths).toMatchObject({
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
'/api/mock': {
get: expect.objectContaining({
responses: {
'204': { description: 'No Content' },
},
}),
patch: expect.objectContaining({
responses: {
'202': { description: 'Accepted' },
},
}),
},
/* eslint-enable @typescript-eslint/no-unsafe-assignment */
});
});
});
});
68 changes: 45 additions & 23 deletions packages/core/src/routes/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { readFile } from 'fs/promises';

import { toTitle } from '@silverhand/essentials';
import { load } from 'js-yaml';
import Router, { IMiddleware } from 'koa-router';
import { OpenAPIV3 } from 'openapi-types';
import { ZodObject, ZodOptional } from 'zod';

import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
import { isPaginationMiddleware, fallbackDefaultPageSize } from '@/middleware/koa-pagination';
import { fallbackDefaultPageSize, isPaginationMiddleware } from '@/middleware/koa-pagination';
import assertThat from '@/utils/assert-that';
import { zodTypeToSwagger } from '@/utils/zod';

Expand Down Expand Up @@ -62,21 +65,37 @@ const buildParameters = (
}));
};

function buildTag(path: string) {
const buildTag = (path: string) => {
const root = path.split('/')[1];

if (root?.startsWith('.')) {
return root;
}

return toTitle(root ?? 'General');
}
};

export const defaultResponses: OpenAPIV3.ResponsesObject = {
'200': {
description: 'OK',
},
};

const buildOperation = (stack: IMiddleware[], path: string): OpenAPIV3.OperationObject => {
const buildOperation = (
stack: IMiddleware[],
path: string,
customResponses?: OpenAPIV3.ResponsesObject
): OpenAPIV3.OperationObject => {
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
isGuardMiddleware(function_)
);
const pathParameters = buildParameters(guard?.config.params, 'path');

const hasPagination = stack.some((function_) => isPaginationMiddleware(function_));
const queryParameters = [
...buildParameters(guard?.config.query, 'query'),
...(hasPagination ? paginationParameters : []),
];

const body = guard?.config.body;
const requestBody = body && {
Expand All @@ -88,21 +107,11 @@ const buildOperation = (stack: IMiddleware[], path: string): OpenAPIV3.Operation
},
};

const pathParameters = buildParameters(guard?.config.params, 'path');
const queryParameters = [
...buildParameters(guard?.config.query, 'query'),
...(hasPagination ? paginationParameters : []),
];

return {
tags: [buildTag(path)],
parameters: [...pathParameters, ...queryParameters],
requestBody,
responses: {
'200': {
description: 'OK',
},
},
responses: customResponses ?? defaultResponses,
};
};

Expand All @@ -111,24 +120,37 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
allRouters: R[]
) {
router.get('/swagger.json', async (ctx, next) => {
// Confirm the final correctness with integration tests.
const additionalSwagger = load(
await readFile('static/yaml/additional-swagger.yaml', { encoding: 'utf-8' })
) as OpenAPIV3.Document;

const routes = allRouters.flatMap<RouteObject>((router) =>
router.stack.flatMap<RouteObject>(({ path, stack, methods }) =>
methods
// There is no need to show the HEAD method.
.filter((method) => method !== 'HEAD')
.map((method) => ({
path: `/api${path}`,
method: method.toLowerCase() as OpenAPIV3.HttpMethods,
operation: buildOperation(stack, path),
}))
.map((method) => {
const realPath = `/api${path}`;
const httpMethod = method.toLowerCase() as OpenAPIV3.HttpMethods;

const additionalPathItem = additionalSwagger.paths[realPath] ?? {};
const additionalResponses = additionalPathItem[httpMethod]?.responses;

return {
path: realPath,
method: httpMethod,
operation: buildOperation(stack, path, additionalResponses),
};
})
)
);

const pathMap = new Map<string, MethodMap>();
const paths = new Map<string, MethodMap>();

// Group routes by path
for (const { path, method, operation } of routes) {
pathMap.set(path, { ...pathMap.get(path), [method]: operation });
paths.set(path, { ...paths.get(path), [method]: operation });
}

const document: OpenAPIV3.Document = {
Expand All @@ -137,7 +159,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
title: 'Logto Core',
version: '0.1.0',
},
paths: Object.fromEntries(pathMap),
paths: Object.fromEntries(paths),
};

ctx.body = document;
Expand Down
60 changes: 60 additions & 0 deletions packages/core/static/yaml/additional-swagger.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# The structure of `paths` SHOULD follow OpenAPI 3.0 Specification.
# See https://swagger.io/docs/specification/paths-and-operations/
paths:
/api/applications/:id:
delete:
responses:
'204':
description: No Content
/api/connectors/:id/test:
delete:
responses:
'204':
description: No Content
/api/me/password:
patch:
responses:
'204':
description: No Content
/api/resources/:id:
delete:
responses:
'204':
description: No Content
/api/session/sign-in/passwordless/sms/send-passcode:
post:
responses:
'204':
description: No Content
/api/session/sign-in/passwordless/email/send-passcode:
post:
responses:
'204':
description: No Content
/api/session/register/passwordless/sms/send-passcode:
post:
responses:
'204':
description: No Content
/api/session/register/passwordless/email/send-passcode:
post:
responses:
'204':
description: No Content
/api/status:
get:
responses:
'204':
description: No Content
/api/users/:userId:
delete:
responses:
'204':
description: No Content
/api/.well-known/sign-in-exp:
get:
responses:
'200':
description: OK
'304':
description: No Modified
Loading

0 comments on commit 87dedd4

Please sign in to comment.