Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/components/ApiReference/openapi.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getTypeLabel>[1];
type Node = Parameters<typeof getTypeLabel>[0];
type RefNode = Parameters<typeof resolveRef>[0];

function parameterNamed(name: string): Node {
const spec = apiSchema as unknown as {
paths: Record<string, Record<string, { parameters?: { name: string; schema: unknown }[] }>>;
};
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);
});
});
37 changes: 33 additions & 4 deletions src/components/ApiReference/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = root as unknown as Record<string, unknown>;
for (const seg of segments) result = result[decodeURIComponent(seg)] as Record<string, unknown>;
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<string, unknown>;
}
return (result ?? schema) as unknown as SchemaObject;
}

export function getRefName(schema: SchemaObject): string | null {
Expand Down Expand Up @@ -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';
Comment thread
kozlek marked this conversation as resolved.
}

if (schema.anyOf) {
const nonNull = schema.anyOf.filter((s) => s.type !== 'null');
Expand All @@ -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) {
Expand Down