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:
parent
73f348af89
commit
35e44a54d3
8 changed files with 331 additions and 103 deletions
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"
|
||||
}
|
||||
|
|
|
@ -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,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[],
|
|
@ -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
|
||||
);
|
||||
|
39
packages/core/src/routes/swagger/utils/general.ts
Normal file
39
packages/core/src/routes/swagger/utils/general.ts
Normal 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 */
|
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,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;
|
||||
|
|
|
@ -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