0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #4862 from logto-io/gao-upgrade-swagger-builder

refactor(core): generate shared id parameters definition for swagger
This commit is contained in:
Gao Sun 2023-11-13 18:51:01 +08:00 committed by GitHub
commit 29efb5bf66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 409 additions and 114 deletions

View file

@ -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`.

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

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

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,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();

View file

@ -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<T extends AnonymousRouter, R extends Router<unknown, any>>(
@ -186,33 +138,46 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
router.stack
// Filter out universal routes (mostly like a proxy route to withtyped)
.filter(({ path }) => !path.includes('.*'))
.flatMap<RouteObject>(({ path: routerPath, stack, methods, name }) =>
.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 path = normalizePath(routerPath);
const operation = buildOperation(stack, routerPath, isAuthGuarded);
return {
path,
method: httpMethod,
operation: buildOperation(stack, routerPath, isAuthGuarded),
operation,
};
})
)
);
});
const pathMap = new Map<string, MethodMap>();
const pathMap = new Map<string, OpenAPIV3.PathItemObject>();
const tags = new Set<string>();
// 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<T extends AnonymousRouter, R extends Route
const baseDocument: OpenAPIV3.Document = {
openapi: '3.0.1',
info: {
title: 'Logto Core',
description: 'Management APIs for Logto core service.',
title: 'Logto API references',
description: 'API references for Logto services.',
version: 'Cloud',
},
paths: Object.fromEntries(pathMap),
components: { schemas: translationSchemas },
components: {
schemas: translationSchemas,
parameters: identifiableEntityNames.reduce(
(previous, entityName) => ({ ...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();
});
}

View file

@ -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('/');

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,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;

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