Generate OpenAPI field descriptions and validate generated clients in CI
This commit is contained in:
1 parent
9eef6f22c9
commit
6156387251
8 files changed
+4329
-38512
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+13
-2
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ?? {}));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user