0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): append additional yaml responses to swagger.json (#1407)

This commit is contained in:
IceHe.xyz 2022-07-07 17:48:46 +08:00 committed by GitHub
parent d75dc24e33
commit 100bffbc6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 248 additions and 291 deletions

View file

@ -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",
@ -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",

View file

@ -1,3 +1,4 @@
import { load } from 'js-yaml';
import Koa from 'koa';
import Router from 'koa-router';
import request from 'supertest';
@ -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()
) => {
@ -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 */
});
});
});
});

View file

@ -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';
@ -62,7 +65,7 @@ const buildParameters = (
}));
};
function buildTag(path: string) {
const buildTag = (path: string) => {
const root = path.split('/')[1];
if (root?.startsWith('.')) {
@ -70,13 +73,29 @@ function buildTag(path: string) {
}
return toTitle(root ?? 'General');
}
};
const buildOperation = (stack: IMiddleware[], path: string): OpenAPIV3.OperationObject => {
export const defaultResponses: OpenAPIV3.ResponsesObject = {
'200': {
description: 'OK',
},
};
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 && {
@ -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,
};
};
@ -111,16 +120,29 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
allRouters: R[]
) {
router.get('/swagger.json', async (ctx, next) => {
// Use `as` here since we'll check typing 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 }) =>
router.stack.flatMap<RouteObject>(({ path: routerPath, 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 path = `/api${routerPath}`;
const httpMethod = method.toLowerCase() as OpenAPIV3.HttpMethods;
const additionalPathItem = additionalSwagger.paths[path] ?? {};
const additionalResponses = additionalPathItem[httpMethod]?.responses;
return {
path,
method: httpMethod,
operation: buildOperation(stack, routerPath, additionalResponses),
};
})
)
);

View file

@ -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

348
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff