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:
commit
29efb5bf66
10 changed files with 409 additions and 114 deletions
9
.changeset/warm-tips-attack.md
Normal file
9
.changeset/warm-tips-attack.md
Normal 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`.
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
|
@ -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();
|
||||
});
|
||||
}
|
56
packages/core/src/routes/swagger/utils/general.ts
Normal file
56
packages/core/src/routes/swagger/utils/general.ts
Normal 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('/');
|
230
packages/core/src/routes/swagger/utils/parameters.ts
Normal file
230
packages/core/src/routes/swagger/utils/parameters.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue