diff --git a/.changeset/flat-rivers-press.md b/.changeset/flat-rivers-press.md new file mode 100644 index 000000000..7e962eb29 --- /dev/null +++ b/.changeset/flat-rivers-press.md @@ -0,0 +1,5 @@ +--- +"@logto/core": patch +--- + +add response schemas to swagger.json API diff --git a/packages/core/package.json b/packages/core/package.json index 7c5244369..9aa50a180 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 812f5bd31..b07c06f74 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -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; } diff --git a/packages/core/src/routes/swagger.test.ts b/packages/core/src/routes/swagger.test.ts index ebc673b05..d68fa3409 100644 --- a/packages/core/src/routes/swagger.test.ts +++ b/packages/core/src/routes/swagger.test.ts @@ -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' }, - }, - }, - }, - }); + }, }); }); }); diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger.ts index d1bceadb3..cd560c39b 100644 --- a/packages/core/src/routes/swagger.ts +++ b/packages/core/src/routes/swagger.ts @@ -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 => 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 { - // 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((router) => router.stack // Filter out universal routes (mostly like a proxy route to withtyped) @@ -144,13 +155,10 @@ export default function swaggerRoutes { 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), }; }) ) diff --git a/packages/core/src/utils/http.ts b/packages/core/src/utils/http.ts new file mode 100644 index 000000000..ac6caa9a7 --- /dev/null +++ b/packages/core/src/utils/http.ts @@ -0,0 +1,43 @@ +export const codeToMessage: Record = 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', +}); diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index 126fe36e4..f557e5569 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -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 diff --git a/packages/core/static/yaml/additional-swagger.yaml b/packages/core/static/yaml/additional-swagger.yaml deleted file mode 100644 index 6a6fa1707..000000000 --- a/packages/core/static/yaml/additional-swagger.yaml +++ /dev/null @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4a9a2e18..29144b6b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}