Generate OpenAPI from App Router handlers with inferred contracts and source analysis

This commit is contained in:
Mike Cao committed 2026-09-01 13:09:40 -07:00
1 parent 5ce5664b29
commit cc5d71d39f
11 files changed
+21892 -34

No files matched your search

+16 -9
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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).`;
}
+8 -1
View File
@@ -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 -3
View File
@@ -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',
+227
View File
@@ -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',
}));
}
+1
View File
@@ -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 {
+744
View File
@@ -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,
};
}