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

refactor(core): add response schemas to swagger.json API (#3801)

This commit is contained in:
Gao Sun 2023-05-04 12:35:55 +08:00 committed by GitHub
parent cd19ff8bf5
commit 1642df7e1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 103 additions and 160 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/core": patch
---
add response schemas to swagger.json API

View file

@ -55,7 +55,6 @@
"i18next": "^22.4.15",
"iconv-lite": "0.6.3",
"jose": "^4.11.0",
"js-yaml": "^4.1.0",
"koa": "^2.13.1",
"koa-body": "^5.0.0",
"koa-compose": "^4.1.0",
@ -86,7 +85,6 @@
"@types/debug": "^4.1.7",
"@types/etag": "^1.8.1",
"@types/jest": "^29.4.0",
"@types/js-yaml": "^4.0.5",
"@types/koa": "^2.13.3",
"@types/koa-compose": "^3.2.5",
"@types/koa-compress": "^4.0.3",

View file

@ -178,7 +178,7 @@ export default function koaGuard<
// Intended
// eslint-disable-next-line @silverhand/fp/no-mutation
guardMiddleware.config = { query, body, params };
guardMiddleware.config = { query, body, params, response, status };
return guardMiddleware;
}

View file

@ -1,6 +1,6 @@
import { createMockUtils } from '@logto/shared/esm';
import Koa from 'koa';
import Router from 'koa-router';
import { type OpenAPIV3 } from 'openapi-types';
import request from 'supertest';
import { number, object, string } from 'zod';
@ -8,18 +8,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import type { AnonymousRouter } from '#src/routes/types.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { load } = mockEsm('js-yaml', () => ({
load: jest.fn().mockReturnValue({ paths: {} }),
}));
const {
default: swaggerRoutes,
defaultResponses,
paginationParameters,
} = await import('./swagger.js');
const { default: swaggerRoutes, paginationParameters } = await import('./swagger.js');
export const createSwaggerRequest = (
allRouters: Router[],
@ -227,55 +216,17 @@ 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.mockReturnValueOnce({
paths: { '/api/mock': { delete: {} } },
});
const swaggerRequest = createSwaggerRequest([mockRouter]);
const response = await swaggerRequest.get('/swagger.json');
expect(response.body.paths).toMatchObject({
'/api/mock': {
delete: { responses: defaultResponses },
it('should fall back to default when no response guard found', async () => {
const swaggerRequest = createSwaggerRequest([mockRouter]);
const response = await swaggerRequest.get('/swagger.json');
expect(response.body.paths).toMatchObject({
'/api/mock': {
delete: {
responses: {
200: { description: 'OK', content: { 'application/json': {} } },
} satisfies OpenAPIV3.ResponsesObject,
},
});
});
it('should use custom "responses" from the additional swagger if it exists', async () => {
load.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({
'/api/mock': {
get: {
responses: {
'204': { description: 'No Content' },
},
},
patch: {
responses: {
'202': { description: 'Accepted' },
},
},
},
});
},
});
});
});

View file

@ -1,7 +1,4 @@
import { readFile } from 'node:fs/promises';
import { toTitle } from '@silverhand/essentials';
import { load } from 'js-yaml';
import { conditionalArray, deduplicate, toTitle } from '@silverhand/essentials';
import type { IMiddleware } from 'koa-router';
import type Router from 'koa-router';
import type { OpenAPIV3 } from 'openapi-types';
@ -11,6 +8,7 @@ import type { WithGuardConfig } from '#src/middleware/koa-guard.js';
import { isGuardMiddleware } from '#src/middleware/koa-guard.js';
import { fallbackDefaultPageSize, isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
import assertThat from '#src/utils/assert-that.js';
import { codeToMessage } from '#src/utils/http.js';
import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
import type { AnonymousRouter } from './types.js';
@ -79,29 +77,19 @@ const buildTag = (path: string) => {
return toTitle(root ?? 'General');
};
export const defaultResponses: OpenAPIV3.ResponsesObject = {
'200': {
description: 'OK',
},
};
const buildOperation = (
stack: IMiddleware[],
path: string,
customResponses?: OpenAPIV3.ResponsesObject
): OpenAPIV3.OperationObject => {
const buildOperation = (stack: IMiddleware[], path: string): OpenAPIV3.OperationObject => {
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
isGuardMiddleware(function_)
);
const pathParameters = buildParameters(guard?.config.params, 'path');
const { params, query, body, response, status } = guard?.config ?? {};
const pathParameters = buildParameters(params, 'path');
const hasPagination = stack.some((function_) => isPaginationMiddleware(function_));
const queryParameters = [
...buildParameters(guard?.config.query, 'query'),
...buildParameters(query, 'query'),
...(hasPagination ? paginationParameters : []),
];
const body = guard?.config.body;
const requestBody = body && {
required: true,
content: {
@ -111,11 +99,40 @@ const buildOperation = (
},
};
const hasInputGuard = Boolean(params ?? query ?? body);
const responses: OpenAPIV3.ResponsesObject = Object.fromEntries(
deduplicate(conditionalArray(status ?? 200, hasInputGuard && 400)).map<
[number, OpenAPIV3.ResponseObject]
>((status) => {
const description = codeToMessage[status];
if (!description) {
throw new Error(`Invalid status code ${status}.`);
}
if (status === 200) {
return [
status,
{
description,
content: {
'application/json': {
schema: response && zodTypeToSwagger(response),
},
},
},
];
}
return [status, { description }];
})
);
return {
tags: [buildTag(path)],
parameters: [...pathParameters, ...queryParameters],
requestBody,
responses: customResponses ?? defaultResponses,
responses,
};
};
@ -126,12 +143,6 @@ 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
// eslint-disable-next-line no-restricted-syntax
const additionalSwagger = load(
await readFile('static/yaml/additional-swagger.yaml', { encoding: 'utf8' })
) as OpenAPIV3.Document;
const routes = allRouters.flatMap<RouteObject>((router) =>
router.stack
// Filter out universal routes (mostly like a proxy route to withtyped)
@ -144,13 +155,10 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
.map((httpMethod) => {
const path = `/api${routerPath}`;
const additionalPathItem = additionalSwagger.paths[path] ?? {};
const additionalResponses = additionalPathItem[httpMethod]?.responses;
return {
path,
method: httpMethod,
operation: buildOperation(stack, routerPath, additionalResponses),
operation: buildOperation(stack, routerPath),
};
})
)

View file

@ -0,0 +1,43 @@
export const codeToMessage: Record<number, string> = Object.freeze({
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
300: 'Multiple Choices',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
306: 'Unused',
307: 'Temporary Redirect',
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Required',
413: 'Request Entry Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Requested Range Not Satisfiable',
417: 'Expectation Failed',
418: "I'm a teapot",
429: 'Too Many Requests',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported',
});

View file

@ -3,8 +3,9 @@ import { jsonObjectGuard, translationGuard } from '@logto/schemas';
import type { ValuesOf } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import type { OpenAPIV3 } from 'openapi-types';
import type { ZodStringDef } from 'zod';
import {
ZodDiscriminatedUnion,
type ZodStringDef,
ZodRecord,
ZodArray,
ZodBoolean,
@ -191,7 +192,7 @@ export const zodTypeToSwagger = (
return { example: {} }; // Any data type
}
if (config instanceof ZodUnion) {
if (config instanceof ZodUnion || config instanceof ZodDiscriminatedUnion) {
return {
// ZodUnion.options type is any
// eslint-disable-next-line no-restricted-syntax

View file

@ -1,55 +0,0 @@
# 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/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

12
pnpm-lock.yaml generated
View file

@ -3134,9 +3134,6 @@ importers:
jose:
specifier: ^4.11.0
version: 4.11.0
js-yaml:
specifier: ^4.1.0
version: 4.1.0
koa:
specifier: ^2.13.1
version: 2.13.4
@ -3222,9 +3219,6 @@ importers:
'@types/jest':
specifier: ^29.4.0
version: 29.4.0
'@types/js-yaml':
specifier: ^4.0.5
version: 4.0.5
'@types/koa':
specifier: ^2.13.3
version: 2.13.4
@ -9251,10 +9245,6 @@ packages:
pretty-format: 29.5.0
dev: true
/@types/js-yaml@4.0.5:
resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==}
dev: true
/@types/jsdom@20.0.0:
resolution: {integrity: sha512-YfAchFs0yM1QPDrLm2VHe+WHGtqms3NXnXAMolrgrVP6fgBHHXy1ozAbo/dFtPNtZC/m66bPiCTWYmqp1F14gA==}
dependencies:
@ -9956,6 +9946,7 @@ packages:
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
dev: true
/aria-query@4.2.2:
resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==}
@ -14451,6 +14442,7 @@ packages:
hasBin: true
dependencies:
argparse: 2.0.1
dev: true
/jsdom@20.0.2:
resolution: {integrity: sha512-AHWa+QO/cgRg4N+DsmHg1Y7xnz+8KU3EflM0LVDTdmrYOc1WWTSkOjtpUveQH+1Bqd5rtcVnb/DuxV/UjDO4rA==}