Generate explicit OpenAPI contracts for API routes
This commit is contained in:
1 parent
cc5d71d39f
commit
e1a4934038
177 files changed
+50955
-1460
No files matched your search
+14
-8
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user