Generate OpenAPI field descriptions and validate generated clients in CI

This commit is contained in:
Mike Cao committed 2026-09-07 17:30:56 -07:00
1 parent 9eef6f22c9
commit 6156387251
8 files changed
+4329 -38512

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ jobs:
node-version: 22 node-version: 22
cache: "pnpm" cache: "pnpm"
- run: pnpm install - run: pnpm install
- run: pnpm openapi:generate
- run: pnpm openapi:check --explicit - run: pnpm openapi:check --explicit
- run: pnpm check:api-client - run: pnpm check:api-client
- run: pnpm build-packages - run: pnpm build-packages
+1
View File
@@ -21,6 +21,7 @@ next-env.d.ts
/build /build
/public/script.js /public/script.js
/public/recorder.js /public/recorder.js
/public/openapi.json
/geo /geo
/dist /dist
/packages/*/dist /packages/*/dist
File diff suppressed because it is too large. Load diff
-38242
View File
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -4,6 +4,9 @@ Umami generates `public/openapi.json` for every App Router API handler. It stati
baseline contract from route source and replaces that baseline with a colocated, Zod-backed baseline contract from route source and replaces that baseline with a colocated, Zod-backed
contract wherever one exists. contract wherever one exists.
`public/openapi.json` is an ignored build artifact and must not be committed. Generate it locally
with `pnpm openapi:generate` before running checks or generating the API client.
## Commands ## Commands
- `pnpm openapi:generate` regenerates the public OpenAPI document. - `pnpm openapi:generate` regenerates the public OpenAPI document.
@@ -14,8 +17,8 @@ contract wherever one exists.
contract. contract.
- `pnpm openapi:check --verbose` lists operations that still use inferred 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 The normal and Docker builds run the generator before the Next.js build. CI generates the artifact
tests and build steps. and runs the check before tests and build steps.
## Adding an operation ## Adding an operation
@@ -68,6 +71,14 @@ Run `pnpm generate:api` after editing to refresh the OpenAPI document and API cl
`pnpm openapi:check` reject entries that do not match a discovered route and method. Entries are `pnpm openapi:check` reject entries that do not match a discovered route and method. Entries are
optional, so documentation can be improved incrementally. optional, so documentation can be improved incrementally.
Field documentation lives in `src/openapi/field-descriptions.ts`. The document builder fills missing
query/path parameter and request/response property descriptions after converting contracts to
OpenAPI, including nested schemas and shared components. Existing contract descriptions take
precedence. Keep shared wording limited to fields with consistent meanings; use endpoint-specific
wording for fields such as the dashboard's `parameters`. Unknown fields are left undocumented
rather than given an automatically generated label. Examples and schema validation rules are not
modified. Run `pnpm generate:api` after editing this file as well.
## Operation IDs ## Operation IDs
Operation IDs are public: they become method names in `@umami/api-client`. Routes with a curated Operation IDs are public: they become method names in `@umami/api-client`. Routes with a curated
+3
View File
@@ -9,6 +9,7 @@ import {
getCoverageErrors, getCoverageErrors,
} from '@/openapi/coverage'; } from '@/openapi/coverage';
import { discoverApiOperations } from '@/openapi/discover'; import { discoverApiOperations } from '@/openapi/discover';
import { applyFieldDescriptions } from '@/openapi/field-descriptions';
import { inferApiContracts } from '@/openapi/infer'; import { inferApiContracts } from '@/openapi/infer';
import { type ApiAudience, type ApiOperationContract, getOperationKey } from '@/openapi/operation'; import { type ApiAudience, type ApiOperationContract, getOperationKey } from '@/openapi/operation';
import { operationDescriptions } from '@/openapi/operation-descriptions'; import { operationDescriptions } from '@/openapi/operation-descriptions';
@@ -147,5 +148,7 @@ export async function buildOpenApiDocument(
}, },
); );
applyFieldDescriptions(document);
return { document, coverage }; return { document, coverage };
} }
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import type { createDocument } from 'zod-openapi';
import { applyFieldDescriptions } from './field-descriptions';
function documentWithSchema(schema: Record<string, unknown>) {
return {
openapi: '3.1.0',
info: { title: 'Test', version: '1' },
paths: {
'/api/dashboard': {
get: {
responses: {
'200': {
description: 'Success',
content: { 'application/json': { schema } },
},
},
},
},
},
} satisfies ReturnType<typeof createDocument>;
}
describe('OpenAPI field descriptions', () => {
it('documents dashboard fields without changing their schemas or example payloads', () => {
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string', description: 'Custom dashboard name.' },
parameters: { anyOf: [{ type: 'object' }, { type: 'null' }] },
createdAt: { type: 'string', format: 'date-time' },
},
required: ['id', 'name', 'parameters', 'createdAt'],
example: { properties: { id: { type: 'string' } } },
};
const original = structuredClone(schema);
const document = documentWithSchema(schema);
applyFieldDescriptions(document);
expect(schema).toEqual({
...original,
properties: {
id: { type: 'string', description: 'Dashboard board ID, equal to the owning user ID.' },
name: original.properties.name,
parameters: {
...original.properties.parameters,
description: 'Dashboard configuration, including components, layout, and saved reports.',
},
createdAt: {
...original.properties.createdAt,
description: 'Date and time the record was created.',
},
},
});
const once = structuredClone(document);
applyFieldDescriptions(document);
expect(document).toEqual(once);
});
it('traverses nested schemas and components while preserving references and unknown fields', () => {
const nested = {
properties: {
userId: { type: 'string' },
unknownField: { type: 'string' },
},
};
const reference = { $ref: '#/components/schemas/User' };
const schema = {
allOf: [
{ type: 'array', items: { oneOf: [nested, reference] } },
{ additionalProperties: { anyOf: [structuredClone(nested)] } },
],
};
const document = {
...documentWithSchema(schema),
components: { schemas: { User: { properties: { username: { type: 'string' as const } } } } },
};
applyFieldDescriptions(document);
expect(nested.properties.userId).toHaveProperty('description');
expect(nested.properties.unknownField).not.toHaveProperty('description');
expect(reference).toEqual({ $ref: '#/components/schemas/User' });
expect(document.components.schemas.User.properties.username).toHaveProperty('description');
expect(schema.allOf[1].additionalProperties.anyOf[0].properties.userId).toHaveProperty(
'description',
);
});
it('fills query and path descriptions while respecting curated parameter and schema wording', () => {
const parameters = [
{ name: 'startAt', in: 'query' as const, schema: { type: 'number' as const } },
{ name: 'boardId', in: 'path' as const, required: true, schema: { type: 'string' as const } },
{
name: 'type',
in: 'query' as const,
schema: { type: 'string' as const, description: 'Dimension to rank.' },
},
{ name: 'page', in: 'query' as const, description: 'Curated page description.' },
];
const document = documentWithSchema({});
Object.assign(document.paths['/api/dashboard'].get, { parameters });
applyFieldDescriptions(document);
expect(parameters[0]).toHaveProperty(
'description',
'Start of the date range as a Unix timestamp in milliseconds.',
);
expect(parameters[1]).toHaveProperty('description', 'ID of the board.');
expect(parameters[2]).toHaveProperty('description', 'Dimension to rank.');
expect(parameters[3]).toHaveProperty('description', 'Curated page description.');
});
});
+304
View File
@@ -0,0 +1,304 @@
import type { createDocument } from 'zod-openapi';
// Shared wording only belongs here when a field has the same meaning across endpoints.
// More specific descriptions in contracts always take precedence.
const fieldDescriptions: Record<string, string> = {
id: 'Unique identifier of the resource.',
name: 'Display name of the resource.',
description: 'Description of the resource.',
parameters: 'Configuration parameters for the resource.',
createdAt: 'Date and time the record was created.',
updatedAt: 'Date and time the record was last updated.',
deletedAt: 'Date and time the record was deleted, if applicable.',
resetAt: 'Date and time the website analytics were last reset.',
createdBy: 'ID of the user who created the resource.',
userId: 'ID of the associated user.',
teamId: 'ID of the associated team.',
websiteId: 'ID of the website.',
boardId: 'ID of the board.',
linkId: 'ID of the tracked link.',
pixelId: 'ID of the tracking pixel.',
reportId: 'ID of the saved report.',
sessionId: 'ID of the visitor session.',
eventId: 'ID of the event.',
websiteEventId: 'ID of the website event.',
annotationId: 'ID of the annotation.',
segmentId: 'ID of the saved segment or cohort.',
replayId: 'ID of the session replay.',
shareId: 'Identifier used to access a shared resource.',
keyId: 'ID of the API key.',
visitId: 'ID of the visit.',
entityId: 'ID of the resource being shared.',
slug: 'URL slug used to access the resource.',
username: 'Username of the account.',
user: 'User associated with the resource.',
team: 'Team associated with the resource.',
teams: 'Teams associated with the user.',
members: 'Members of the team.',
createUser: 'User who created the resource.',
boards: 'Boards associated with the user.',
links: 'Tracked links associated with the user.',
pixels: 'Tracking pixels associated with the user.',
reports: 'Saved reports associated with the user.',
apiKeys: 'API keys associated with the user.',
password: 'Password used to authenticate the account.',
currentPassword: 'Current account password.',
newPassword: 'New password to set for the account.',
isAdmin: 'Whether the user has administrator privileges.',
role: 'Permission role assigned to the user.',
accessCode: 'Code used to join the team.',
logoUrl: 'URL of the logo image.',
domain: 'Domain name associated with the resource.',
url: 'URL associated with the resource.',
page: 'Page number, starting at 1.',
pageSize: 'Number of results per page.',
maxResults: 'Maximum number of results to include.',
limit: 'Maximum number of rows to return.',
offset: 'Number of rows to skip before returning results.',
orderBy: 'Field to sort the results by.',
sortDescending: 'Whether to sort results in descending order.',
search: 'Search text used to filter results.',
isCapped: 'Whether the results were truncated by the maximum result limit.',
data: 'Data returned by the operation.',
count: 'Number of matching records.',
startAt: 'Start of the date range as a Unix timestamp in milliseconds.',
endAt: 'End of the date range as a Unix timestamp in milliseconds.',
startDate: 'Start of the date range as an ISO 8601 date or date-time.',
endDate: 'End of the date range as an ISO 8601 date or date-time.',
timezone:
'IANA time zone used to interpret dates and group results, for example America/New_York.',
unit: 'Time interval used to group results: minute, hour, day, month, or year.',
filters: 'Filters applied to the analytics data.',
match: 'Whether records must match all filters or any filter.',
segment: 'ID of a saved segment used to filter results.',
cohort: 'ID of a saved cohort used to filter visitors.',
excludeBounce: 'Set a non-empty value to exclude visits with only one pageview.',
hostname: 'Hostname on which the activity occurred.',
browser: 'Browser used by the visitor.',
os: 'Operating system used by the visitor.',
device: 'Device category used by the visitor.',
screen: 'Screen resolution of the visitor device.',
language: 'Preferred language reported by the visitor browser.',
country: 'Country code of the visitor.',
region: 'Region or subdivision of the visitor.',
city: 'City of the visitor.',
distinctId: 'Custom identifier assigned to the visitor.',
urlPath: 'Path portion of the page URL.',
urlQuery: 'Query string portion of the page URL.',
referrerDomain: 'Domain of the referring page.',
referrer: 'URL of the referring page.',
title: 'Title of the page.',
pageTitle: 'Title of the page.',
eventName: 'Name of the custom event.',
eventType: 'Event type: 1 for a pageview or 2 for a custom event.',
tag: 'Tag attached to the tracked activity.',
utmSource: 'UTM campaign source.',
utmMedium: 'UTM campaign medium.',
utmCampaign: 'UTM campaign name.',
utmContent: 'UTM campaign content.',
utmTerm: 'UTM campaign search term.',
pageviews: 'Pageview counts for the selected period.',
visitors: 'Unique visitor counts for the selected period.',
visits: 'Visit counts for the selected period.',
views: 'Pageview counts for the selected period.',
bounces: 'Number of visits with only one pageview.',
totaltime: 'Total visit duration in seconds.',
firstAt: 'Date and time of the first recorded activity.',
lastAt: 'Date and time of the most recent recorded activity.',
comparison: 'Analytics for the comparison period.',
uniqueEvents: 'Number of unique events.',
average: 'Arithmetic mean of the numeric property values.',
median: 'Median of the numeric property values.',
min: 'Minimum numeric property value.',
max: 'Maximum numeric property value.',
currency: 'Currency code used for revenue values.',
propertyName: 'Name of the custom event or session property.',
propertyValue: 'Value of the custom property.',
propertyKeys: 'Names of the custom properties.',
propertyValues: 'Values of the custom properties.',
dataKey: 'Name of the custom property.',
dataType: 'Data type of the custom property.',
stringValue: 'Custom property value stored as a string.',
numberValue: 'Custom property value stored as a number.',
dateValue: 'Custom property value stored as a date-time.',
eventProperties: 'Custom properties recorded with the event.',
minDuration: 'Minimum replay duration in seconds.',
startedAt: 'Date and time the recording started.',
endedAt: 'Date and time the recording ended.',
chunkCount: 'Number of stored recording chunks.',
eventCount: 'Number of recorded replay events.',
isSaved: 'Whether the replay has been saved.',
duration: 'Replay duration in milliseconds.',
viewportW: 'Viewport width in pixels.',
viewportH: 'Viewport height in pixels.',
pageW: 'Page width in pixels.',
pageH: 'Page height in pixels.',
pageX: 'Horizontal position on the page in pixels.',
pageY: 'Vertical position on the page in pixels.',
recorderEnabled: 'Whether recording is enabled for the website.',
replayConfig: 'Session replay and heatmap recording configuration.',
replayEnabled: 'Whether session replay recording is enabled.',
heatmapEnabled: 'Whether heatmap recording is enabled.',
sampleRate: 'Fraction of sessions to record for replay, from 0 to 1.',
heatmapSampleRate: 'Fraction of sessions to record for heatmaps, from 0 to 1.',
maskLevel: 'Privacy masking level used by the recorder: strict or moderate.',
blockSelector: 'CSS selector for elements excluded from recordings.',
allDay: 'Whether the annotation applies to the entire day.',
note: 'Text of the annotation.',
apiKey: 'API key authentication details.',
keyPrefix: 'Visible prefix used to identify an API key.',
lastUsedAt: 'Date and time the credential was last used.',
expiresAt: 'Date and time the credential expires.',
revokedAt: 'Date and time the credential was revoked.',
usedAt: 'Date and time the code was used.',
backupCode: 'Single-use backup code for two-factor authentication.',
backupCodes: 'Single-use backup codes for two-factor authentication.',
twoFactorRequired: 'Whether two-factor authentication is required.',
requiresTwoFactor: 'Whether login requires a second authentication factor.',
isEnabled: 'Whether two-factor authentication is enabled.',
isConfigured: 'Whether two-factor authentication has been configured.',
isRequired: 'Whether two-factor authentication is required for this user.',
globalRequired: 'Whether two-factor authentication is required for all users.',
requiredReason: 'Reason two-factor authentication is required.',
partialToken: 'Temporary login token used to complete two-factor authentication.',
manualKey: 'Secret key for manually configuring an authenticator app.',
qrCodeDataUrl: 'Data URL of the QR code for authenticator setup.',
lockedUntil: 'Time until which further authentication attempts are blocked.',
error: 'Error details returned when the operation fails.',
message: 'Human-readable explanation of the result.',
ok: 'Whether the operation succeeded.',
client_id: 'OAuth client identifier.',
client_name: 'Human-readable name of the OAuth client.',
client_uri: 'URL of the OAuth client homepage.',
logo_uri: 'URL of the OAuth client logo.',
redirect_uri: 'Registered redirect URI to receive the authorization response.',
redirect_uris: 'Redirect URIs registered for the OAuth client.',
response_type: 'OAuth authorization response type; use code.',
response_types: 'Authorization response types supported by the client.',
code_challenge: 'PKCE challenge derived from the code verifier.',
code_challenge_method: 'Method used to derive the PKCE challenge; use S256.',
code_verifier: 'Original PKCE verifier used to obtain the authorization code.',
scope: 'Space-separated OAuth permissions.',
resource: 'Resource URI identifying the API targeted by the OAuth request.',
state: 'Client-provided value returned unchanged to correlate the authorization response.',
access_token: 'OAuth access token used to authorize API requests.',
refresh_token: 'OAuth refresh token used to obtain a new access token.',
token_type: 'Authorization scheme for the access token.',
expires_in: 'Access token lifetime in seconds.',
grant_type: 'OAuth grant used to obtain or refresh an access token.',
grant_types: 'OAuth grant types supported by the client.',
token_endpoint_auth_method: 'Client authentication method used at the token endpoint.',
token_type_hint: 'Hint indicating whether the revoked token is an access or refresh token.',
error_description: 'Human-readable explanation of the OAuth error.',
shareType: 'Type of resource made available by the share.',
};
const queryDescriptions: Record<string, string> = {
compare: 'Comparison period: prev for the previous period or yoy for the same period last year.',
ids: 'Comma-separated resource IDs. Provide between 1 and 20 IDs.',
includeTeams: 'Set a non-empty value to include websites accessible through team membership.',
type: 'Type of resource or analytics dimension to return.',
metric: 'Numeric aggregation to calculate for the selected property.',
path: 'Filter by page URL path.',
referrer: 'Filter by referring URL.',
title: 'Filter by page title.',
query: 'Filter by page URL query string.',
event: 'Filter by custom event name.',
};
const dashboardDescriptions: Record<string, string> = {
id: 'Dashboard board ID, equal to the owning user ID.',
name: 'Display name of the dashboard.',
description: 'Description of the dashboard.',
parameters: 'Dashboard configuration, including components, layout, and saved reports.',
userId: 'ID of the user who owns the dashboard.',
teamId: 'ID of the associated team, if any.',
type: 'Board type used for the dashboard.',
};
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function describeSchema(value: unknown, overrides: Record<string, string> = {}) {
if (!isObject(value)) return;
if (isObject(value.properties)) {
for (const [name, property] of Object.entries(value.properties)) {
if (!isObject(property)) continue;
const description = overrides[name] ?? fieldDescriptions[name];
if (!property.description && description) property.description = description;
describeSchema(property);
}
}
for (const keyword of ['items', 'additionalProperties', 'not', 'if', 'then', 'else']) {
describeSchema(value[keyword]);
}
for (const keyword of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) {
const schemas = value[keyword];
if (Array.isArray(schemas)) {
schemas.forEach(schema => {
describeSchema(schema, overrides);
});
}
}
}
// Visit only OpenAPI containers and schema keywords, never example/default payloads.
function describeContent(value: unknown, overrides: Record<string, string> = {}) {
if (!isObject(value)) return;
if (isObject(value.content)) {
for (const media of Object.values(value.content)) {
if (isObject(media)) describeSchema(media.schema, overrides);
}
}
}
function describeParameters(value: unknown) {
if (!Array.isArray(value)) return;
for (const parameter of value) {
if (!isObject(parameter) || typeof parameter.name !== 'string') continue;
const schema = isObject(parameter.schema) ? parameter.schema : undefined;
const description =
schema?.description ??
(parameter.in === 'query' ? queryDescriptions[parameter.name] : undefined) ??
fieldDescriptions[parameter.name];
if (!parameter.description && description) parameter.description = description;
describeSchema(schema);
describeContent(parameter);
}
}
export function applyFieldDescriptions(document: ReturnType<typeof createDocument>) {
for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
if (!isObject(pathItem)) continue;
describeParameters(pathItem.parameters);
for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) {
const operation = pathItem[method];
if (!isObject(operation)) continue;
const overrides = path === '/api/dashboard' ? dashboardDescriptions : {};
describeParameters(operation.parameters);
describeContent(operation.requestBody, overrides);
if (isObject(operation.responses)) {
Object.values(operation.responses).forEach(response => {
describeContent(response, overrides);
});
}
}
}
const components = document.components;
Object.values(components?.schemas ?? {}).forEach(schema => {
describeSchema(schema);
});
Object.values(components?.responses ?? {}).forEach(response => {
describeContent(response);
});
Object.values(components?.requestBodies ?? {}).forEach(body => {
describeContent(body);
});
describeParameters(Object.values(components?.parameters ?? {}));
}