Add OpenAPI schema generation and validation for API routes

This commit is contained in:
Mike Cao committed 2026-08-31 19:37:37 -07:00
1 parent 5d822e6c7e
commit 5ce5664b29
25 files changed
+2019 -52

No files matched your search

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