Generate OpenAPI from App Router handlers with inferred contracts and source analysis
This commit is contained in:
1 parent
5ce5664b29
commit
cc5d71d39f
11 files changed
+21892
-34
No files matched your search
+16
-9
@@ -1,15 +1,16 @@
|
||||
# OpenAPI contracts
|
||||
|
||||
Umami generates `public/openapi.json` from Zod-backed contracts colocated with App Router API
|
||||
handlers.
|
||||
Umami generates `public/openapi.json` for every App Router API handler. It statically infers a
|
||||
baseline contract from route source and replaces that baseline with a colocated, Zod-backed
|
||||
contract wherever one exists.
|
||||
|
||||
## 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
|
||||
- `pnpm openapi:check --explicit` also fails while any non-OPTIONS API route lacks a hand-authored
|
||||
contract.
|
||||
- `pnpm openapi:check --verbose` lists routes that still need contracts.
|
||||
- `pnpm openapi:check --verbose` lists operations that still use inferred contracts.
|
||||
|
||||
The normal and Docker builds run the generator before the Next.js build. CI runs the check before
|
||||
tests and build steps.
|
||||
@@ -23,12 +24,18 @@ request and response shapes with Zod, and import the same request schema into th
|
||||
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:
|
||||
The generated artifact contains all audiences. Every operation declares one of:
|
||||
|
||||
- `public` operations are emitted to `public/openapi.json`.
|
||||
- `internal` operations are available when building an unfiltered document.
|
||||
- `public` operations are intended for API consumers.
|
||||
- `internal` operations support the Umami application or deployment.
|
||||
- `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.
|
||||
requires all discovered non-OPTIONS operations in the artifact and rejects duplicate contracts,
|
||||
duplicate operation IDs, orphaned contracts, and mismatched dynamic path parameters.
|
||||
|
||||
Inferred operations are marked with `x-umami-contract: inferred` and their source file. The inference
|
||||
layer extracts path parameters, Zod request fields, authentication, response status helpers, and
|
||||
response media types without importing route modules. Unknown response bodies remain free-form JSON
|
||||
instead of being assigned an unverified shape. Explicit contracts are marked `explicit` and remain
|
||||
the way to add exact response schemas, examples, and curated prose.
|
||||
@@ -25,7 +25,7 @@ export async function loadApiContracts(
|
||||
const source = path.relative(projectRoot, contractFile).replaceAll(path.sep, '/');
|
||||
|
||||
module.operations.forEach(operation => {
|
||||
contracts.push({ ...operation, source });
|
||||
contracts.push({ ...operation, source, origin: 'explicit' });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+30
-8
@@ -8,7 +8,9 @@ import {
|
||||
export interface ContractCoverage {
|
||||
discovered: DiscoveredApiOperation[];
|
||||
contracted: LoadedApiOperationContract[];
|
||||
generated: LoadedApiOperationContract[];
|
||||
missing: DiscoveredApiOperation[];
|
||||
missingGenerated: DiscoveredApiOperation[];
|
||||
orphaned: LoadedApiOperationContract[];
|
||||
duplicateContracts: string[];
|
||||
duplicateOperationIds: string[];
|
||||
@@ -23,11 +25,22 @@ function getContractPathParameters(contract: ApiOperationContract) {
|
||||
const schema = contract.operation.requestParams?.path as { shape?: unknown } | undefined;
|
||||
const shape = schema?.shape;
|
||||
|
||||
if (!shape || typeof shape !== 'object') {
|
||||
return [];
|
||||
if (shape && typeof shape === 'object') {
|
||||
return Object.keys(shape).sort();
|
||||
}
|
||||
|
||||
return Object.keys(shape).sort();
|
||||
return (contract.operation.parameters ?? [])
|
||||
.filter(
|
||||
parameter =>
|
||||
parameter &&
|
||||
typeof parameter === 'object' &&
|
||||
'in' in parameter &&
|
||||
parameter.in === 'path' &&
|
||||
'name' in parameter &&
|
||||
typeof parameter.name === 'string',
|
||||
)
|
||||
.map(parameter => ('name' in parameter ? (parameter.name as string) : ''))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[], right: string[]) {
|
||||
@@ -37,13 +50,14 @@ function arraysEqual(left: string[], right: string[]) {
|
||||
export function analyzeContractCoverage(
|
||||
discovered: DiscoveredApiOperation[],
|
||||
contracted: LoadedApiOperationContract[],
|
||||
generated: LoadedApiOperationContract[] = contracted,
|
||||
): 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 => {
|
||||
generated.forEach(contract => {
|
||||
const key = getOperationKey(contract);
|
||||
const operationId = contract.operation.operationId;
|
||||
|
||||
@@ -69,11 +83,13 @@ export function analyzeContractCoverage(
|
||||
)
|
||||
.sort();
|
||||
const contractedKeys = new Set(contractsByKey.keys());
|
||||
const missing = relevantRoutes.filter(
|
||||
const explicitKeys = new Set(contracted.map(getOperationKey));
|
||||
const missing = relevantRoutes.filter(operation => !explicitKeys.has(getOperationKey(operation)));
|
||||
const missingGenerated = relevantRoutes.filter(
|
||||
operation => !contractedKeys.has(getOperationKey(operation)),
|
||||
);
|
||||
const orphaned = contracted.filter(contract => !discoveredKeys.has(getOperationKey(contract)));
|
||||
const pathParameterMismatches = contracted
|
||||
const orphaned = generated.filter(contract => !discoveredKeys.has(getOperationKey(contract)));
|
||||
const pathParameterMismatches = generated
|
||||
.map(contract => {
|
||||
const expected = getExpectedPathParameters(contract.path);
|
||||
const actual = getContractPathParameters(contract);
|
||||
@@ -90,7 +106,9 @@ export function analyzeContractCoverage(
|
||||
return {
|
||||
discovered: relevantRoutes,
|
||||
contracted,
|
||||
generated,
|
||||
missing,
|
||||
missingGenerated,
|
||||
orphaned,
|
||||
duplicateContracts,
|
||||
duplicateOperationIds,
|
||||
@@ -107,9 +125,13 @@ export function getCoverageErrors(coverage: ContractCoverage) {
|
||||
`Contract does not match a route: ${getOperationKey(contract)} (${contract.source})`,
|
||||
),
|
||||
...coverage.pathParameterMismatches.map(message => `Path parameter mismatch: ${message}`),
|
||||
...coverage.missingGenerated.map(
|
||||
operation =>
|
||||
`Generated spec is missing route: ${getOperationKey(operation)} (${operation.source})`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function formatCoverageSummary(coverage: ContractCoverage) {
|
||||
return `${coverage.contracted.length}/${coverage.discovered.length} API operations have explicit OpenAPI contracts; ${coverage.missing.length} remain.`;
|
||||
return `${coverage.generated.length}/${coverage.discovered.length} API operations are included in the generated spec (${coverage.contracted.length} explicit, ${coverage.missing.length} source-inferred).`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { analyzeSourceOperation, type SourceOperationAnalysis } from '@/openapi/source-analysis';
|
||||
|
||||
const ROUTE_FILE_PATTERN = /^route\.(?:js|jsx|ts|tsx)$/;
|
||||
const METHOD_NAMES = new Set(API_HTTP_METHODS.map(method => method.toUpperCase()));
|
||||
@@ -10,6 +11,7 @@ export interface DiscoveredApiOperation {
|
||||
method: ApiHttpMethod;
|
||||
path: `/${string}`;
|
||||
source: string;
|
||||
analysis: SourceOperationAnalysis;
|
||||
}
|
||||
|
||||
async function walk(directory: string, predicate: (name: string) => boolean): Promise<string[]> {
|
||||
@@ -115,7 +117,12 @@ export async function discoverApiOperations(
|
||||
const relativeSource = path.relative(projectRoot, routeFile).replaceAll(path.sep, '/');
|
||||
|
||||
getExportedMethods(source).forEach(method => {
|
||||
operations.push({ method, path: routePath, source: relativeSource });
|
||||
operations.push({
|
||||
method,
|
||||
path: routePath,
|
||||
source: relativeSource,
|
||||
analysis: analyzeSourceOperation(source, method),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getCoverageErrors,
|
||||
} from '@/openapi/coverage';
|
||||
import { discoverApiOperations } from '@/openapi/discover';
|
||||
import { inferApiContracts } from '@/openapi/infer';
|
||||
import type { ApiAudience } from '@/openapi/operation';
|
||||
import { getSecurityRequirements, securitySchemes } from '@/openapi/security';
|
||||
|
||||
@@ -46,12 +47,14 @@ export async function buildOpenApiDocument(
|
||||
audience: DocumentAudience = 'public',
|
||||
projectRoot = process.cwd(),
|
||||
): Promise<OpenApiBuildResult> {
|
||||
const [contracts, discovered, version] = await Promise.all([
|
||||
const [explicitContracts, discovered, version] = await Promise.all([
|
||||
loadApiContracts(projectRoot),
|
||||
discoverApiOperations(projectRoot),
|
||||
getPackageVersion(projectRoot),
|
||||
]);
|
||||
const coverage = analyzeContractCoverage(discovered, contracts);
|
||||
const inferredContracts = inferApiContracts(discovered, explicitContracts);
|
||||
const contracts = [...explicitContracts, ...inferredContracts];
|
||||
const coverage = analyzeContractCoverage(discovered, explicitContracts, contracts);
|
||||
const errors = getCoverageErrors(coverage);
|
||||
|
||||
if (errors.length) {
|
||||
@@ -77,6 +80,8 @@ export async function buildOpenApiDocument(
|
||||
...contract.operation,
|
||||
security: getSecurityRequirements(contract.auth),
|
||||
'x-umami-audience': contract.audience,
|
||||
'x-umami-contract': contract.origin,
|
||||
'x-umami-source': contract.source,
|
||||
};
|
||||
|
||||
Object.assign(pathItem, { [contract.method]: operation });
|
||||
@@ -89,7 +94,7 @@ export async function buildOpenApiDocument(
|
||||
title: 'Umami API',
|
||||
version,
|
||||
description:
|
||||
'REST API for Umami analytics. Self-hosted deployments serve these paths beneath their configured BASE_PATH.',
|
||||
'REST API for Umami analytics. Every App Router API operation is included. Operations marked with x-umami-contract: inferred are generated from handler source; colocated explicit contracts provide curated descriptions and exact response models. Self-hosted deployments serve these paths beneath their configured BASE_PATH.',
|
||||
license: {
|
||||
name: 'MIT',
|
||||
identifier: 'MIT',
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
ZodOpenApiOperationObject,
|
||||
ZodOpenApiParameterObject,
|
||||
ZodOpenApiResponseObject,
|
||||
ZodOpenApiResponsesObject,
|
||||
ZodOpenApiSchemaObject,
|
||||
} from 'zod-openapi';
|
||||
import type { DiscoveredApiOperation } from '@/openapi/discover';
|
||||
import type { ApiAudience, ApiHttpMethod, LoadedApiOperationContract } from '@/openapi/operation';
|
||||
import { getOperationKey } from '@/openapi/operation';
|
||||
import {
|
||||
badRequestResponse,
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
okSchema,
|
||||
payloadTooLargeResponse,
|
||||
serverErrorResponse,
|
||||
serviceUnavailableResponse,
|
||||
unauthorizedResponse,
|
||||
} from '@/openapi/schemas';
|
||||
import type { InferredOpenApiSchema } from '@/openapi/source-analysis';
|
||||
|
||||
const errorResponses: Record<number, ZodOpenApiResponseObject> = {
|
||||
400: badRequestResponse,
|
||||
401: unauthorizedResponse,
|
||||
403: forbiddenResponse,
|
||||
404: notFoundResponse,
|
||||
413: payloadTooLargeResponse,
|
||||
500: serverErrorResponse,
|
||||
503: serviceUnavailableResponse,
|
||||
};
|
||||
|
||||
const TAGS: Record<string, string> = {
|
||||
'2fa': 'Two-factor authentication',
|
||||
admin: 'Administration',
|
||||
auth: 'Authentication',
|
||||
batch: 'Collection',
|
||||
boards: 'Boards',
|
||||
config: 'Configuration',
|
||||
dashboard: 'Dashboard',
|
||||
heartbeat: 'System',
|
||||
links: 'Links',
|
||||
me: 'Account',
|
||||
pixels: 'Pixels',
|
||||
realtime: 'Realtime',
|
||||
record: 'Collection',
|
||||
reports: 'Reports',
|
||||
scripts: 'Scripts',
|
||||
send: 'Collection',
|
||||
share: 'Shares',
|
||||
teams: 'Teams',
|
||||
users: 'Users',
|
||||
websites: 'Websites',
|
||||
};
|
||||
|
||||
function getPathSegments(path: string) {
|
||||
return path.split('/').filter(Boolean).slice(1);
|
||||
}
|
||||
|
||||
function words(value: string) {
|
||||
return value
|
||||
.replace(/[{}]/g, '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function pascalCase(value: string) {
|
||||
return words(value)
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(word => `${word[0]?.toUpperCase()}${word.slice(1)}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getOperationId(method: ApiHttpMethod, path: string) {
|
||||
return `inferred${pascalCase(method)}${getPathSegments(path).map(pascalCase).join('')}`;
|
||||
}
|
||||
|
||||
function getSummary(method: ApiHttpMethod, path: string) {
|
||||
const action: Record<ApiHttpMethod, string> = {
|
||||
get: 'Get',
|
||||
post: 'Create or update',
|
||||
put: 'Replace',
|
||||
patch: 'Update',
|
||||
delete: 'Delete',
|
||||
head: 'Inspect',
|
||||
options: 'Inspect options for',
|
||||
};
|
||||
const label = getPathSegments(path)
|
||||
.map(segment => words(segment))
|
||||
.join(' ');
|
||||
|
||||
return `${action[method]} ${label}`;
|
||||
}
|
||||
|
||||
function getAudience(path: string): ApiAudience {
|
||||
if (
|
||||
path === '/api/send' ||
|
||||
path === '/api/batch' ||
|
||||
path === '/api/record' ||
|
||||
path.endsWith('/recorder')
|
||||
) {
|
||||
return 'collect';
|
||||
}
|
||||
|
||||
if (
|
||||
['/api/admin', '/api/config', '/api/dashboard', '/api/heartbeat', '/api/scripts'].some(
|
||||
prefix => path === prefix || path.startsWith(`${prefix}/`),
|
||||
)
|
||||
) {
|
||||
return 'internal';
|
||||
}
|
||||
|
||||
return 'public';
|
||||
}
|
||||
|
||||
function getTag(path: string) {
|
||||
const root = getPathSegments(path)[0] ?? 'api';
|
||||
return TAGS[root] ?? pascalCase(root);
|
||||
}
|
||||
|
||||
function getPathParameters(path: string): ZodOpenApiParameterObject[] {
|
||||
return [...path.matchAll(/\{([^}]+)}/g)].map(match => ({
|
||||
name: match[1],
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
}));
|
||||
}
|
||||
|
||||
function getQueryParameters(schema: InferredOpenApiSchema | undefined) {
|
||||
if (schema?.type !== 'object' || !schema.properties) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const required = new Set((schema.required ?? []) as string[]);
|
||||
|
||||
return Object.entries(schema.properties).map(([name, propertySchema]) => ({
|
||||
name,
|
||||
in: 'query' as const,
|
||||
required: required.has(name),
|
||||
schema: propertySchema,
|
||||
}));
|
||||
}
|
||||
|
||||
function successResponse(mediaType: string, returnsOk: boolean): ZodOpenApiResponseObject {
|
||||
return {
|
||||
description: returnsOk
|
||||
? 'The operation completed successfully.'
|
||||
: 'Successful response. The response shape is inferred as free-form because the handler does not expose a reusable response schema.',
|
||||
content: {
|
||||
[mediaType]: {
|
||||
schema: returnsOk
|
||||
? okSchema
|
||||
: mediaType === 'application/json'
|
||||
? ({} as ZodOpenApiSchemaObject)
|
||||
: ({ type: 'string' } as ZodOpenApiSchemaObject),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getResponses(operation: DiscoveredApiOperation): ZodOpenApiResponsesObject {
|
||||
const responses: ZodOpenApiResponsesObject = {};
|
||||
|
||||
operation.analysis.responseStatuses.forEach(status => {
|
||||
const response = errorResponses[status];
|
||||
responses[`${status}` as `${1 | 2 | 3 | 4 | 5}${string}`] =
|
||||
response ??
|
||||
successResponse(operation.analysis.responseMediaType, operation.analysis.returnsOk);
|
||||
});
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
function getOperation(operation: DiscoveredApiOperation): ZodOpenApiOperationObject {
|
||||
const { analysis, method, path, source } = operation;
|
||||
const parameters = [
|
||||
...getPathParameters(path),
|
||||
...(method === 'get' ? getQueryParameters(analysis.requestSchema) : []),
|
||||
];
|
||||
const requestBody =
|
||||
method !== 'get' && analysis.hasRequestSchema
|
||||
? {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: analysis.requestSchema ?? ({} as ZodOpenApiSchemaObject),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
operationId: getOperationId(method, path),
|
||||
summary: getSummary(method, path),
|
||||
description:
|
||||
'Generated from the App Router handler and its request-validation source. Add a colocated contract.ts to supply exact response models and curated documentation.',
|
||||
tags: [getTag(path)],
|
||||
...(parameters.length ? { parameters } : {}),
|
||||
...(requestBody ? { requestBody } : {}),
|
||||
responses: getResponses(operation),
|
||||
'x-umami-source': source,
|
||||
} as ZodOpenApiOperationObject;
|
||||
}
|
||||
|
||||
export function inferApiContracts(
|
||||
discovered: DiscoveredApiOperation[],
|
||||
explicit: LoadedApiOperationContract[],
|
||||
): LoadedApiOperationContract[] {
|
||||
const explicitKeys = new Set(explicit.map(getOperationKey));
|
||||
|
||||
return discovered
|
||||
.filter(
|
||||
operation => operation.method !== 'options' && !explicitKeys.has(getOperationKey(operation)),
|
||||
)
|
||||
.map(operation => ({
|
||||
method: operation.method,
|
||||
path: operation.path,
|
||||
audience: getAudience(operation.path),
|
||||
auth: operation.analysis.auth,
|
||||
operation: getOperation(operation),
|
||||
source: operation.source,
|
||||
origin: 'inferred',
|
||||
}));
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface ApiOperationContract {
|
||||
|
||||
export interface LoadedApiOperationContract extends ApiOperationContract {
|
||||
source: string;
|
||||
origin: 'explicit' | 'inferred';
|
||||
}
|
||||
|
||||
export function defineOperation<const T extends ApiOperationContract>(contract: T): T {
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
import ts from 'typescript';
|
||||
import type { ApiAuth, ApiHttpMethod } from '@/openapi/operation';
|
||||
|
||||
export interface InferredOpenApiSchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
enum?: unknown[];
|
||||
const?: unknown;
|
||||
properties?: Record<string, InferredOpenApiSchema>;
|
||||
required?: string[];
|
||||
items?: InferredOpenApiSchema;
|
||||
prefixItems?: InferredOpenApiSchema[];
|
||||
anyOf?: InferredOpenApiSchema[];
|
||||
allOf?: InferredOpenApiSchema[];
|
||||
additionalProperties?: boolean | InferredOpenApiSchema;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
exclusiveMinimum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type Schema = InferredOpenApiSchema;
|
||||
|
||||
export interface SourceOperationAnalysis {
|
||||
auth: ApiAuth;
|
||||
hasRequestSchema: boolean;
|
||||
requestSchema?: Schema;
|
||||
responseStatuses: number[];
|
||||
responseMediaType: string;
|
||||
returnsOk: boolean;
|
||||
}
|
||||
|
||||
interface InferredSchema {
|
||||
schema: Schema;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
const RESPONSE_STATUSES: Record<string, number> = {
|
||||
json: 200,
|
||||
ok: 200,
|
||||
badRequest: 400,
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
notFound: 404,
|
||||
payloadTooLarge: 413,
|
||||
serverError: 500,
|
||||
serviceUnavailable: 503,
|
||||
};
|
||||
|
||||
const stringSchema = (): Schema => ({ type: 'string' });
|
||||
const numberSchema = (): Schema => ({ type: 'number' });
|
||||
const booleanSchema = (): Schema => ({ type: 'boolean' });
|
||||
const unknownSchema = (): Schema => ({});
|
||||
|
||||
function required(schema: Schema): InferredSchema {
|
||||
return { schema, optional: false };
|
||||
}
|
||||
|
||||
function objectSchema(
|
||||
properties: Record<string, Schema>,
|
||||
requiredProperties: string[] = Object.keys(properties),
|
||||
): Schema {
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(requiredProperties.length ? { required: requiredProperties } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const knownSchemas: Record<string, () => InferredSchema> = {
|
||||
timezoneParam: () => required(stringSchema()),
|
||||
unitParam: () => required(stringSchema()),
|
||||
userRoleParam: () => required({ type: 'string', enum: ['admin', 'user', 'view-only'] }),
|
||||
teamRoleParam: () =>
|
||||
required({ type: 'string', enum: ['team-member', 'team-view-only', 'team-manager'] }),
|
||||
anyObjectParam: () => required({ type: 'object', additionalProperties: true }),
|
||||
urlOrPathParam: () => required(stringSchema()),
|
||||
fieldsParam: () => required(stringSchema()),
|
||||
reportTypeParam: () => required(stringSchema()),
|
||||
operatorParam: () => required(stringSchema()),
|
||||
segmentTypeParam: () => required({ type: 'string', enum: ['segment', 'cohort'] }),
|
||||
annotationSchema: () =>
|
||||
required(
|
||||
objectSchema(
|
||||
{
|
||||
date: { type: 'string', format: 'date-time' },
|
||||
allDay: booleanSchema(),
|
||||
note: { type: 'string', minLength: 1, maxLength: 500 },
|
||||
},
|
||||
['date', 'note'],
|
||||
),
|
||||
),
|
||||
segmentParamSchema: () =>
|
||||
required(
|
||||
objectSchema(
|
||||
{
|
||||
filters: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
match: { type: 'string', enum: ['all', 'any'] },
|
||||
dateRange: stringSchema(),
|
||||
action: objectSchema({ type: stringSchema(), value: stringSchema() }),
|
||||
},
|
||||
[],
|
||||
),
|
||||
),
|
||||
reportSchema: () =>
|
||||
required(
|
||||
objectSchema(
|
||||
{
|
||||
websiteId: { type: 'string', format: 'uuid' },
|
||||
type: stringSchema(),
|
||||
name: { type: 'string', maxLength: 200 },
|
||||
description: { type: 'string', maxLength: 500 },
|
||||
parameters: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
['websiteId', 'type', 'name', 'parameters'],
|
||||
),
|
||||
),
|
||||
reportResultSchema: () =>
|
||||
required(
|
||||
objectSchema(
|
||||
{
|
||||
websiteId: { type: 'string', format: 'uuid' },
|
||||
filters: { type: 'object', additionalProperties: true },
|
||||
type: stringSchema(),
|
||||
parameters: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
['websiteId', 'filters', 'type', 'parameters'],
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const knownFragments: Record<string, Schema> = {
|
||||
dateRangeParams: objectSchema(
|
||||
{
|
||||
startAt: numberSchema(),
|
||||
endAt: numberSchema(),
|
||||
startDate: { type: 'string', format: 'date-time' },
|
||||
endDate: { type: 'string', format: 'date-time' },
|
||||
timezone: stringSchema(),
|
||||
unit: stringSchema(),
|
||||
compare: { type: 'string', enum: ['prev', 'yoy'] },
|
||||
},
|
||||
[],
|
||||
),
|
||||
filterParams: objectSchema(
|
||||
{
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'path',
|
||||
'referrer',
|
||||
'title',
|
||||
'query',
|
||||
'os',
|
||||
'browser',
|
||||
'device',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
'tag',
|
||||
'hostname',
|
||||
'distinctId',
|
||||
'language',
|
||||
'event',
|
||||
'utmSource',
|
||||
'utmMedium',
|
||||
'utmCampaign',
|
||||
'utmContent',
|
||||
'utmTerm',
|
||||
'excludeBounce',
|
||||
].map(name => [name, stringSchema()]),
|
||||
),
|
||||
segment: { type: 'string', format: 'uuid' },
|
||||
cohort: { type: 'string', format: 'uuid' },
|
||||
eventType: { type: 'integer', minimum: 1 },
|
||||
match: { type: 'string', enum: ['all', 'any'] },
|
||||
},
|
||||
[],
|
||||
),
|
||||
searchParams: objectSchema({ search: stringSchema() }, []),
|
||||
replayParams: objectSchema({ minDuration: { type: 'integer', minimum: 0 } }, []),
|
||||
pagingParams: objectSchema(
|
||||
{
|
||||
page: { type: 'integer', minimum: 1 },
|
||||
pageSize: { type: 'integer', minimum: 1 },
|
||||
maxResults: { type: 'integer', minimum: 1 },
|
||||
},
|
||||
[],
|
||||
),
|
||||
sortingParams: objectSchema(
|
||||
{
|
||||
orderBy: stringSchema(),
|
||||
sortDescending: { type: 'string', enum: ['true', 'false'] },
|
||||
},
|
||||
[],
|
||||
),
|
||||
};
|
||||
|
||||
function getPropertyName(node: ts.PropertyName | undefined, source: ts.SourceFile) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
return node.getText(source).replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function getLiteralValue(expression: ts.Expression): string | number | boolean | null | undefined {
|
||||
if (ts.isStringLiteral(expression)) {
|
||||
return expression.text;
|
||||
}
|
||||
|
||||
if (ts.isNumericLiteral(expression)) {
|
||||
return Number(expression.text);
|
||||
}
|
||||
|
||||
if (expression.kind === ts.SyntaxKind.TrueKeyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (expression.kind === ts.SyntaxKind.FalseKeyword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expression.kind === ts.SyntaxKind.NullKeyword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mergeObjectSchemas(target: Schema, source: Schema) {
|
||||
const targetProperties = (target.properties ?? {}) as Record<string, Schema>;
|
||||
const sourceProperties = (source.properties ?? {}) as Record<string, Schema>;
|
||||
const requiredProperties = new Set([
|
||||
...((target.required ?? []) as string[]),
|
||||
...((source.required ?? []) as string[]),
|
||||
]);
|
||||
|
||||
target.type = 'object';
|
||||
target.properties = { ...targetProperties, ...sourceProperties };
|
||||
|
||||
if (requiredProperties.size) {
|
||||
target.required = [...requiredProperties];
|
||||
}
|
||||
}
|
||||
|
||||
function inferObjectLiteral(
|
||||
expression: ts.ObjectLiteralExpression,
|
||||
source: ts.SourceFile,
|
||||
declarations: Map<string, ts.Expression>,
|
||||
seen: Set<string>,
|
||||
): InferredSchema {
|
||||
const properties: Record<string, Schema> = {};
|
||||
const requiredProperties = new Set<string>();
|
||||
|
||||
expression.properties.forEach(property => {
|
||||
if (ts.isSpreadAssignment(property)) {
|
||||
const inferred = inferExpression(property.expression, source, declarations, seen);
|
||||
Object.assign(properties, (inferred.schema.properties ?? {}) as Record<string, Schema>);
|
||||
((inferred.schema.required ?? []) as string[]).forEach(name => {
|
||||
requiredProperties.add(name);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = getPropertyName(property.name, source);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inferred = inferExpression(property.initializer, source, declarations, seen);
|
||||
properties[name] = inferred.schema;
|
||||
|
||||
if (!inferred.optional) {
|
||||
requiredProperties.add(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isShorthandPropertyAssignment(property)) {
|
||||
const name = property.name.text;
|
||||
const inferred = inferExpression(property.name, source, declarations, seen);
|
||||
properties[name] = inferred.schema;
|
||||
|
||||
if (!inferred.optional) {
|
||||
requiredProperties.add(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return required(objectSchema(properties, [...requiredProperties]));
|
||||
}
|
||||
|
||||
function applyConstraint(schema: Schema, method: string, argument?: ts.Expression) {
|
||||
const value = argument ? getLiteralValue(argument) : undefined;
|
||||
|
||||
if (method === 'int') {
|
||||
schema.type = 'integer';
|
||||
} else if (method === 'positive') {
|
||||
schema.exclusiveMinimum = 0;
|
||||
} else if (method === 'nonnegative') {
|
||||
schema.minimum = 0;
|
||||
} else if (method === 'min' && typeof value === 'number') {
|
||||
const numericValue = value;
|
||||
|
||||
if (schema.type === 'string') schema.minLength = numericValue;
|
||||
else if (schema.type === 'array') schema.minItems = numericValue;
|
||||
else schema.minimum = numericValue;
|
||||
} else if (method === 'max' && typeof value === 'number') {
|
||||
const numericValue = value;
|
||||
|
||||
if (schema.type === 'string') schema.maxLength = numericValue;
|
||||
else if (schema.type === 'array') schema.maxItems = numericValue;
|
||||
else schema.maximum = numericValue;
|
||||
} else if (method === 'length' && typeof value === 'number') {
|
||||
const numericValue = value;
|
||||
|
||||
if (schema.type === 'array') {
|
||||
schema.minItems = numericValue;
|
||||
schema.maxItems = numericValue;
|
||||
} else {
|
||||
schema.minLength = numericValue;
|
||||
schema.maxLength = numericValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inferCallExpression(
|
||||
expression: ts.CallExpression,
|
||||
source: ts.SourceFile,
|
||||
declarations: Map<string, ts.Expression>,
|
||||
seen: Set<string>,
|
||||
): InferredSchema {
|
||||
const callee = expression.expression;
|
||||
const method = ts.isPropertyAccessExpression(callee) ? callee.name.text : callee.getText(source);
|
||||
const base = ts.isPropertyAccessExpression(callee) ? callee.expression : undefined;
|
||||
|
||||
if (method === 'withDateRange') {
|
||||
const schema = structuredClone(knownFragments.dateRangeParams);
|
||||
|
||||
if (expression.arguments[0]) {
|
||||
mergeObjectSchemas(
|
||||
schema,
|
||||
inferExpression(expression.arguments[0], source, declarations, seen).schema,
|
||||
);
|
||||
}
|
||||
|
||||
return required(schema);
|
||||
}
|
||||
|
||||
if (['optional', 'nullish', 'default', 'catch'].includes(method) && base) {
|
||||
const inferred = inferExpression(base, source, declarations, seen);
|
||||
|
||||
if (method === 'nullish') {
|
||||
inferred.schema = { anyOf: [inferred.schema, { type: 'null' }] };
|
||||
}
|
||||
|
||||
inferred.optional = true;
|
||||
return inferred;
|
||||
}
|
||||
|
||||
if (method === 'nullable' && base) {
|
||||
const inferred = inferExpression(base, source, declarations, seen);
|
||||
inferred.schema = { anyOf: [inferred.schema, { type: 'null' }] };
|
||||
return inferred;
|
||||
}
|
||||
|
||||
if (method === 'or' && base && expression.arguments[0]) {
|
||||
const left = inferExpression(base, source, declarations, seen);
|
||||
const right = inferExpression(expression.arguments[0], source, declarations, seen);
|
||||
return {
|
||||
schema: { anyOf: [left.schema, right.schema] },
|
||||
optional: left.optional && right.optional,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'refine',
|
||||
'superRefine',
|
||||
'transform',
|
||||
'pipe',
|
||||
'strict',
|
||||
'passthrough',
|
||||
'strip',
|
||||
'describe',
|
||||
'meta',
|
||||
'brand',
|
||||
'readonly',
|
||||
].includes(method) &&
|
||||
base
|
||||
) {
|
||||
return inferExpression(base, source, declarations, seen);
|
||||
}
|
||||
|
||||
if (['min', 'max', 'length', 'int', 'positive', 'nonnegative'].includes(method) && base) {
|
||||
const inferred = inferExpression(base, source, declarations, seen);
|
||||
applyConstraint(inferred.schema, method, expression.arguments[0]);
|
||||
return inferred;
|
||||
}
|
||||
|
||||
if (method === 'partial' && base) {
|
||||
const inferred = inferExpression(base, source, declarations, seen);
|
||||
delete inferred.schema.required;
|
||||
return inferred;
|
||||
}
|
||||
|
||||
if (method === 'array' && expression.arguments[0]) {
|
||||
return required({
|
||||
type: 'array',
|
||||
items: inferExpression(expression.arguments[0], source, declarations, seen).schema,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'object' &&
|
||||
expression.arguments[0] &&
|
||||
ts.isObjectLiteralExpression(expression.arguments[0])
|
||||
) {
|
||||
return inferObjectLiteral(expression.arguments[0], source, declarations, seen);
|
||||
}
|
||||
|
||||
if (method === 'record') {
|
||||
const valueExpression = expression.arguments.at(-1);
|
||||
return required({
|
||||
type: 'object',
|
||||
additionalProperties: valueExpression
|
||||
? inferExpression(valueExpression, source, declarations, seen).schema
|
||||
: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (['union', 'discriminatedUnion'].includes(method)) {
|
||||
const variantsExpression = expression.arguments[method === 'union' ? 0 : 1];
|
||||
|
||||
if (variantsExpression && ts.isArrayLiteralExpression(variantsExpression)) {
|
||||
return required({
|
||||
anyOf: variantsExpression.elements.map(
|
||||
element => inferExpression(element as ts.Expression, source, declarations, seen).schema,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'intersection' && expression.arguments.length >= 2) {
|
||||
return required({
|
||||
allOf: expression.arguments.map(
|
||||
argument => inferExpression(argument, source, declarations, seen).schema,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'enum' &&
|
||||
expression.arguments[0] &&
|
||||
ts.isArrayLiteralExpression(expression.arguments[0])
|
||||
) {
|
||||
return required({
|
||||
type: 'string',
|
||||
enum: expression.arguments[0].elements
|
||||
.map(element => getLiteralValue(element as ts.Expression))
|
||||
.filter((value): value is string => typeof value === 'string'),
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'literal' && expression.arguments[0]) {
|
||||
const value = getLiteralValue(expression.arguments[0]);
|
||||
return value === undefined ? required(unknownSchema()) : required({ const: value });
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'tuple' &&
|
||||
expression.arguments[0] &&
|
||||
ts.isArrayLiteralExpression(expression.arguments[0])
|
||||
) {
|
||||
const prefixItems = expression.arguments[0].elements.map(
|
||||
element => inferExpression(element as ts.Expression, source, declarations, seen).schema,
|
||||
);
|
||||
return required({
|
||||
type: 'array',
|
||||
prefixItems,
|
||||
minItems: prefixItems.length,
|
||||
maxItems: prefixItems.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (['string', 'email', 'url'].includes(method)) {
|
||||
return required({ type: 'string', ...(method !== 'string' ? { format: method } : {}) });
|
||||
}
|
||||
|
||||
if (['uuid', 'guid'].includes(method)) {
|
||||
return required({ type: 'string', format: 'uuid' });
|
||||
}
|
||||
|
||||
if (method === 'date') {
|
||||
return required({ type: 'string', format: 'date-time' });
|
||||
}
|
||||
|
||||
if (['number', 'float32', 'float64'].includes(method)) {
|
||||
return required(numberSchema());
|
||||
}
|
||||
|
||||
if (['int', 'int32', 'int64'].includes(method) && base?.getText(source).startsWith('z')) {
|
||||
return required({ type: 'integer' });
|
||||
}
|
||||
|
||||
if (method === 'boolean') {
|
||||
return required(booleanSchema());
|
||||
}
|
||||
|
||||
if (['any', 'unknown', 'undefined', 'void', 'never'].includes(method)) {
|
||||
return required(unknownSchema());
|
||||
}
|
||||
|
||||
if (ts.isIdentifier(callee) && knownSchemas[callee.text]) {
|
||||
return knownSchemas[callee.text]();
|
||||
}
|
||||
|
||||
return required(unknownSchema());
|
||||
}
|
||||
|
||||
function inferExpression(
|
||||
expression: ts.Expression,
|
||||
source: ts.SourceFile,
|
||||
declarations: Map<string, ts.Expression>,
|
||||
seen = new Set<string>(),
|
||||
): InferredSchema {
|
||||
if (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)) {
|
||||
return inferExpression(expression.expression, source, declarations, seen);
|
||||
}
|
||||
|
||||
if (ts.isIdentifier(expression)) {
|
||||
const name = expression.text;
|
||||
|
||||
if (knownFragments[name]) {
|
||||
return required(structuredClone(knownFragments[name]));
|
||||
}
|
||||
|
||||
if (knownSchemas[name]) {
|
||||
return knownSchemas[name]();
|
||||
}
|
||||
|
||||
const declaration = declarations.get(name);
|
||||
|
||||
if (declaration && !seen.has(name)) {
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(name);
|
||||
return inferExpression(declaration, source, declarations, nextSeen);
|
||||
}
|
||||
|
||||
return required(unknownSchema());
|
||||
}
|
||||
|
||||
if (ts.isObjectLiteralExpression(expression)) {
|
||||
return inferObjectLiteral(expression, source, declarations, seen);
|
||||
}
|
||||
|
||||
if (ts.isArrayLiteralExpression(expression)) {
|
||||
const variants = expression.elements.map(
|
||||
element => inferExpression(element as ts.Expression, source, declarations, seen).schema,
|
||||
);
|
||||
return required({
|
||||
type: 'array',
|
||||
items: variants.length === 1 ? variants[0] : { anyOf: variants },
|
||||
});
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(expression)) {
|
||||
return inferCallExpression(expression, source, declarations, seen);
|
||||
}
|
||||
|
||||
const literal = getLiteralValue(expression);
|
||||
|
||||
if (literal !== undefined) {
|
||||
return required({ const: literal });
|
||||
}
|
||||
|
||||
return required(unknownSchema());
|
||||
}
|
||||
|
||||
function collectDeclarations(node: ts.Node, declarations: Map<string, ts.Expression>) {
|
||||
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
||||
declarations.set(node.name.text, node.initializer);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, child => collectDeclarations(child, declarations));
|
||||
}
|
||||
|
||||
function findHandler(source: ts.SourceFile, method: ApiHttpMethod): ts.Node | undefined {
|
||||
const methodName = method.toUpperCase();
|
||||
|
||||
for (const statement of source.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name?.text === methodName) {
|
||||
return statement;
|
||||
}
|
||||
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
const declaration = statement.declarationList.declarations.find(
|
||||
item => ts.isIdentifier(item.name) && item.name.text === methodName,
|
||||
);
|
||||
|
||||
if (declaration?.initializer) {
|
||||
return declaration.initializer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNumericStatus(options: ts.Expression | undefined, source: ts.SourceFile) {
|
||||
if (!options || !ts.isObjectLiteralExpression(options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = options.properties.find(
|
||||
property =>
|
||||
ts.isPropertyAssignment(property) && getPropertyName(property.name, source) === 'status',
|
||||
);
|
||||
|
||||
if (!status || !ts.isPropertyAssignment(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = getLiteralValue(status.initializer);
|
||||
return typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : null;
|
||||
}
|
||||
|
||||
export function analyzeSourceOperation(
|
||||
source: ts.SourceFile,
|
||||
method: ApiHttpMethod,
|
||||
): SourceOperationAnalysis {
|
||||
const handler = findHandler(source, method);
|
||||
|
||||
if (!handler) {
|
||||
return {
|
||||
auth: 'bearer',
|
||||
hasRequestSchema: false,
|
||||
responseStatuses: [200],
|
||||
responseMediaType: 'application/json',
|
||||
returnsOk: false,
|
||||
};
|
||||
}
|
||||
|
||||
const declarations = new Map<string, ts.Expression>();
|
||||
source.statements.forEach(statement => {
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
statement.declarationList.declarations.forEach(declaration => {
|
||||
if (ts.isIdentifier(declaration.name) && declaration.initializer) {
|
||||
declarations.set(declaration.name.text, declaration.initializer);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
collectDeclarations(handler, declarations);
|
||||
|
||||
let parseRequestCall: ts.CallExpression | undefined;
|
||||
let hasShareAwarePermission = false;
|
||||
let returnsOk = false;
|
||||
let responseMediaType = 'application/json';
|
||||
const responseStatuses = new Set<number>();
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const calleeText = node.expression.getText(source);
|
||||
const name = ts.isIdentifier(node.expression)
|
||||
? node.expression.text
|
||||
: ts.isPropertyAccessExpression(node.expression)
|
||||
? node.expression.name.text
|
||||
: calleeText;
|
||||
|
||||
if (name === 'parseRequest' && !parseRequestCall) {
|
||||
parseRequestCall = node;
|
||||
}
|
||||
|
||||
if (/^canView/.test(name)) {
|
||||
hasShareAwarePermission = true;
|
||||
}
|
||||
|
||||
const mappedStatus = RESPONSE_STATUSES[name];
|
||||
|
||||
if (mappedStatus) {
|
||||
responseStatuses.add(mappedStatus);
|
||||
returnsOk ||= name === 'ok';
|
||||
}
|
||||
|
||||
if (calleeText === 'Response.json') {
|
||||
responseStatuses.add(getNumericStatus(node.arguments[1], source) ?? 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isNewExpression(node) && node.expression.getText(source) === 'Response') {
|
||||
responseStatuses.add(getNumericStatus(node.arguments?.[1], source) ?? 200);
|
||||
responseMediaType = handler.getText(source).includes('text/javascript')
|
||||
? 'text/javascript'
|
||||
: 'text/plain';
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(handler);
|
||||
|
||||
const schemaArgument = parseRequestCall?.arguments[1];
|
||||
const hasRequestSchema = Boolean(
|
||||
schemaArgument && schemaArgument.kind !== ts.SyntaxKind.NullKeyword,
|
||||
);
|
||||
const skipAuth =
|
||||
parseRequestCall?.arguments[2]?.getText(source).includes('skipAuth: true') ?? false;
|
||||
const auth: ApiAuth =
|
||||
!parseRequestCall || skipAuth ? 'none' : hasShareAwarePermission ? 'bearer-or-share' : 'bearer';
|
||||
|
||||
if (hasRequestSchema) {
|
||||
responseStatuses.add(400);
|
||||
}
|
||||
|
||||
if (auth !== 'none') {
|
||||
responseStatuses.add(401);
|
||||
}
|
||||
|
||||
if (![...responseStatuses].some(status => status >= 200 && status < 400)) {
|
||||
responseStatuses.add(200);
|
||||
}
|
||||
|
||||
return {
|
||||
auth,
|
||||
hasRequestSchema,
|
||||
requestSchema: schemaArgument
|
||||
? inferExpression(schemaArgument, source, declarations).schema
|
||||
: undefined,
|
||||
responseStatuses: [...responseStatuses].sort((left, right) => left - right),
|
||||
responseMediaType,
|
||||
returnsOk,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user