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:
parent
cd19ff8bf5
commit
1642df7e1c
9 changed files with 103 additions and 160 deletions
5
.changeset/flat-rivers-press.md
Normal file
5
.changeset/flat-rivers-press.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
add response schemas to swagger.json API
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
})
|
||||
)
|
||||
|
|
43
packages/core/src/utils/http.ts
Normal file
43
packages/core/src/utils/http.ts
Normal 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',
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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
12
pnpm-lock.yaml
generated
|
@ -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==}
|
||||
|
|
Loading…
Add table
Reference in a new issue