diff --git a/.changeset/warm-tips-attack.md b/.changeset/warm-tips-attack.md new file mode 100644 index 000000000..4cc300a2c --- /dev/null +++ b/.changeset/warm-tips-attack.md @@ -0,0 +1,9 @@ +--- +"@logto/core": patch +--- + +refactored swagger json api + +- reuse parameter definitions, which reduces the size of the swagger response. +- tags are now in sentence case. +- path parameters now follow the swagger convention, using `{foo}` instead of `:foo`. diff --git a/.vscode/settings.json b/.vscode/settings.json index 52fecd4b9..0ef0dfd3d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,7 +29,7 @@ "json.schemas": [ { "fileMatch": [ - "packages/core/src/routes/*.openapi.json" + "packages/core/src/routes/**/*.openapi.json" ], "url": "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.0/schema.json" } diff --git a/packages/core/package.json b/packages/core/package.json index 2e0fb7ceb..8b599e998 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,7 +14,7 @@ "precommit": "lint-staged", "copyfiles": "copyfiles -u 1 src/routes/**/*.openapi.json build/", "build": "rm -rf build/ && tsc -p tsconfig.build.json && pnpm run copyfiles", - "build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap", + "build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap && pnpm run copyfiles", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "dev": "rm -rf build/ && pnpm run copyfiles && nodemon", diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index ff832b813..427bc9e66 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -29,7 +29,7 @@ import roleScopeRoutes from './role.scope.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; import ssoConnectors from './sso-connector/index.js'; import statusRoutes from './status.js'; -import swaggerRoutes from './swagger.js'; +import swaggerRoutes from './swagger/index.js'; import type { AnonymousRouter, AuthedRouter } from './types.js'; import userAssetsRoutes from './user-assets.js'; import verificationCodeRoutes from './verification-code.js'; diff --git a/packages/core/src/routes/swagger.test.ts b/packages/core/src/routes/swagger/index.test.ts similarity index 87% rename from packages/core/src/routes/swagger.test.ts rename to packages/core/src/routes/swagger/index.test.ts index 3d3fe566c..36fc60ab9 100644 --- a/packages/core/src/routes/swagger.test.ts +++ b/packages/core/src/routes/swagger/index.test.ts @@ -8,7 +8,8 @@ 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 { default: swaggerRoutes, paginationParameters } = await import('./swagger.js'); +const { default: swaggerRoutes } = await import('./index.js'); +const { paginationParameters } = await import('./utils/parameters.js'); const createSwaggerRequest = ( allRouters: Router[], @@ -79,7 +80,7 @@ describe('GET /swagger.json', () => { get: { tags: ['Mock'] }, }, '/api/.well-known': { - put: { tags: ['.well-known'] }, + put: { tags: ['Well known'] }, }, }); }); @@ -100,14 +101,11 @@ describe('GET /swagger.json', () => { const response = await swaggerRequest.get('/swagger.json'); expect(response.body.paths).toMatchObject({ - '/api/mock/:id/:field': { + '/api/mock/{id}/{field}': { get: { parameters: [ { - name: 'id', - in: 'path', - required: true, - schema: { type: 'number' }, + $ref: '#/components/parameters/mocId:root', }, { name: 'field', @@ -121,6 +119,27 @@ describe('GET /swagger.json', () => { }); }); + it('should be able to find supplement files and merge them', async () => { + const swaggerRequest = createSwaggerRequest([mockRouter]); + const response = await swaggerRequest.get('/swagger.json'); + // Partially match one of the supplement files `status.openapi.json`. Should update this test + // when the file is updated. + expect(response.body).toMatchObject({ + paths: { + '/api/status': { + get: { + summary: 'Health check', + responses: { + '204': { + description: 'The Logto core service is healthy.', + }, + }, + }, + }, + }, + }); + }); + describe('parse query parameters', () => { it('should parse the normal query parameters', async () => { const queryParametersRouter = new Router(); diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger/index.ts similarity index 59% rename from packages/core/src/routes/swagger.ts rename to packages/core/src/routes/swagger/index.ts index 45aeabba7..ccab819a2 100644 --- a/packages/core/src/routes/swagger.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -1,23 +1,30 @@ import fs from 'node:fs/promises'; -import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { httpCodeToMessage } from '@logto/core-kit'; -import { conditionalArray, deduplicate, toTitle } from '@silverhand/essentials'; +import { conditionalArray, deduplicate } from '@silverhand/essentials'; import deepmerge from 'deepmerge'; +import { findUp } from 'find-up'; import type { IMiddleware } from 'koa-router'; import type Router from 'koa-router'; import type { OpenAPIV3 } from 'openapi-types'; -import { ZodObject, ZodOptional } from 'zod'; import { isKoaAuthMiddleware } from '#src/middleware/koa-auth/index.js'; 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 { isPaginationMiddleware } from '#src/middleware/koa-pagination.js'; import assertThat from '#src/utils/assert-that.js'; import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js'; -import type { AnonymousRouter } from './types.js'; +import type { AnonymousRouter } from '../types.js'; + +import { buildTag, findSupplementFiles, normalizePath } from './utils/general.js'; +import { + buildParameters, + paginationParameters, + buildPathIdParameters, + mergeParameters, +} from './utils/parameters.js'; type RouteObject = { path: string; @@ -25,64 +32,6 @@ type RouteObject = { operation: OpenAPIV3.OperationObject; }; -type MethodMap = { - [key in OpenAPIV3.HttpMethods]?: OpenAPIV3.OperationObject; -}; - -export const paginationParameters: OpenAPIV3.ParameterObject[] = [ - { - name: 'page', - in: 'query', - required: false, - schema: { - type: 'integer', - minimum: 1, - default: 1, - }, - }, - { - name: 'page_size', - in: 'query', - required: false, - schema: { - type: 'integer', - minimum: 1, - default: fallbackDefaultPageSize, - }, - }, -]; - -// Parameter serialization: https://swagger.io/docs/specification/serialization -const buildParameters = ( - zodParameters: unknown, - inWhere: 'path' | 'query' -): OpenAPIV3.ParameterObject[] => { - if (!zodParameters) { - return []; - } - - assertThat(zodParameters instanceof ZodObject, 'swagger.not_supported_zod_type_for_params'); - - // Type from Zod is any - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return Object.entries(zodParameters.shape).map(([key, value]) => ({ - name: key, - in: inWhere, - required: !(value instanceof ZodOptional), - schema: zodTypeToSwagger(value), - })); -}; - -const buildTag = (path: string) => { - const root = path.split('/')[1]; - - if (root?.startsWith('.')) { - return root; - } - - return toTitle(root ?? 'General'); -}; - const buildOperation = ( stack: IMiddleware[], path: string, @@ -92,7 +41,7 @@ const buildOperation = ( isGuardMiddleware(function_) ); const { params, query, body, response, status } = guard?.config ?? {}; - const pathParameters = buildParameters(params, 'path'); + const pathParameters = buildParameters(params, 'path', path); const hasPagination = stack.some((function_) => isPaginationMiddleware(function_)); const queryParameters = [ @@ -151,27 +100,30 @@ const isManagementApiRouter = ({ stack }: Router) => .filter(({ path }) => !path.includes('.*')) .some(({ stack }) => stack.some((function_) => isKoaAuthMiddleware(function_))); +// Add more components here to cover more ID parameters in paths. For example, if there is a +// path `/foo/:barBazId`, then add `bar-baz` to the array. +const identifiableEntityNames = [ + 'application', + 'connector', + 'resource', + 'user', + 'log', + 'role', + 'scope', + 'hook', + 'domain', + 'organization', + 'organization-role', + 'organization-scope', +]; + /** - * Recursively find all supplement files (files end with `.openapi.json`) for the given - * directory. + * Attach the `/swagger.json` route which returns the generated OpenAPI document for the + * management APIs. + * + * @param router The router to attach the route to. + * @param allRouters All management API routers. This is used to generate the OpenAPI document. */ -/* eslint-disable @silverhand/fp/no-mutating-methods, no-await-in-loop */ -const findSupplementFiles = async (directory: string) => { - const result: string[] = []; - - for (const file of await fs.readdir(directory)) { - const stats = await fs.stat(path.join(directory, file)); - if (stats.isDirectory()) { - result.push(...(await findSupplementFiles(path.join(directory, file)))); - } else if (file.endsWith('.openapi.json')) { - result.push(path.join(directory, file)); - } - } - - return result; -}; -/* eslint-enable @silverhand/fp/no-mutating-methods, no-await-in-loop */ - // Keep using `any` to accept various custom context types. // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function swaggerRoutes>( @@ -186,33 +138,46 @@ export default function swaggerRoutes !path.includes('.*')) - .flatMap(({ path: routerPath, stack, methods, name }) => + .flatMap(({ path: routerPath, stack, methods }) => methods .map((method) => method.toLowerCase()) // There is no need to show the HEAD method. .filter((method): method is OpenAPIV3.HttpMethods => method !== 'head') .map((httpMethod) => { - const path = `/api${routerPath}`; + const path = normalizePath(routerPath); + const operation = buildOperation(stack, routerPath, isAuthGuarded); return { path, method: httpMethod, - operation: buildOperation(stack, routerPath, isAuthGuarded), + operation, }; }) ) ); }); - const pathMap = new Map(); + const pathMap = new Map(); + const tags = new Set(); // Group routes by path for (const { path, method, operation } of routes) { + if (operation.tags) { + // Collect all tags for sorting + for (const tag of operation.tags) { + tags.add(tag); + } + } pathMap.set(path, { ...pathMap.get(path), [method]: operation }); } - // Current path should be the root directory of routes files. - const supplementPaths = await findSupplementFiles(path.dirname(fileURLToPath(import.meta.url))); + const routesDirectory = await findUp('routes', { + type: 'directory', + cwd: fileURLToPath(import.meta.url), + }); + assertThat(routesDirectory, new Error('Cannot find routes directory.')); + + const supplementPaths = await findSupplementFiles(routesDirectory); const supplementDocuments = await Promise.all( supplementPaths.map( // eslint-disable-next-line no-restricted-syntax @@ -223,19 +188,31 @@ export default function swaggerRoutes ({ ...previous, ...buildPathIdParameters(entityName) }), + {} + ), + }, + tags: [...tags].map((tag) => ({ name: tag })), }; - ctx.body = supplementDocuments.reduce( - (document, supplement) => deepmerge(document, supplement), + const data = supplementDocuments.reduce( + (document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }), baseDocument ); + ctx.body = { + ...data, + tags: data.tags?.slice().sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)), + }; + return next(); }); } diff --git a/packages/core/src/routes/swagger/utils/general.ts b/packages/core/src/routes/swagger/utils/general.ts new file mode 100644 index 000000000..a43fe2dc2 --- /dev/null +++ b/packages/core/src/routes/swagger/utils/general.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); + +/** + * Get the root component name from the given absolute path. + * @example '/organization/:id' -> 'organization' + */ +export const getRootComponent = (path?: string) => path?.split('/')[1]; + +/** + * Build a tag name from the given absolute path. The tag name is the sentence case of the root + * component name. + * @example '/organization-roles' -> 'Organization roles' + */ +export const buildTag = (path: string) => { + const rootComponent = (getRootComponent(path) ?? 'General').replaceAll('-', ' '); + return rootComponent.startsWith('.') + ? capitalize(rootComponent.slice(1)) + : capitalize(rootComponent); +}; + +/** + * Recursively find all supplement files (files end with `.openapi.json`) for the given + * directory. + */ +/* eslint-disable @silverhand/fp/no-mutating-methods, no-await-in-loop */ +export const findSupplementFiles = async (directory: string) => { + const result: string[] = []; + + for (const file of await fs.readdir(directory)) { + const stats = await fs.stat(path.join(directory, file)); + if (stats.isDirectory()) { + result.push(...(await findSupplementFiles(path.join(directory, file)))); + } else if (file.endsWith('.openapi.json')) { + result.push(path.join(directory, file)); + } + } + + return result; +}; +/* eslint-enable @silverhand/fp/no-mutating-methods, no-await-in-loop */ + +/** + * Normalize the path to the OpenAPI path by adding `/api` prefix and replacing the path parameters + * with OpenAPI path parameters. + * + * @example + * normalizePath('/organization/:id') -> '/api/organization/{id}' + */ +export const normalizePath = (path: string) => + `/api${path}` + .split('/') + .map((part) => (part.startsWith(':') ? `{${part.slice(1)}}` : part)) + .join('/'); diff --git a/packages/core/src/routes/swagger/utils/parameters.ts b/packages/core/src/routes/swagger/utils/parameters.ts new file mode 100644 index 000000000..45cf5167d --- /dev/null +++ b/packages/core/src/routes/swagger/utils/parameters.ts @@ -0,0 +1,230 @@ +import camelcase from 'camelcase'; +import deepmerge from 'deepmerge'; +import { type OpenAPIV3 } from 'openapi-types'; +import { z } from 'zod'; + +import { fallbackDefaultPageSize } from '#src/middleware/koa-pagination.js'; +import assertThat from '#src/utils/assert-that.js'; +import { zodTypeToSwagger } from '#src/utils/zod.js'; + +import { getRootComponent } from './general.js'; + +export type ParameterArray = Array; + +// TODO: Generate pagination parameters according to the config. +export const paginationParameters: OpenAPIV3.ParameterObject[] = [ + { + name: 'page', + in: 'query', + description: 'Page number (starts from 1).', + required: false, + schema: { + type: 'integer', + minimum: 1, + default: 1, + }, + }, + { + name: 'page_size', + in: 'query', + description: 'Entries per page.', + required: false, + schema: { + type: 'integer', + minimum: 1, + default: fallbackDefaultPageSize, + }, + }, +]; + +type BuildParameters = { + /** + * Build a parameter array for the given `ZodObject`. + * + * For path parameters, this function will try to match reusable ID parameters: + * + * - If the parameter name is `id`, and the path is `/organizations/{id}/users`, the parameter + * `id` will be a reference to `#/components/parameters/organizationId:root`. + * - If the parameter name ends with `Id`, and the path is `/organizations/{id}/users/{userId}`, + * the parameter `userId` will be a reference to `#/components/parameters/userId`. + * + * @param zodParameters The `ZodObject` to build parameters from. The keys of the object are the + * parameter names. + * @param inWhere The parameters are in a path, for example, `/users/:id`. + * @param path The path of the route. Only required when `inWhere` is `path`. + * @returns The built parameter array for OpenAPI. + * @see {@link buildPathIdParameters} for reusable ID parameters. + */ + (zodParameters: unknown, inWhere: 'path', path: string): ParameterArray; + /** + * Build a parameter array for the given `ZodObject`. + * @param zodParameters The `ZodObject` to build parameters from. The keys of the object are the + * parameter names. + * @param inWhere The parameters are in a query, for example, `/users?name=foo`. + * @returns The built parameter array for OpenAPI. + */ + (zodParameters: unknown, inWhere: 'query'): ParameterArray; +}; + +// Parameter serialization: https://swagger.io/docs/specification/serialization +export const buildParameters: BuildParameters = ( + zodParameters: unknown, + inWhere: 'path' | 'query', + path?: string +): ParameterArray => { + if (!zodParameters) { + return []; + } + + assertThat(zodParameters instanceof z.ZodObject, 'swagger.not_supported_zod_type_for_params'); + + const rootComponent = camelcase(getRootComponent(path) ?? ''); + + // Type from Zod is any + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return Object.entries(zodParameters.shape).map(([key, value]) => { + if (inWhere === 'path') { + if (key === 'id') { + if (rootComponent) { + return { + $ref: `#/components/parameters/${rootComponent.slice(0, -1)}Id:root`, + }; + } + + throw new Error( + 'Cannot find root path component for `:id` in path `' + + (path ?? '') + + '`. This is probably not expected.' + ); + } else if (key.endsWith('Id')) { + return { + $ref: `#/components/parameters/${key}`, + }; + } + } + + return { + name: key, + in: inWhere, + required: !(value instanceof z.ZodOptional), + schema: zodTypeToSwagger(value), + }; + }); +}; + +const isObjectArray = (value: unknown): value is Array> => + Array.isArray(value) && value.every((item) => typeof item === 'object' && item !== null); + +/** + * Merge two arrays. If the two arrays are both object arrays, merge them with the following + * rules: + * + * - If the source array has an item with `name` and `in` properties, and the destination array + * also has an item with the same `name` and `in` properties, merge the two items with + * `deepmerge`. + * - Otherwise, append the item to the destination array (the default behavior of + * `deepmerge`). + * + * Otherwise, use `deepmerge` to merge the two arrays. + * + * @param destination The destination array. + * @param source The source array. + * @returns The merged array. + */ +export const mergeParameters = (destination: unknown[], source: unknown[]) => { + if (!isObjectArray(destination) || !isObjectArray(source)) { + return deepmerge(destination, source); + } + + const result = destination.slice(); + + for (const item of source) { + if (!('name' in item) || !('in' in item)) { + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + result.push(item); + continue; + } + + const index = result.findIndex( + (resultItem) => resultItem.name === item.name && resultItem.in === item.in + ); + + if (index === -1) { + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + result.push(item); + } else { + // eslint-disable-next-line @silverhand/fp/no-mutation, @typescript-eslint/no-non-null-assertion + result[index] = deepmerge(result[index]!, item); + } + } + + return result; +}; + +/** + * Given a root path component, build a reusable parameter object for the entity ID in path with + * two properties, one for the root path component, and one for other path components. + * + * @example + * ```ts + * buildPathIdParameters('organization'); + * ``` + * + * Will generate the following object: + * + * ```ts + * { + * organizationId: { + * name: 'organizationId', + * in: 'path', + * description: 'The unique identifier of the organization.', + * required: true, + * schema: { + * type: 'string', + * }, + * }, + * 'organizationId:root': { + * name: 'id', + * // ... same as above + * }, + * } + * ``` + * + * @remarks + * The root path component is the first path component in the path. For example, the root path + * component of `/organizations/{id}/users` is `organizations`. Since the name of the parameter is + * same for all root path components, we need to add an additional key with the `:root` suffix to + * distinguish them. + * + * @param rootComponent The root path component in kebab case (`foo-bar`). + * @returns The parameter object for the entity ID in path. + */ +export const buildPathIdParameters = ( + rootComponent: string +): Record => { + const entityId = `${camelcase(rootComponent)}Id`; + const shared = { + in: 'path', + description: `The unique identifier of the ${rootComponent + .split('-') + .join(' ') + .toLowerCase()}.`, + required: true, + schema: { + type: 'string', + }, + } as const; + + // Need to duplicate the object because OpenAPI does not support partial reference. + // See https://github.com/OAI/OpenAPI-Specification/issues/2026 + return { + [`${entityId}:root`]: { + ...shared, + name: 'id', + }, + [entityId]: { + ...shared, + name: entityId, + }, + }; +}; diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 44daa4671..ebd947a4b 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -254,10 +254,11 @@ export default class SchemaRouter< { disabled }: Partial = {} ) { const relationSchema = relationQueries.schemas[1]; + const relationSchemaId = camelCaseSchemaId(relationSchema); const columns = { schemaId: camelCaseSchemaId(this.schema), - relationSchemaId: camelCaseSchemaId(relationSchema), - relationSchemaIds: camelCaseSchemaId(relationSchema) + 's', + relationSchemaId, + relationSchemaIds: relationSchemaId + 's', }; if (!disabled?.get) { @@ -332,20 +333,21 @@ export default class SchemaRouter< ); this.delete( - `/:id/${pathname}/:relationId`, + `/:id/${pathname}/:${camelCaseSchemaId(relationSchema)}`, koaGuard({ - params: z.object({ id: z.string().min(1), relationId: z.string().min(1) }), - // Should be 422 if the relation doesn't exist, update until we change the error handling - status: [204, 404], + params: z.object({ id: z.string().min(1), [relationSchemaId]: z.string().min(1) }), + status: [204, 422], }), async (ctx, next) => { const { - params: { id, relationId }, + params: { id, [relationSchemaId]: relationId }, } = ctx.guard; await relationQueries.delete({ - [columns.schemaId]: id, - [columns.relationSchemaId]: relationId, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `koaGuard()` ensures the value is not `undefined` + [columns.schemaId]: id!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `koaGuard()` ensures the value is not `undefined` + [columns.relationSchemaId]: relationId!, }); ctx.status = 204; diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index c32f0fb46..231eeb003 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -204,7 +204,9 @@ export const zodTypeToSwagger = ( if (config instanceof ZodObject) { // Type from Zod is any // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const entries = Object.entries(config.shape); + const entries = Object.entries(config.shape) + // `tenantId` is not editable for all routes + .filter(([key]) => key !== 'tenantId'); const required = entries .filter(([, value]) => !(value instanceof ZodOptional)) .map(([key]) => key);