diff --git a/.vscode/settings.json b/.vscode/settings.json index 1c0626205..dff39ae45 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/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 99% rename from packages/core/src/routes/swagger.test.ts rename to packages/core/src/routes/swagger/index.test.ts index 3d3fe566c..37387fb6b 100644 --- a/packages/core/src/routes/swagger.test.ts +++ b/packages/core/src/routes/swagger/index.test.ts @@ -8,7 +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 { default: swaggerRoutes, paginationParameters } = await import('./swagger.js'); +const { default: swaggerRoutes, paginationParameters } = await import('./index.js'); const createSwaggerRequest = ( allRouters: Router[], diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger/index.ts similarity index 61% rename from packages/core/src/routes/swagger.ts rename to packages/core/src/routes/swagger/index.ts index 45aeabba7..23c8571f0 100644 --- a/packages/core/src/routes/swagger.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -3,96 +3,47 @@ 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 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 assertThat from '#src/utils/assert-that.js'; +import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js'; import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js'; -import type { AnonymousRouter } from './types.js'; +import type { AnonymousRouter } from '../types.js'; + +import { buildTag, findSupplementFiles } from './utils/general.js'; +import { + type ParameterArray, + buildParameters, + paginationParameters, + buildPathIdParameters, + mergeParameters, +} from './utils/parameters.js'; type RouteObject = { path: string; method: OpenAPIV3.HttpMethods; 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'); + /** Path parameters for the operation. E.g. `/users/:id` has a path parameter `id`. */ + pathParameters: ParameterArray; }; const buildOperation = ( stack: IMiddleware[], path: string, isAuthGuarded: boolean -): OpenAPIV3.OperationObject => { +): OpenAPIV3.OperationObject & { pathParameters: ParameterArray } => { const guard = stack.find((function_): function_ is WithGuardConfig => 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 = [ @@ -140,7 +91,8 @@ const buildOperation = ( return { tags: [buildTag(path)], - parameters: [...pathParameters, ...queryParameters], + parameters: queryParameters, + pathParameters, requestBody, responses, }; @@ -152,26 +104,12 @@ const isManagementApiRouter = ({ stack }: Router) => .some(({ stack }) => stack.some((function_) => isKoaAuthMiddleware(function_))); /** - * 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>( @@ -185,29 +123,41 @@ export default function swaggerRoutes !path.includes('.*')) - .flatMap(({ path: routerPath, stack, methods, name }) => + .filter(({ path }) => !path.includes('.*') && path.startsWith('/organization')) + .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 { pathParameters, ...operation } = buildOperation( + stack, + routerPath, + isAuthGuarded + ); return { path, method: httpMethod, - operation: buildOperation(stack, routerPath, isAuthGuarded), + pathParameters, + operation, }; }) ) ); }); - const pathMap = new Map(); + const pathMap = new Map(); // Group routes by path - for (const { path, method, operation } of routes) { + for (const { path, method, operation, pathParameters } of routes) { + // Use the first path parameters record as the shared definition for the path to avoid + // duplication. + if (!pathMap.has(path)) { + pathMap.set(path, { parameters: pathParameters }); + } + pathMap.set(path, { ...pathMap.get(path), [method]: operation }); } @@ -228,11 +178,17 @@ export default function swaggerRoutes ({ ...previous, ...buildPathIdParameters(entityName) }), + {} + ), + }, }; ctx.body = supplementDocuments.reduce( - (document, supplement) => deepmerge(document, supplement), + (document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }), baseDocument ); 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..4f969365c --- /dev/null +++ b/packages/core/src/routes/swagger/utils/general.ts @@ -0,0 +1,39 @@ +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) => + capitalize((getRootComponent(path) ?? 'General').replaceAll('-', ' ')); + +/** + * 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 */ 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..65a13d04b 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,20 @@ 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) }), + params: z.object({ id: z.string().min(1), [relationSchemaId]: z.string().min(1) }), // Should be 422 if the relation doesn't exist, update until we change the error handling status: [204, 404], }), async (ctx, next) => { const { - params: { id, relationId }, + params: { id, [relationSchemaId]: relationId }, } = ctx.guard; await relationQueries.delete({ - [columns.schemaId]: id, - [columns.relationSchemaId]: relationId, + [columns.schemaId]: id!, + [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);