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) {