Add OpenAPI schema generation and validation for API routes
This commit is contained in:
1 parent
5d822e6c7e
commit
5ce5664b29
25 files changed
+2019
-52
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# OpenAPI contracts
|
||||
|
||||
Umami generates `public/openapi.json` from Zod-backed contracts colocated with App Router API
|
||||
handlers.
|
||||
|
||||
## Commands
|
||||
|
||||
- `pnpm openapi:generate` regenerates the public OpenAPI document.
|
||||
- `pnpm openapi:check` validates contract alignment and checks that the generated file is current.
|
||||
- `pnpm openapi:check --strict` also fails while any non-OPTIONS API route lacks an explicit
|
||||
contract.
|
||||
- `pnpm openapi:check --verbose` lists routes that still need contracts.
|
||||
|
||||
The normal and Docker builds run the generator before the Next.js build. CI runs the check before
|
||||
tests and build steps.
|
||||
|
||||
## Adding an operation
|
||||
|
||||
Create a `contract.ts` next to the matching `route.ts` and export an `operations` array. Define
|
||||
request and response shapes with Zod, and import the same request schema into the route handler for
|
||||
`parseRequest` validation.
|
||||
|
||||
Contract modules must remain safe to import from a build script. They must not import route
|
||||
handlers, database clients, Redis clients, or modules with environment-dependent side effects.
|
||||
|
||||
Every operation declares an audience:
|
||||
|
||||
- `public` operations are emitted to `public/openapi.json`.
|
||||
- `internal` operations are available when building an unfiltered document.
|
||||
- `collect` operations identify tracker ingestion APIs.
|
||||
|
||||
Route paths and HTTP methods are discovered independently from `src/app/api/**/route.ts`. The check
|
||||
rejects duplicate contracts, duplicate operation IDs, orphaned contracts, and mismatched dynamic
|
||||
path parameters.
|
||||
@@ -0,0 +1,33 @@
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { discoverContractFiles } from '@/openapi/discover';
|
||||
import type { ApiOperationContract, LoadedApiOperationContract } from '@/openapi/operation';
|
||||
|
||||
interface ContractModule {
|
||||
operations?: readonly ApiOperationContract[];
|
||||
}
|
||||
|
||||
export async function loadApiContracts(
|
||||
projectRoot = process.cwd(),
|
||||
): Promise<LoadedApiOperationContract[]> {
|
||||
const contractFiles = await discoverContractFiles(projectRoot);
|
||||
const contracts: LoadedApiOperationContract[] = [];
|
||||
|
||||
for (const contractFile of contractFiles) {
|
||||
const module = (await import(pathToFileURL(contractFile).href)) as ContractModule;
|
||||
|
||||
if (!Array.isArray(module.operations)) {
|
||||
throw new Error(
|
||||
`${path.relative(projectRoot, contractFile)} must export an \`operations\` array.`,
|
||||
);
|
||||
}
|
||||
|
||||
const source = path.relative(projectRoot, contractFile).replaceAll(path.sep, '/');
|
||||
|
||||
module.operations.forEach(operation => {
|
||||
contracts.push({ ...operation, source });
|
||||
});
|
||||
}
|
||||
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { DiscoveredApiOperation } from '@/openapi/discover';
|
||||
import {
|
||||
type ApiOperationContract,
|
||||
getOperationKey,
|
||||
type LoadedApiOperationContract,
|
||||
} from '@/openapi/operation';
|
||||
|
||||
export interface ContractCoverage {
|
||||
discovered: DiscoveredApiOperation[];
|
||||
contracted: LoadedApiOperationContract[];
|
||||
missing: DiscoveredApiOperation[];
|
||||
orphaned: LoadedApiOperationContract[];
|
||||
duplicateContracts: string[];
|
||||
duplicateOperationIds: string[];
|
||||
pathParameterMismatches: string[];
|
||||
}
|
||||
|
||||
function getExpectedPathParameters(path: string) {
|
||||
return [...path.matchAll(/\{([^}]+)}/g)].map(match => match[1]).sort();
|
||||
}
|
||||
|
||||
function getContractPathParameters(contract: ApiOperationContract) {
|
||||
const schema = contract.operation.requestParams?.path as { shape?: unknown } | undefined;
|
||||
const shape = schema?.shape;
|
||||
|
||||
if (!shape || typeof shape !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(shape).sort();
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
export function analyzeContractCoverage(
|
||||
discovered: DiscoveredApiOperation[],
|
||||
contracted: LoadedApiOperationContract[],
|
||||
): ContractCoverage {
|
||||
const relevantRoutes = discovered.filter(operation => operation.method !== 'options');
|
||||
const discoveredKeys = new Set(relevantRoutes.map(getOperationKey));
|
||||
const contractsByKey = new Map<string, LoadedApiOperationContract[]>();
|
||||
const contractsByOperationId = new Map<string, LoadedApiOperationContract[]>();
|
||||
|
||||
contracted.forEach(contract => {
|
||||
const key = getOperationKey(contract);
|
||||
const operationId = contract.operation.operationId;
|
||||
|
||||
contractsByKey.set(key, [...(contractsByKey.get(key) ?? []), contract]);
|
||||
|
||||
if (operationId) {
|
||||
contractsByOperationId.set(operationId, [
|
||||
...(contractsByOperationId.get(operationId) ?? []),
|
||||
contract,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateContracts = [...contractsByKey.entries()]
|
||||
.filter(([, contracts]) => contracts.length > 1)
|
||||
.map(([key, contracts]) => `${key} (${contracts.map(contract => contract.source).join(', ')})`)
|
||||
.sort();
|
||||
const duplicateOperationIds = [...contractsByOperationId.entries()]
|
||||
.filter(([, contracts]) => contracts.length > 1)
|
||||
.map(
|
||||
([operationId, contracts]) =>
|
||||
`${operationId} (${contracts.map(contract => contract.source).join(', ')})`,
|
||||
)
|
||||
.sort();
|
||||
const contractedKeys = new Set(contractsByKey.keys());
|
||||
const missing = relevantRoutes.filter(
|
||||
operation => !contractedKeys.has(getOperationKey(operation)),
|
||||
);
|
||||
const orphaned = contracted.filter(contract => !discoveredKeys.has(getOperationKey(contract)));
|
||||
const pathParameterMismatches = contracted
|
||||
.map(contract => {
|
||||
const expected = getExpectedPathParameters(contract.path);
|
||||
const actual = getContractPathParameters(contract);
|
||||
|
||||
if (arraysEqual(expected, actual)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${getOperationKey(contract)} declares [${actual.join(', ')}], expected [${expected.join(', ')}] (${contract.source})`;
|
||||
})
|
||||
.filter((message): message is string => message !== null)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
discovered: relevantRoutes,
|
||||
contracted,
|
||||
missing,
|
||||
orphaned,
|
||||
duplicateContracts,
|
||||
duplicateOperationIds,
|
||||
pathParameterMismatches,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCoverageErrors(coverage: ContractCoverage) {
|
||||
return [
|
||||
...coverage.duplicateContracts.map(message => `Duplicate contract: ${message}`),
|
||||
...coverage.duplicateOperationIds.map(message => `Duplicate operationId: ${message}`),
|
||||
...coverage.orphaned.map(
|
||||
contract =>
|
||||
`Contract does not match a route: ${getOperationKey(contract)} (${contract.source})`,
|
||||
),
|
||||
...coverage.pathParameterMismatches.map(message => `Path parameter mismatch: ${message}`),
|
||||
];
|
||||
}
|
||||
|
||||
export function formatCoverageSummary(coverage: ContractCoverage) {
|
||||
return `${coverage.contracted.length}/${coverage.discovered.length} API operations have explicit OpenAPI contracts; ${coverage.missing.length} remain.`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import { API_HTTP_METHODS, type ApiHttpMethod } from '@/openapi/operation';
|
||||
|
||||
const ROUTE_FILE_PATTERN = /^route\.(?:js|jsx|ts|tsx)$/;
|
||||
const METHOD_NAMES = new Set(API_HTTP_METHODS.map(method => method.toUpperCase()));
|
||||
|
||||
export interface DiscoveredApiOperation {
|
||||
method: ApiHttpMethod;
|
||||
path: `/${string}`;
|
||||
source: string;
|
||||
}
|
||||
|
||||
async function walk(directory: string, predicate: (name: string) => boolean): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return walk(entryPath, predicate);
|
||||
}
|
||||
|
||||
return predicate(entry.name) ? [entryPath] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
function hasExportModifier(node: ts.Node) {
|
||||
return ts.canHaveModifiers(node)
|
||||
? ts.getModifiers(node)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
||||
: false;
|
||||
}
|
||||
|
||||
function getExportedMethods(source: ts.SourceFile): ApiHttpMethod[] {
|
||||
const methods = new Set<ApiHttpMethod>();
|
||||
|
||||
const addMethod = (name: string) => {
|
||||
if (METHOD_NAMES.has(name)) {
|
||||
methods.add(name.toLowerCase() as ApiHttpMethod);
|
||||
}
|
||||
};
|
||||
|
||||
source.statements.forEach(statement => {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name && hasExportModifier(statement)) {
|
||||
addMethod(statement.name.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
|
||||
statement.declarationList.declarations.forEach(declaration => {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
addMethod(declaration.name.text);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isExportDeclaration(statement) && statement.exportClause) {
|
||||
if (ts.isNamedExports(statement.exportClause)) {
|
||||
statement.exportClause.elements.forEach(element => {
|
||||
addMethod(element.name.text);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return API_HTTP_METHODS.filter(method => methods.has(method));
|
||||
}
|
||||
|
||||
function toOpenApiSegment(segment: string) {
|
||||
if ((segment.startsWith('(') && segment.endsWith(')')) || segment.startsWith('@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)]]$/);
|
||||
const catchAll = segment.match(/^\[\.\.\.(.+)]$/);
|
||||
const dynamic = segment.match(/^\[(.+)]$/);
|
||||
const name = optionalCatchAll?.[1] ?? catchAll?.[1] ?? dynamic?.[1];
|
||||
|
||||
return name ? `{${name}}` : segment;
|
||||
}
|
||||
|
||||
function getRoutePath(apiRoot: string, routeFile: string): `/${string}` {
|
||||
const relativeDirectory = path.relative(apiRoot, path.dirname(routeFile));
|
||||
const segments = relativeDirectory
|
||||
.split(path.sep)
|
||||
.filter(Boolean)
|
||||
.map(toOpenApiSegment)
|
||||
.filter((segment): segment is string => segment !== null);
|
||||
|
||||
return `/api${segments.length ? `/${segments.join('/')}` : ''}`;
|
||||
}
|
||||
|
||||
export async function discoverApiOperations(
|
||||
projectRoot = process.cwd(),
|
||||
): Promise<DiscoveredApiOperation[]> {
|
||||
const apiRoot = path.join(projectRoot, 'src', 'app', 'api');
|
||||
const routeFiles = await walk(apiRoot, name => ROUTE_FILE_PATTERN.test(name));
|
||||
const operations: DiscoveredApiOperation[] = [];
|
||||
|
||||
for (const routeFile of routeFiles.sort()) {
|
||||
const content = await readFile(routeFile, 'utf8');
|
||||
const source = ts.createSourceFile(
|
||||
routeFile,
|
||||
content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
routeFile.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const routePath = getRoutePath(apiRoot, routeFile);
|
||||
const relativeSource = path.relative(projectRoot, routeFile).replaceAll(path.sep, '/');
|
||||
|
||||
getExportedMethods(source).forEach(method => {
|
||||
operations.push({ method, path: routePath, source: relativeSource });
|
||||
});
|
||||
}
|
||||
|
||||
return operations.sort((left, right) =>
|
||||
`${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function discoverContractFiles(projectRoot = process.cwd()) {
|
||||
const apiRoot = path.join(projectRoot, 'src', 'app', 'api');
|
||||
|
||||
return (await walk(apiRoot, name => name === 'contract.ts')).sort();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createDocument, type ZodOpenApiPathsObject } from 'zod-openapi';
|
||||
import { loadApiContracts } from '@/openapi/contracts';
|
||||
import {
|
||||
analyzeContractCoverage,
|
||||
type ContractCoverage,
|
||||
getCoverageErrors,
|
||||
} from '@/openapi/coverage';
|
||||
import { discoverApiOperations } from '@/openapi/discover';
|
||||
import type { ApiAudience } from '@/openapi/operation';
|
||||
import { getSecurityRequirements, securitySchemes } from '@/openapi/security';
|
||||
|
||||
export type DocumentAudience = ApiAudience | 'all';
|
||||
|
||||
export interface OpenApiBuildResult {
|
||||
document: ReturnType<typeof createDocument>;
|
||||
coverage: ContractCoverage;
|
||||
}
|
||||
|
||||
async function getPackageVersion(projectRoot: string) {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(projectRoot, 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function getDocumentTags(paths: ZodOpenApiPathsObject) {
|
||||
const names = new Set<string>();
|
||||
|
||||
Object.values(paths).forEach(pathItem => {
|
||||
Object.values(pathItem).forEach(value => {
|
||||
if (value && typeof value === 'object' && 'tags' in value && Array.isArray(value.tags)) {
|
||||
value.tags.forEach(tag => {
|
||||
names.add(tag);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return [...names].sort().map(name => ({ name }));
|
||||
}
|
||||
|
||||
export async function buildOpenApiDocument(
|
||||
audience: DocumentAudience = 'public',
|
||||
projectRoot = process.cwd(),
|
||||
): Promise<OpenApiBuildResult> {
|
||||
const [contracts, discovered, version] = await Promise.all([
|
||||
loadApiContracts(projectRoot),
|
||||
discoverApiOperations(projectRoot),
|
||||
getPackageVersion(projectRoot),
|
||||
]);
|
||||
const coverage = analyzeContractCoverage(discovered, contracts);
|
||||
const errors = getCoverageErrors(coverage);
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(`Invalid OpenAPI contracts:\n${errors.map(error => `- ${error}`).join('\n')}`);
|
||||
}
|
||||
|
||||
const selectedContracts = contracts
|
||||
.filter(contract => audience === 'all' || contract.audience === audience)
|
||||
.sort((left, right) =>
|
||||
`${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`),
|
||||
);
|
||||
const paths: ZodOpenApiPathsObject = {};
|
||||
|
||||
selectedContracts.forEach(contract => {
|
||||
let pathItem = paths[contract.path];
|
||||
|
||||
if (!pathItem) {
|
||||
pathItem = {};
|
||||
paths[contract.path] = pathItem;
|
||||
}
|
||||
|
||||
const operation = {
|
||||
...contract.operation,
|
||||
security: getSecurityRequirements(contract.auth),
|
||||
'x-umami-audience': contract.audience,
|
||||
};
|
||||
|
||||
Object.assign(pathItem, { [contract.method]: operation });
|
||||
});
|
||||
|
||||
const document = createDocument(
|
||||
{
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Umami API',
|
||||
version,
|
||||
description:
|
||||
'REST API for Umami analytics. Self-hosted deployments serve these paths beneath their configured BASE_PATH.',
|
||||
license: {
|
||||
name: 'MIT',
|
||||
identifier: 'MIT',
|
||||
},
|
||||
},
|
||||
externalDocs: {
|
||||
description: 'Umami API documentation',
|
||||
url: 'https://docs.umami.is/docs/api',
|
||||
},
|
||||
tags: getDocumentTags(paths),
|
||||
paths,
|
||||
components: {
|
||||
securitySchemes,
|
||||
},
|
||||
},
|
||||
{
|
||||
reused: 'inline',
|
||||
cycles: 'ref',
|
||||
},
|
||||
);
|
||||
|
||||
return { document, coverage };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ZodOpenApiOperationObject } from 'zod-openapi';
|
||||
|
||||
export const API_HTTP_METHODS = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'options',
|
||||
] as const;
|
||||
|
||||
export type ApiHttpMethod = (typeof API_HTTP_METHODS)[number];
|
||||
export type ApiAudience = 'public' | 'internal' | 'collect';
|
||||
export type ApiAuth = 'none' | 'bearer' | 'bearer-or-share';
|
||||
|
||||
export interface ApiOperationContract {
|
||||
method: ApiHttpMethod;
|
||||
path: `/${string}`;
|
||||
audience: ApiAudience;
|
||||
auth: ApiAuth;
|
||||
operation: ZodOpenApiOperationObject;
|
||||
}
|
||||
|
||||
export interface LoadedApiOperationContract extends ApiOperationContract {
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function defineOperation<const T extends ApiOperationContract>(contract: T): T {
|
||||
return contract;
|
||||
}
|
||||
|
||||
export function getOperationKey({ method, path }: Pick<ApiOperationContract, 'method' | 'path'>) {
|
||||
return `${method.toUpperCase()} ${path}`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod';
|
||||
import type { ZodOpenApiResponseObject } from 'zod-openapi';
|
||||
|
||||
export const apiErrorSchema = z
|
||||
.object({
|
||||
error: z.looseObject({
|
||||
message: z.string(),
|
||||
code: z.string(),
|
||||
status: z.number().int(),
|
||||
}),
|
||||
})
|
||||
.meta({ id: 'ApiError', description: 'Standard Umami API error response.' });
|
||||
|
||||
export const okSchema = z
|
||||
.object({
|
||||
ok: z.literal(true),
|
||||
})
|
||||
.meta({ id: 'Ok', description: 'Successful operation response.' });
|
||||
|
||||
export function jsonResponse(
|
||||
schema: z.ZodType,
|
||||
description = 'Successful response.',
|
||||
): ZodOpenApiResponseObject {
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(
|
||||
id: string,
|
||||
status: number,
|
||||
code: string,
|
||||
description: string,
|
||||
): ZodOpenApiResponseObject {
|
||||
return {
|
||||
id,
|
||||
description,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: apiErrorSchema,
|
||||
example: {
|
||||
error: {
|
||||
message: description,
|
||||
code,
|
||||
status,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const badRequestResponse = errorResponse(
|
||||
'BadRequestResponse',
|
||||
400,
|
||||
'bad-request',
|
||||
'Bad request.',
|
||||
);
|
||||
export const unauthorizedResponse = errorResponse(
|
||||
'UnauthorizedResponse',
|
||||
401,
|
||||
'unauthorized',
|
||||
'Unauthorized.',
|
||||
);
|
||||
export const forbiddenResponse = errorResponse('ForbiddenResponse', 403, 'forbidden', 'Forbidden.');
|
||||
export const notFoundResponse = errorResponse('NotFoundResponse', 404, 'not-found', 'Not found.');
|
||||
export const payloadTooLargeResponse = errorResponse(
|
||||
'PayloadTooLargeResponse',
|
||||
413,
|
||||
'payload-too-large',
|
||||
'Payload too large.',
|
||||
);
|
||||
export const serverErrorResponse = errorResponse(
|
||||
'ServerErrorResponse',
|
||||
500,
|
||||
'server-error',
|
||||
'Server error.',
|
||||
);
|
||||
export const serviceUnavailableResponse = errorResponse(
|
||||
'ServiceUnavailableResponse',
|
||||
503,
|
||||
'service-unavailable',
|
||||
'Service unavailable.',
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ApiAuth } from '@/openapi/operation';
|
||||
|
||||
export const securitySchemes = {
|
||||
bearerAuth: {
|
||||
type: 'http' as const,
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Token returned by POST /api/auth/login.',
|
||||
},
|
||||
shareToken: {
|
||||
type: 'apiKey' as const,
|
||||
in: 'header' as const,
|
||||
name: 'x-umami-share-token',
|
||||
description: 'Signed token returned by a share endpoint.',
|
||||
},
|
||||
shareContext: {
|
||||
type: 'apiKey' as const,
|
||||
in: 'header' as const,
|
||||
name: 'x-umami-share-context',
|
||||
description: 'Required context header when authenticating with a share token.',
|
||||
},
|
||||
};
|
||||
|
||||
export function getSecurityRequirements(auth: ApiAuth) {
|
||||
if (auth === 'none') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (auth === 'bearer-or-share') {
|
||||
return [{ bearerAuth: [] }, { shareToken: [], shareContext: [] }];
|
||||
}
|
||||
|
||||
return [{ bearerAuth: [] }];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
function sortJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortJson);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, child]) => [key, sortJson(child)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function serializeOpenApiDocument(document: unknown) {
|
||||
return `${JSON.stringify(sortJson(document), null, 2)}\n`;
|
||||
}
|
||||
Reference in new issue
Block a user