From 2be5cc3dfea506970dfe9723fa43cc95bbc6bec9 Mon Sep 17 00:00:00 2001 From: Thomas Berdy Date: Tue, 4 Aug 2026 19:09:09 +0200 Subject: [PATCH] fix(docs): show accepted values for API parameters typed by a shared enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicating repeated enums in the OpenAPI spec hoists them into shared components, so a query parameter's schema becomes a `$ref` instead of an inline `enum`. `getTypeLabel` returned the component name for any `$ref`, which is right for an object — the schema tree below expands it — but wrong for an enum: parameters are rendered from that label alone, with no tree underneath and no `enum` among their constraints. Fifteen public parameters were affected. `/api/activity-log`'s `event_type` listed all 44 accepted values and would have shown `EventType[]`; `source` on the quarantines endpoint would have shown `QuarantineSource` instead of `"manual" | "auto"`. The components themselves are not rendered anywhere on the site, so the values would have been published nowhere at all. Resolve the reference when it targets an enum and keep the name otherwise. Part of MRGFY-8330 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ic546133c44ed82ddc0f57c2269b64f6725247e27 --- src/components/ApiReference/openapi.test.ts | 50 +++++++++++++++++++++ src/components/ApiReference/openapi.ts | 37 +++++++++++++-- 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/components/ApiReference/openapi.test.ts diff --git a/src/components/ApiReference/openapi.test.ts b/src/components/ApiReference/openapi.test.ts new file mode 100644 index 0000000000..1177e1094f --- /dev/null +++ b/src/components/ApiReference/openapi.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import apiSchema from '../../../public/api-schemas.json'; +import { getTypeLabel, resolveRef } from './openapi'; + +type Spec = Parameters[1]; +type Node = Parameters[0]; +type RefNode = Parameters[0]; + +function parameterNamed(name: string): Node { + const spec = apiSchema as unknown as { + paths: Record>; + }; + const found = Object.values(spec.paths) + .flatMap((item) => Object.values(item)) + .flatMap((op) => op?.parameters ?? []) + .find((p) => p.name === name); + return found?.schema as Node; +} + +// Parameters are rendered from `getTypeLabel` alone — no schema tree beneath +// them and no `enum` among their constraints — so whatever it returns is the +// only description of the accepted values a reader gets. +describe('getTypeLabel', () => { + it('resolves a $ref to an enum into its values', () => { + expect(getTypeLabel(parameterNamed('source'), apiSchema as unknown as Spec)).toContain( + '"manual"' + ); + }); + + it('parenthesises a union before the array suffix', () => { + // `"a" | "b"[]` reads as though only the last member were the array. + const label = getTypeLabel(parameterNamed('outcome'), apiSchema as unknown as Spec); + expect(label).toContain('("success"'); + expect(label).toContain('")[]'); + }); +}); + +describe('resolveRef', () => { + it('does not throw on a component name with a stray percent sign', () => { + // This runs while rendering every parameter, so an unresolvable ref has + // to degrade rather than fail the build. + const root = { components: { schemas: { 'A%B': { enum: ['x'] } } } } as unknown as Spec; + expect(() => resolveRef({ $ref: '#/components/schemas/A%B' } as RefNode, root)).not.toThrow(); + }); + + it('returns the node it was given for a dangling ref', () => { + const node = { $ref: '#/components/schemas/Nope' } as RefNode; + expect(resolveRef(node, { components: { schemas: {} } } as unknown as Spec)).toEqual(node); + }); +}); diff --git a/src/components/ApiReference/openapi.ts b/src/components/ApiReference/openapi.ts index 900f7aef23..7d26ec0555 100644 --- a/src/components/ApiReference/openapi.ts +++ b/src/components/ApiReference/openapi.ts @@ -98,8 +98,20 @@ export function resolveRef(schema: SchemaObject, root: OpenAPISpec): SchemaObjec if (!schema?.$ref) return schema; const segments = schema.$ref.replace('#/', '').split('/'); let result: Record = root as unknown as Record; - for (const seg of segments) result = result[decodeURIComponent(seg)] as Record; - return result as unknown as SchemaObject; + for (const seg of segments) { + if (!result || typeof result !== 'object') return schema; + // A component name containing a stray `%` makes `decodeURIComponent` + // raise, and this runs while rendering every parameter — an unresolvable + // ref has to degrade to the node we were given, not fail the build. + let key: string; + try { + key = decodeURIComponent(seg); + } catch { + key = seg; + } + result = result[key] as Record; + } + return (result ?? schema) as unknown as SchemaObject; } export function getRefName(schema: SchemaObject): string | null { @@ -250,7 +262,20 @@ function escapeHtml(text: string): string { export function getTypeLabel(schema: SchemaObject | undefined, root: OpenAPISpec): string { if (!schema) return 'any'; - if (schema.$ref) return getRefName(schema) ?? 'object'; + if (schema.$ref) { + // A referenced enum still has to show its values here. Parameters are + // rendered from this label alone (Endpoint.astro) with no schema tree + // underneath and no `enum` in their constraints, so falling back to the + // component name would leave a query parameter documented as + // `EventType[]` with its accepted values published nowhere on the site. + // Object references keep their name — expanding those is what the schema + // tree is for. + const resolved = resolveRef(schema, root); + if (Array.isArray(resolved?.enum)) { + return resolved.enum.map((v) => JSON.stringify(v)).join(' | '); + } + return getRefName(schema) ?? 'object'; + } if (schema.anyOf) { const nonNull = schema.anyOf.filter((s) => s.type !== 'null'); @@ -273,7 +298,11 @@ export function getTypeLabel(schema: SchemaObject | undefined, root: OpenAPISpec } if (schema.type === 'array' && schema.items) { - return `${getTypeLabel(schema.items, root)}[]`; + // Parenthesise a union before suffixing `[]`, or an array of an enum + // reads as though only its last member were the array: + // `"a" | "b"[]` rather than `("a" | "b")[]`. + const item = getTypeLabel(schema.items, root); + return `${item.includes(' | ') ? `(${item})` : item}[]`; } if (schema.enum) {