0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(core): generate shared id parameters definition for swagger

This commit is contained in:
Gao Sun 2023-11-11 16:34:04 +08:00
parent 73f348af89
commit 35e44a54d3
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
8 changed files with 331 additions and 103 deletions

View file

@ -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"
}

View file

@ -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';

View file

@ -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[],

View file

@ -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<IMiddleware> =>
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<T extends AnonymousRouter, R extends Router<unknown, any>>(
@ -185,29 +123,41 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
return (
router.stack
// Filter out universal routes (mostly like a proxy route to withtyped)
.filter(({ path }) => !path.includes('.*'))
.flatMap<RouteObject>(({ path: routerPath, stack, methods, name }) =>
.filter(({ path }) => !path.includes('.*') && path.startsWith('/organization'))
.flatMap<RouteObject>(({ 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<string, MethodMap>();
const pathMap = new Map<string, OpenAPIV3.PathItemObject>();
// 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<T extends AnonymousRouter, R extends Route
version: 'Cloud',
},
paths: Object.fromEntries(pathMap),
components: { schemas: translationSchemas },
components: {
schemas: translationSchemas,
parameters: ['organization', 'organization-role', 'organization-scope', 'user'].reduce(
(previous, entityName) => ({ ...previous, ...buildPathIdParameters(entityName) }),
{}
),
},
};
ctx.body = supplementDocuments.reduce(
(document, supplement) => deepmerge(document, supplement),
(document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }),
baseDocument
);

View file

@ -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 */

View file

@ -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<OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject>;
// 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<Record<string, unknown>> =>
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<string, OpenAPIV3.ParameterObject> => {
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,
},
};
};

View file

@ -254,10 +254,11 @@ export default class SchemaRouter<
{ disabled }: Partial<RelationRoutesConfig> = {}
) {
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;

View file

@ -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);