Generate explicit OpenAPI contracts for API routes

This commit is contained in:
Mike Cao committed 2026-09-01 15:09:10 -07:00
1 parent cc5d71d39f
commit e1a4934038
177 files changed
+50955 -1460

No files matched your search

+14 -8
View File
@@ -7,6 +7,8 @@ contract wherever one exists.
## Commands
- `pnpm openapi:generate` regenerates the public OpenAPI document.
- `pnpm openapi:contracts` refreshes source-controlled contracts for routes without a curated
`contract.ts` module.
- `pnpm openapi:check` validates contract alignment and checks that the generated file is current.
- `pnpm openapi:check --explicit` also fails while any non-OPTIONS API route lacks a hand-authored
contract.
@@ -17,9 +19,13 @@ 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.
Run `pnpm openapi:contracts` to create a `contract.generated.ts` next to every route that does not
have a curated contract. Generated contracts use the route's Zod request validation and TypeScript
response types. They are checked in so operation IDs and client-facing schemas remain stable.
For curated descriptions, examples, or runtime-shared Zod response models, replace the generated
module with a `contract.ts` that exports 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.
@@ -34,8 +40,8 @@ Route paths and HTTP methods are discovered independently from `src/app/api/**/r
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.
New operations are initially marked with `x-umami-contract: inferred` and their source file. The
inference layer extracts path parameters, Zod request fields, authentication, typed response bodies,
response status helpers, and response media types without importing route modules. Running
`pnpm openapi:contracts` snapshots that behavior into an explicit contract. Curated `contract.ts`
modules remain the way to add examples, custom operation IDs, and hand-written prose.
+17 -10
View File
@@ -103,16 +103,21 @@ export async function discoverApiOperations(
const apiRoot = path.join(projectRoot, 'src', 'app', 'api');
const routeFiles = await walk(apiRoot, name => ROUTE_FILE_PATTERN.test(name));
const operations: DiscoveredApiOperation[] = [];
const configFile = ts.readConfigFile(path.join(projectRoot, 'tsconfig.json'), ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectRoot);
const program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options);
const checker = program.getTypeChecker();
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 source =
program.getSourceFile(routeFile) ??
ts.createSourceFile(
routeFile,
await readFile(routeFile, 'utf8'),
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, '/');
@@ -121,7 +126,7 @@ export async function discoverApiOperations(
method,
path: routePath,
source: relativeSource,
analysis: analyzeSourceOperation(source, method),
analysis: analyzeSourceOperation(source, method, checker),
});
});
}
@@ -134,5 +139,7 @@ export async function discoverApiOperations(
export async function discoverContractFiles(projectRoot = process.cwd()) {
const apiRoot = path.join(projectRoot, 'src', 'app', 'api');
return (await walk(apiRoot, name => name === 'contract.ts')).sort();
return (
await walk(apiRoot, name => name === 'contract.ts' || name === 'contract.generated.ts')
).sort();
}
+2
View File
@@ -10,6 +10,7 @@ import {
import { discoverApiOperations } from '@/openapi/discover';
import { inferApiContracts } from '@/openapi/infer';
import type { ApiAudience } from '@/openapi/operation';
import { errorResponseComponents } from '@/openapi/schemas';
import { getSecurityRequirements, securitySchemes } from '@/openapi/security';
export type DocumentAudience = ApiAudience | 'all';
@@ -108,6 +109,7 @@ export async function buildOpenApiDocument(
paths,
components: {
securitySchemes,
responses: errorResponseComponents,
},
},
{
+22 -11
View File
@@ -74,7 +74,7 @@ function pascalCase(value: string) {
}
function getOperationId(method: ApiHttpMethod, path: string) {
return `inferred${pascalCase(method)}${getPathSegments(path).map(pascalCase).join('')}`;
return `${method}${getPathSegments(path).map(pascalCase).join('')}`;
}
function getSummary(method: ApiHttpMethod, path: string) {
@@ -144,18 +144,25 @@ function getQueryParameters(schema: InferredOpenApiSchema | undefined) {
}));
}
function successResponse(mediaType: string, returnsOk: boolean): ZodOpenApiResponseObject {
function successResponse(
mediaType: string,
returnsOk: boolean,
schema?: InferredOpenApiSchema,
): 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.',
description:
schema || 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),
schema: schema
? (schema as ZodOpenApiSchemaObject)
: returnsOk
? okSchema
: mediaType === 'application/json'
? ({} as ZodOpenApiSchemaObject)
: ({ type: 'string' } as ZodOpenApiSchemaObject),
},
},
};
@@ -168,7 +175,11 @@ function getResponses(operation: DiscoveredApiOperation): ZodOpenApiResponsesObj
const response = errorResponses[status];
responses[`${status}` as `${1 | 2 | 3 | 4 | 5}${string}`] =
response ??
successResponse(operation.analysis.responseMediaType, operation.analysis.returnsOk);
successResponse(
operation.analysis.responseMediaType,
operation.analysis.returnsOk,
operation.analysis.responseSchemas[status],
);
});
return responses;
+10
View File
@@ -87,3 +87,13 @@ export const serviceUnavailableResponse = errorResponse(
'service-unavailable',
'Service unavailable.',
);
export const errorResponseComponents = {
BadRequestResponse: badRequestResponse,
UnauthorizedResponse: unauthorizedResponse,
ForbiddenResponse: forbiddenResponse,
NotFoundResponse: notFoundResponse,
PayloadTooLargeResponse: payloadTooLargeResponse,
ServerErrorResponse: serverErrorResponse,
ServiceUnavailableResponse: serviceUnavailableResponse,
};
+197 -1
View File
@@ -30,6 +30,7 @@ export interface SourceOperationAnalysis {
hasRequestSchema: boolean;
requestSchema?: Schema;
responseStatuses: number[];
responseSchemas: Record<number, Schema>;
responseMediaType: string;
returnsOk: boolean;
}
@@ -56,6 +57,161 @@ const numberSchema = (): Schema => ({ type: 'number' });
const booleanSchema = (): Schema => ({ type: 'boolean' });
const unknownSchema = (): Schema => ({});
function isUnknownSchema(schema: Schema) {
return Object.keys(schema).length === 0;
}
function typeToSchema(
type: ts.Type,
checker: ts.TypeChecker,
location: ts.Node,
seen = new Set<ts.Type>(),
depth = 0,
): Schema {
if (depth > 12 || seen.has(type)) {
return unknownSchema();
}
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter)) {
return unknownSchema();
}
if (type.flags & ts.TypeFlags.Never) {
return unknownSchema();
}
if (type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null)) {
return { type: 'null' };
}
if (type.isStringLiteral()) {
return { const: type.value };
}
if (type.flags & (ts.TypeFlags.String | ts.TypeFlags.TemplateLiteral)) {
return stringSchema();
}
if (type.isNumberLiteral()) {
return { const: type.value };
}
if (type.flags & (ts.TypeFlags.Number | ts.TypeFlags.BigInt)) {
return numberSchema();
}
if (type.flags & ts.TypeFlags.BooleanLiteral) {
return { const: checker.typeToString(type) === 'true' };
}
if (type.flags & ts.TypeFlags.Boolean) {
return booleanSchema();
}
if (type.isUnion()) {
const variants = type.types
.filter(variant => !(variant.flags & ts.TypeFlags.Undefined))
.map(variant => typeToSchema(variant, checker, location, new Set(seen), depth + 1));
const unique = [...new Map(variants.map(schema => [JSON.stringify(schema), schema])).values()];
if (unique.length === 0) return unknownSchema();
if (unique.length === 1) return unique[0];
return { anyOf: unique };
}
if (type.isIntersection()) {
return {
allOf: type.types.map(variant =>
typeToSchema(variant, checker, location, new Set(seen), depth + 1),
),
};
}
const symbolName = type.aliasSymbol?.getName() ?? type.getSymbol()?.getName();
const promised = symbolName === 'Promise' ? checker.getAwaitedType(type) : undefined;
if (promised && promised !== type) {
return typeToSchema(promised, checker, location, seen, depth + 1);
}
if (symbolName === 'Date') {
return { type: 'string', format: 'date-time' };
}
if (checker.isTupleType(type)) {
const typeArguments = checker.getTypeArguments(type as ts.TypeReference);
const prefixItems = typeArguments.map(item =>
typeToSchema(item, checker, location, new Set(seen), depth + 1),
);
return {
type: 'array',
prefixItems,
minItems: prefixItems.length,
maxItems: prefixItems.length,
};
}
if (checker.isArrayType(type)) {
const item = checker.getTypeArguments(type as ts.TypeReference)[0];
return {
type: 'array',
items: item
? typeToSchema(item, checker, location, new Set(seen), depth + 1)
: unknownSchema(),
};
}
if (type.flags & ts.TypeFlags.Object) {
const nextSeen = new Set(seen);
nextSeen.add(type);
const properties: Record<string, Schema> = {};
const requiredProperties: string[] = [];
checker.getPropertiesOfType(type).forEach(property => {
if (property.getName().startsWith('__@')) {
return;
}
const declaration = property.valueDeclaration ?? property.declarations?.[0] ?? location;
const propertyType = checker.getTypeOfSymbolAtLocation(property, declaration);
// Functions are not JSON values. This also prevents array prototype methods from
// leaking into schemas when an array is spread into an object literal.
if (propertyType.getCallSignatures().length) {
return;
}
properties[property.getName()] = typeToSchema(
propertyType,
checker,
declaration,
new Set(nextSeen),
depth + 1,
);
if (!(property.flags & ts.SymbolFlags.Optional)) {
requiredProperties.push(property.getName());
}
});
const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String);
const schema = objectSchema(properties, requiredProperties);
if (stringIndex) {
schema.additionalProperties = typeToSchema(
stringIndex,
checker,
location,
new Set(nextSeen),
depth + 1,
);
}
return schema;
}
return unknownSchema();
}
function required(schema: Schema): InferredSchema {
return { schema, optional: false };
}
@@ -638,6 +794,7 @@ function getNumericStatus(options: ts.Expression | undefined, source: ts.SourceF
export function analyzeSourceOperation(
source: ts.SourceFile,
method: ApiHttpMethod,
checker?: ts.TypeChecker,
): SourceOperationAnalysis {
const handler = findHandler(source, method);
@@ -646,6 +803,7 @@ export function analyzeSourceOperation(
auth: 'bearer',
hasRequestSchema: false,
responseStatuses: [200],
responseSchemas: {},
responseMediaType: 'application/json',
returnsOk: false,
};
@@ -668,6 +826,23 @@ export function analyzeSourceOperation(
let returnsOk = false;
let responseMediaType = 'application/json';
const responseStatuses = new Set<number>();
const responseSchemaVariants = new Map<number, Schema[]>();
const addResponseSchema = (status: number, expression: ts.Expression | undefined) => {
if (!expression) {
return;
}
const syntactic = inferExpression(expression, source, declarations).schema;
const typed = checker
? typeToSchema(checker.getTypeAtLocation(expression), checker, expression)
: unknownSchema();
const schema = isUnknownSchema(typed) ? syntactic : typed;
if (!isUnknownSchema(schema)) {
responseSchemaVariants.set(status, [...(responseSchemaVariants.get(status) ?? []), schema]);
}
};
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
@@ -691,10 +866,21 @@ export function analyzeSourceOperation(
if (mappedStatus) {
responseStatuses.add(mappedStatus);
returnsOk ||= name === 'ok';
if (name === 'ok') {
responseSchemaVariants.set(mappedStatus, [
...(responseSchemaVariants.get(mappedStatus) ?? []),
objectSchema({ ok: { const: true } }),
]);
} else if (name === 'json') {
addResponseSchema(mappedStatus, node.arguments[0]);
}
}
if (calleeText === 'Response.json') {
responseStatuses.add(getNumericStatus(node.arguments[1], source) ?? 200);
const status = getNumericStatus(node.arguments[1], source) ?? 200;
responseStatuses.add(status);
addResponseSchema(status, node.arguments[0]);
}
}
@@ -731,6 +917,15 @@ export function analyzeSourceOperation(
responseStatuses.add(200);
}
const responseSchemas = Object.fromEntries(
[...responseSchemaVariants.entries()].map(([status, variants]) => {
const unique = [
...new Map(variants.map(schema => [JSON.stringify(schema), schema])).values(),
];
return [status, unique.length === 1 ? unique[0] : { anyOf: unique }];
}),
);
return {
auth,
hasRequestSchema,
@@ -738,6 +933,7 @@ export function analyzeSourceOperation(
? inferExpression(schemaArgument, source, declarations).schema
: undefined,
responseStatuses: [...responseStatuses].sort((left, right) => left - right),
responseSchemas,
responseMediaType,
returnsOk,
};