From d94b6ee3c4b9fe22a6b7854839548d3b26c99201 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 8 Nov 2026 23:11:54 -0500 Subject: [PATCH 1/2] refactor: separate OpenAPI interpretation from document I/O --- app/[[...slug]]/page.tsx | 3 +- components/docs/api-reference.tsx | 5 +- lib/agent-markdown.ts | 3 +- lib/docs-server.ts | 62 +------------------- lib/docs/apiObject.ts | 3 + lib/docs/buildCurl.ts | 37 ++++++++++++ lib/docs/playground/resolvePlaygroundAuth.ts | 2 +- lib/docs/playground/summarizeBody.ts | 4 +- lib/docs/playground/summarizeOperation.ts | 3 +- lib/docs/resolveReference.ts | 10 ++++ lib/docs/schemaAnchorId.ts | 2 +- lib/docs/schemaExample.ts | 18 ++++++ tests/docs-migration.test.ts | 3 +- 13 files changed, 87 insertions(+), 68 deletions(-) create mode 100644 lib/docs/apiObject.ts create mode 100644 lib/docs/buildCurl.ts create mode 100644 lib/docs/resolveReference.ts create mode 100644 lib/docs/schemaExample.ts diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index c759ee1..ca5817b 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -9,7 +9,8 @@ import { DocsSidebar } from '@/components/docs/docs-interactive'; import { DocsContent } from '@/components/docs/docs-content'; import { ApiReference } from '@/components/docs/api-reference'; import { CopyPage } from '@/components/docs/copy-page'; -import { getDocSpec, resolveReference } from '@/lib/docs-server'; +import { getDocSpec } from "@/lib/docs-server"; +import { resolveReference } from "@/lib/docs/resolveReference"; import '../docs.css'; import './docs-mobile-toc.css'; diff --git a/components/docs/api-reference.tsx b/components/docs/api-reference.tsx index b1c9385..d7ed522 100644 --- a/components/docs/api-reference.tsx +++ b/components/docs/api-reference.tsx @@ -1,7 +1,10 @@ import { SkyArrow } from '@/components/sky/arrow'; import Link from 'next/link'; import type { DocPage } from '@/lib/docs'; -import { getDocSpec, resolveReference, buildCurl, type ApiObject } from '@/lib/docs-server'; +import { getDocSpec } from "@/lib/docs-server"; +import { resolveReference } from "@/lib/docs/resolveReference"; +import { buildCurl } from "@/lib/docs/buildCurl"; +import type { ApiObject } from "@/lib/docs/apiObject"; import { DocsCode } from './docs-interactive'; import { DocDescription, DocInlineDescription } from './docs-content'; import { ApiPlayground } from './playground/api-playground'; diff --git a/lib/agent-markdown.ts b/lib/agent-markdown.ts index c866537..467bf6f 100644 --- a/lib/agent-markdown.ts +++ b/lib/agent-markdown.ts @@ -4,7 +4,8 @@ import { createProcessor } from "@mdx-js/mdx"; import remarkGfm from "remark-gfm"; import type { DocPage } from "./docs"; import { resolveDocsHref } from "./docs-paths.ts"; -import { getDocSpec, type ApiObject } from "./docs-server.ts"; +import { getDocSpec } from "./docs-server.ts"; +import type { ApiObject } from "./docs/apiObject.ts"; import { siteConfig } from "./config.ts"; type MarkdownNode = { diff --git a/lib/docs-server.ts b/lib/docs-server.ts index 0376b8b..c06d899 100644 --- a/lib/docs-server.ts +++ b/lib/docs-server.ts @@ -2,67 +2,9 @@ import { readFile } from 'node:fs/promises'; import { cache } from 'react'; import path from 'node:path'; -// OpenAPI is intentionally open-ended; the full source remains downloadable, including vendor extensions. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ApiObject = Record; +import type { ApiObject } from "./docs/apiObject.ts"; + export const getDocSpec = cache(async (name: string): Promise => { if(!/^[a-z-]+\.json$/.test(name))throw new Error('Invalid specification name'); return JSON.parse(await readFile(path.join(process.cwd(),'content/docs/source/api-reference/openapi',name),'utf8')); }); -export function resolveReference(value:ApiObject|undefined,spec:ApiObject):ApiObject { - if(!value)return {}; - if(!value.$ref)return value; - if(!value.$ref.startsWith('#/'))return value; - let resolved:ApiObject=spec; - for(const part of value.$ref.slice(2).split('/'))resolved=resolved?.[part.replace(/~1/g,'/').replace(/~0/g,'~')]; - return resolved ? {...resolved,...Object.fromEntries(Object.entries(value).filter(([key])=>key!=='$ref'))}:value; -} -export function schemaExample(schema:ApiObject,spec:ApiObject,depth=0):unknown { - if(depth>5)return undefined; - schema=resolveReference(schema,spec); - if(schema.example!==undefined)return schema.example; - if(schema.default!==undefined)return schema.default; - if(schema.enum)return schema.enum[0]; - if(schema.oneOf||schema.anyOf)return schemaExample((schema.oneOf||schema.anyOf)[0],spec,depth+1); - if(schema.allOf)return Object.assign({},...schema.allOf.map((item:ApiObject)=>schemaExample(item,spec,depth+1))); - if(schema.type==='object'||schema.properties)return Object.fromEntries(Object.entries(schema.properties||{}).filter(([key])=>!schema.required||schema.required.includes(key)).map(([key,value])=>[key,schemaExample(value as ApiObject,spec,depth+1)])); - if(schema.type==='array')return [schemaExample(schema.items||{},spec,depth+1)]; - if(schema.type==='boolean')return true; - if(schema.type==='integer'||schema.type==='number')return schema.minimum??0; - if(schema.format==='uuid')return 'YOUR_ID'; - return schema.type==='string'?'string':undefined; -} - -function shellQuote(value:string) { return `'${value.replace(/'/g, `'"'"'`)}'`; } -export function buildCurl({method,endpoint,spec,operation,pathItem={}}:{method:string;endpoint:string;spec:ApiObject;operation:ApiObject;pathItem?:ApiObject}):string { - const parameters=[...(pathItem.parameters||[]),...(operation.parameters||[])].map(parameter=>resolveReference(parameter,spec)); - const base=(operation.servers||pathItem.servers||spec.servers)?.[0]?.url||'https://api.recoupable.dev'; - const route=endpoint.replace(/\{([^}]+)\}/g,(_match,name)=>`YOUR_${name.replace(/([a-z])([A-Z])/g,'$1_$2').toUpperCase()}`); - const query=new URLSearchParams(); - for(const parameter of parameters.filter(parameter=>parameter.in==='query'&¶meter.required)) { - const value=parameter.example??parameter.schema?.example??parameter.schema?.default??`YOUR_${parameter.name.toUpperCase()}`; - query.append(parameter.name,typeof value==='object'?JSON.stringify(value):String(value)); - } - const lines=[`curl --request ${method}`,` --url ${shellQuote(base+route+(query.size?`?${query}`:''))}`]; - const security=operation.security??spec.security; - const scheme=security?.length?spec.components?.securitySchemes?.[Object.keys(security[0])[0]]:undefined; - if(security?.length!==0)lines.push(` --header ${shellQuote(scheme?.scheme==='bearer'?'Authorization: Bearer YOUR_TOKEN':`${scheme?.name||'x-api-key'}: YOUR_API_KEY`)}`); - for(const parameter of parameters.filter(parameter=>parameter.in==='header'&¶meter.required&¶meter.name!==scheme?.name&¶meter.name.toLowerCase()!=='x-api-key'))lines.push(` --header ${shellQuote(`${parameter.name}: ${parameter.example??parameter.schema?.example??'YOUR_VALUE'}`)}`); - const body=resolveReference(operation.requestBody,spec); - const mediaType=body.content?.['application/json']?'application/json':Object.keys(body.content||{})[0]; - const media=body.content?.[mediaType]; - const schema=resolveReference(media?.schema,spec); - const example=media?.example??(media?.examples?resolveReference(Object.values(media.examples)[0] as ApiObject,spec).value:undefined)??(media?.schema?schemaExample(media.schema,spec):undefined); - if(mediaType==='multipart/form-data'||mediaType==='application/x-www-form-urlencoded') { - for(const [name,raw] of Object.entries(schema.properties||{})) { - if(schema.required&&!schema.required.includes(name))continue; - const property=resolveReference(raw as ApiObject,spec); - const value=property.format==='binary'||property.items?.format==='binary'?'@YOUR_FILE_PATH':(example as ApiObject)?.[name]??schemaExample(property,spec)??'YOUR_VALUE'; - lines.push(` ${mediaType==='multipart/form-data'?'--form':'--data-urlencode'} ${shellQuote(`${name}=${typeof value==='object'?JSON.stringify(value):String(value)}`)}`); - } - } else if(mediaType) { - lines.push(` --header ${shellQuote(`Content-Type: ${mediaType}`)}`); - if(example!==undefined)lines.push(` --data ${shellQuote(typeof example==='string'?example:JSON.stringify(example,null,2))}`); - } - return lines.join(' \\\n'); -} diff --git a/lib/docs/apiObject.ts b/lib/docs/apiObject.ts new file mode 100644 index 0000000..8ea8279 --- /dev/null +++ b/lib/docs/apiObject.ts @@ -0,0 +1,3 @@ +// OpenAPI is intentionally open-ended; the full source remains downloadable, including vendor extensions. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ApiObject = Record; diff --git a/lib/docs/buildCurl.ts b/lib/docs/buildCurl.ts new file mode 100644 index 0000000..fb8dea9 --- /dev/null +++ b/lib/docs/buildCurl.ts @@ -0,0 +1,37 @@ +import type { ApiObject } from "./apiObject.ts"; +import { resolveReference } from "./resolveReference.ts"; +import { schemaExample } from "./schemaExample.ts"; + +function shellQuote(value:string) { return `'${value.replace(/'/g, `'"'"'`)}'`; } +export function buildCurl({method,endpoint,spec,operation,pathItem={}}:{method:string;endpoint:string;spec:ApiObject;operation:ApiObject;pathItem?:ApiObject}):string { + const parameters=[...(pathItem.parameters||[]),...(operation.parameters||[])].map(parameter=>resolveReference(parameter,spec)); + const base=(operation.servers||pathItem.servers||spec.servers)?.[0]?.url||'https://api.recoupable.dev'; + const route=endpoint.replace(/\{([^}]+)\}/g,(_match,name)=>`YOUR_${name.replace(/([a-z])([A-Z])/g,'$1_$2').toUpperCase()}`); + const query=new URLSearchParams(); + for(const parameter of parameters.filter(parameter=>parameter.in==='query'&¶meter.required)) { + const value=parameter.example??parameter.schema?.example??parameter.schema?.default??`YOUR_${parameter.name.toUpperCase()}`; + query.append(parameter.name,typeof value==='object'?JSON.stringify(value):String(value)); + } + const lines=[`curl --request ${method}`,` --url ${shellQuote(base+route+(query.size?`?${query}`:''))}`]; + const security=operation.security??spec.security; + const scheme=security?.length?spec.components?.securitySchemes?.[Object.keys(security[0])[0]]:undefined; + if(security?.length!==0)lines.push(` --header ${shellQuote(scheme?.scheme==='bearer'?'Authorization: Bearer YOUR_TOKEN':`${scheme?.name||'x-api-key'}: YOUR_API_KEY`)}`); + for(const parameter of parameters.filter(parameter=>parameter.in==='header'&¶meter.required&¶meter.name!==scheme?.name&¶meter.name.toLowerCase()!=='x-api-key'))lines.push(` --header ${shellQuote(`${parameter.name}: ${parameter.example??parameter.schema?.example??'YOUR_VALUE'}`)}`); + const body=resolveReference(operation.requestBody,spec); + const mediaType=body.content?.['application/json']?'application/json':Object.keys(body.content||{})[0]; + const media=body.content?.[mediaType]; + const schema=resolveReference(media?.schema,spec); + const example=media?.example??(media?.examples?resolveReference(Object.values(media.examples)[0] as ApiObject,spec).value:undefined)??(media?.schema?schemaExample(media.schema,spec):undefined); + if(mediaType==='multipart/form-data'||mediaType==='application/x-www-form-urlencoded') { + for(const [name,raw] of Object.entries(schema.properties||{})) { + if(schema.required&&!schema.required.includes(name))continue; + const property=resolveReference(raw as ApiObject,spec); + const value=property.format==='binary'||property.items?.format==='binary'?'@YOUR_FILE_PATH':(example as ApiObject)?.[name]??schemaExample(property,spec)??'YOUR_VALUE'; + lines.push(` ${mediaType==='multipart/form-data'?'--form':'--data-urlencode'} ${shellQuote(`${name}=${typeof value==='object'?JSON.stringify(value):String(value)}`)}`); + } + } else if(mediaType) { + lines.push(` --header ${shellQuote(`Content-Type: ${mediaType}`)}`); + if(example!==undefined)lines.push(` --data ${shellQuote(typeof example==='string'?example:JSON.stringify(example,null,2))}`); + } + return lines.join(' \\\n'); +} diff --git a/lib/docs/playground/resolvePlaygroundAuth.ts b/lib/docs/playground/resolvePlaygroundAuth.ts index 178c650..a940428 100644 --- a/lib/docs/playground/resolvePlaygroundAuth.ts +++ b/lib/docs/playground/resolvePlaygroundAuth.ts @@ -1,4 +1,4 @@ -import type { ApiObject } from "@/lib/docs-server"; +import type { ApiObject } from "@/lib/docs/apiObject"; import type { PlaygroundAuth } from "./types"; /** diff --git a/lib/docs/playground/summarizeBody.ts b/lib/docs/playground/summarizeBody.ts index d209721..bb4667b 100644 --- a/lib/docs/playground/summarizeBody.ts +++ b/lib/docs/playground/summarizeBody.ts @@ -1,4 +1,6 @@ -import { resolveReference, schemaExample, type ApiObject } from "@/lib/docs-server"; +import { resolveReference } from "@/lib/docs/resolveReference"; +import { schemaExample } from "@/lib/docs/schemaExample"; +import type { ApiObject } from "@/lib/docs/apiObject"; import type { PlaygroundOperation } from "./types"; /** Picks the JSON media type when offered, and prefills the editor from the example, the schema, or `{}`. */ diff --git a/lib/docs/playground/summarizeOperation.ts b/lib/docs/playground/summarizeOperation.ts index ccece87..86fc12d 100644 --- a/lib/docs/playground/summarizeOperation.ts +++ b/lib/docs/playground/summarizeOperation.ts @@ -1,4 +1,5 @@ -import { resolveReference, type ApiObject } from "@/lib/docs-server"; +import { resolveReference } from "@/lib/docs/resolveReference"; +import type { ApiObject } from "@/lib/docs/apiObject"; import { resolvePlaygroundAuth } from "./resolvePlaygroundAuth"; import { summarizeBody } from "./summarizeBody"; import type { PlaygroundOperation, PlaygroundParam } from "./types"; diff --git a/lib/docs/resolveReference.ts b/lib/docs/resolveReference.ts new file mode 100644 index 0000000..da8db7e --- /dev/null +++ b/lib/docs/resolveReference.ts @@ -0,0 +1,10 @@ +import type { ApiObject } from "./apiObject.ts"; + +export function resolveReference(value:ApiObject|undefined,spec:ApiObject):ApiObject { + if(!value)return {}; + if(!value.$ref)return value; + if(!value.$ref.startsWith('#/'))return value; + let resolved:ApiObject=spec; + for(const part of value.$ref.slice(2).split('/'))resolved=resolved?.[part.replace(/~1/g,'/').replace(/~0/g,'~')]; + return resolved ? {...resolved,...Object.fromEntries(Object.entries(value).filter(([key])=>key!=='$ref'))}:value; +} diff --git a/lib/docs/schemaAnchorId.ts b/lib/docs/schemaAnchorId.ts index 62fec45..5db6c5b 100644 --- a/lib/docs/schemaAnchorId.ts +++ b/lib/docs/schemaAnchorId.ts @@ -1,4 +1,4 @@ -import type { ApiObject } from "../docs-server.ts"; +import type { ApiObject } from "./apiObject.ts"; import { anchorSlug } from "./anchorSlug.ts"; /** diff --git a/lib/docs/schemaExample.ts b/lib/docs/schemaExample.ts new file mode 100644 index 0000000..5c5be16 --- /dev/null +++ b/lib/docs/schemaExample.ts @@ -0,0 +1,18 @@ +import type { ApiObject } from "./apiObject.ts"; +import { resolveReference } from "./resolveReference.ts"; + +export function schemaExample(schema:ApiObject,spec:ApiObject,depth=0):unknown { + if(depth>5)return undefined; + schema=resolveReference(schema,spec); + if(schema.example!==undefined)return schema.example; + if(schema.default!==undefined)return schema.default; + if(schema.enum)return schema.enum[0]; + if(schema.oneOf||schema.anyOf)return schemaExample((schema.oneOf||schema.anyOf)[0],spec,depth+1); + if(schema.allOf)return Object.assign({},...schema.allOf.map((item:ApiObject)=>schemaExample(item,spec,depth+1))); + if(schema.type==='object'||schema.properties)return Object.fromEntries(Object.entries(schema.properties||{}).filter(([key])=>!schema.required||schema.required.includes(key)).map(([key,value])=>[key,schemaExample(value as ApiObject,spec,depth+1)])); + if(schema.type==='array')return [schemaExample(schema.items||{},spec,depth+1)]; + if(schema.type==='boolean')return true; + if(schema.type==='integer'||schema.type==='number')return schema.minimum??0; + if(schema.format==='uuid')return 'YOUR_ID'; + return schema.type==='string'?'string':undefined; +} diff --git a/tests/docs-migration.test.ts b/tests/docs-migration.test.ts index b3233eb..c157e16 100644 --- a/tests/docs-migration.test.ts +++ b/tests/docs-migration.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { test, expect } from 'vitest'; -const { buildCurl, resolveReference }: typeof import('../lib/docs-server') = await import(new URL('../lib/docs-server.ts',import.meta.url).href); +import { buildCurl } from "../lib/docs/buildCurl"; +import { resolveReference } from "../lib/docs/resolveReference"; const { resolveDocsHref }: typeof import('../lib/docs-paths') = await import(new URL('../lib/docs-paths.ts',import.meta.url).href); const pages=JSON.parse(readFileSync(new URL('../content/docs/manifest.json',import.meta.url),'utf8')); const inventory=JSON.parse(readFileSync(new URL('../content/docs/inventory.json',import.meta.url),'utf8')); From 4d559805ad22b4478c1adee2ed18bae78ea4ed02 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 8 Nov 2026 23:21:14 -0500 Subject: [PATCH 2/2] fix: render complete OpenAPI curl authentication requirements --- lib/docs/__tests__/buildCurl.test.ts | 65 +++++++++++++++++++++ lib/docs/__tests__/resolveReference.test.ts | 18 ++++++ lib/docs/__tests__/schemaExample.test.ts | 30 ++++++++++ lib/docs/buildCurl.ts | 25 ++++++-- 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 lib/docs/__tests__/buildCurl.test.ts create mode 100644 lib/docs/__tests__/resolveReference.test.ts create mode 100644 lib/docs/__tests__/schemaExample.test.ts diff --git a/lib/docs/__tests__/buildCurl.test.ts b/lib/docs/__tests__/buildCurl.test.ts new file mode 100644 index 0000000..b6e1ae6 --- /dev/null +++ b/lib/docs/__tests__/buildCurl.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import contextSpec from "../../../content/docs/source/api-reference/openapi/context.json"; +import { buildCurl } from "../buildCurl"; + +const guestClaim = contextSpec.paths["/api/context/guest/claim"].post; + +function claimCurl(operation = guestClaim) { + return buildCurl({ method: "POST", endpoint: "/api/context/guest/claim", spec: contextSpec, operation }); +} + +describe("curl security requirements", () => { + it("renders both the guest cookie and bearer token required by the published claim operation", () => { + const curl = claimCurl(); + expect(curl).toContain("--cookie 'recoup_context_guest=YOUR_API_KEY'"); + expect(curl).toContain("--header 'Authorization: Bearer YOUR_TOKEN'"); + expect(curl).toContain("--header 'Origin: YOUR_VALUE'"); + expect(curl).not.toContain("--header 'recoup_context_guest:"); + expect(curl).not.toContain("--header 'x-api-key:"); + }); + + it("renders the published guest read as cookie-only authentication", () => { + const curl = buildCurl({ method: "GET", endpoint: "/api/context/guest", spec: contextSpec, operation: contextSpec.paths["/api/context/guest"].get }); + expect(curl).toContain("--cookie 'recoup_context_guest=YOUR_API_KEY'"); + expect(curl).not.toContain("--header"); + }); + + it("selects only the first OR alternative while retaining all of its required schemes", () => { + const curl = claimCurl({ ...guestClaim, security: [...guestClaim.security].reverse() }); + expect(curl).toContain("--cookie 'recoup_context_guest=YOUR_API_KEY'"); + expect(curl).toContain("--header 'x-api-key: YOUR_API_KEY'"); + expect(curl).not.toContain("Authorization:"); + }); + + it("keeps standalone API-key, bearer, implicit legacy auth and explicit public examples", () => { + const render = (operation: object) => buildCurl({ method: "GET", endpoint: "/fixture", spec: contextSpec, operation }); + expect(render({ security: [{ ApiKey: [] }] })).toContain("--header 'x-api-key: YOUR_API_KEY'"); + expect(render({ security: [{ BearerAuth: [] }] })).toContain("--header 'Authorization: Bearer YOUR_TOKEN'"); + expect(buildCurl({ method: "GET", endpoint: "/fixture", spec: {}, operation: {} })).toContain("--header 'x-api-key: YOUR_API_KEY'"); + expect(render({ security: [] })).not.toContain("--header"); + expect(render({ security: [{}, { BearerAuth: [] }] })).not.toContain("--header"); + }); + + it("places query keys in the URL, combines required cookies, and keeps shell arguments quoted", () => { + const spec = { + security: [{ queryKey: [], cookieA: [], cookieB: [], headerKey: [] }], + components: { securitySchemes: { + queryKey: { type: "apiKey", in: "query", name: "access key" }, + cookieA: { type: "apiKey", in: "cookie", name: "session" }, + cookieB: { type: "apiKey", in: "cookie", name: "tenant's-session" }, + headerKey: { type: "apiKey", in: "header", name: "x-custom-key" }, + } }, + }; + const operation = { parameters: [ + { in: "query", name: "q", required: true, example: "artist & song" }, + { in: "header", name: "X-CUSTOM-KEY", required: true }, + ] }; + const curl = buildCurl({ method: "GET", endpoint: "/fixture", spec, operation }); + expect(curl).toContain("?q=artist+%26+song&access+key=YOUR_API_KEY'"); + expect(curl).toContain("--cookie 'session=YOUR_API_KEY; tenant'\"'\"'s-session=YOUR_API_KEY'"); + expect(curl.match(/--cookie/g)).toHaveLength(1); + expect(curl).toContain("--header 'x-custom-key: YOUR_API_KEY'"); + expect(curl).not.toContain("--header 'X-CUSTOM-KEY:"); + expect(curl).not.toContain("--header 'access key:"); + }); +}); diff --git a/lib/docs/__tests__/resolveReference.test.ts b/lib/docs/__tests__/resolveReference.test.ts new file mode 100644 index 0000000..b7978d1 --- /dev/null +++ b/lib/docs/__tests__/resolveReference.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { resolveReference } from "../resolveReference"; + +describe("OpenAPI local references", () => { + it("resolves escaped pointer names and retains use-site descriptions without mutating the source", () => { + const shared = { type: "string", description: "Shared description", maxLength: 50 }; + const spec = { components: { schemas: { "artist/name~label": shared } } }; + const reference = { $ref: "#/components/schemas/artist~1name~0label", description: "Name used on this request" }; + expect(resolveReference(reference, spec)).toEqual({ type: "string", description: "Name used on this request", maxLength: 50 }); + expect(shared.description).toBe("Shared description"); + expect(reference.$ref).toBe("#/components/schemas/artist~1name~0label"); + }); + + it.each(["#/components/schemas/Missing", "https://example.com/schema.json#/Artist"])("leaves unresolved reference %s visible", ($ref) => { + const reference = { $ref, description: "Unresolved schema" }; + expect(resolveReference(reference, {})).toEqual(reference); + }); +}); diff --git a/lib/docs/__tests__/schemaExample.test.ts b/lib/docs/__tests__/schemaExample.test.ts new file mode 100644 index 0000000..393c2d3 --- /dev/null +++ b/lib/docs/__tests__/schemaExample.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { schemaExample } from "../schemaExample"; + +describe("OpenAPI request examples", () => { + it("combines required fields from composed request schemas and keeps the first alternative", () => { + const spec = { components: { schemas: { + Artist: { type: "object", required: ["artist_id"], properties: { artist_id: { type: "string", format: "uuid" }, note: { type: "string" } } }, + } } }; + const schema = { allOf: [ + { $ref: "#/components/schemas/Artist" }, + { oneOf: [ + { type: "object", properties: { action: { enum: ["claim"] }, enabled: { type: "boolean", default: false } } }, + { type: "object", properties: { action: { enum: ["release"] } } }, + ] }, + ] }; + expect(schemaExample(schema, spec)).toEqual({ artist_id: "YOUR_ID", action: "claim", enabled: false }); + }); + + it.each([null, false, 0, ""])("does not replace an explicit %j example with a default", (example) => { + expect(schemaExample({ example, default: "fallback" }, {})).toBe(example); + }); + + it("produces a serializable example for a recursive schema without recursing forever", () => { + const node = { type: "object", required: ["label", "child"], properties: { label: { example: "node" }, child: { $ref: "#/components/schemas/Node" } } }; + const spec = { components: { schemas: { Node: node } } }; + const example = schemaExample({ $ref: "#/components/schemas/Node" }, spec); + expect(example).toMatchObject({ label: "node", child: { label: "node" } }); + expect(() => JSON.stringify(example)).not.toThrow(); + }); +}); diff --git a/lib/docs/buildCurl.ts b/lib/docs/buildCurl.ts index fb8dea9..feebe80 100644 --- a/lib/docs/buildCurl.ts +++ b/lib/docs/buildCurl.ts @@ -12,11 +12,28 @@ export function buildCurl({method,endpoint,spec,operation,pathItem={}}:{method:s const value=parameter.example??parameter.schema?.example??parameter.schema?.default??`YOUR_${parameter.name.toUpperCase()}`; query.append(parameter.name,typeof value==='object'?JSON.stringify(value):String(value)); } + const security = operation.security ?? spec.security; + const schemes = security == null + ? [{ type: 'apiKey', in: 'header', name: 'x-api-key' }] + : Object.keys(security[0] ?? {}).map(name => resolveReference(spec.components?.securitySchemes?.[name], spec)); + const authHeaders = new Map(); + const cookies = new Map(); + for (const scheme of schemes) { + if (scheme.scheme === 'bearer') { + authHeaders.set('Authorization', 'Bearer YOUR_TOKEN'); + } else if (scheme.type === 'apiKey' && scheme.in === 'query') { + query.set(scheme.name, 'YOUR_API_KEY'); + } else if (scheme.type === 'apiKey' && scheme.in === 'cookie') { + cookies.set(scheme.name, 'YOUR_API_KEY'); + } else { + authHeaders.set(scheme.name || 'x-api-key', 'YOUR_API_KEY'); + } + } const lines=[`curl --request ${method}`,` --url ${shellQuote(base+route+(query.size?`?${query}`:''))}`]; - const security=operation.security??spec.security; - const scheme=security?.length?spec.components?.securitySchemes?.[Object.keys(security[0])[0]]:undefined; - if(security?.length!==0)lines.push(` --header ${shellQuote(scheme?.scheme==='bearer'?'Authorization: Bearer YOUR_TOKEN':`${scheme?.name||'x-api-key'}: YOUR_API_KEY`)}`); - for(const parameter of parameters.filter(parameter=>parameter.in==='header'&¶meter.required&¶meter.name!==scheme?.name&¶meter.name.toLowerCase()!=='x-api-key'))lines.push(` --header ${shellQuote(`${parameter.name}: ${parameter.example??parameter.schema?.example??'YOUR_VALUE'}`)}`); + for (const [name, value] of authHeaders) lines.push(` --header ${shellQuote(`${name}: ${value}`)}`); + if (cookies.size) lines.push(` --cookie ${shellQuote([...cookies].map(([name, value]) => `${name}=${value}`).join('; '))}`); + const authHeaderNames = new Set([...authHeaders.keys()].map(name => name.toLowerCase())); + for(const parameter of parameters.filter(parameter=>parameter.in==='header'&¶meter.required&&!authHeaderNames.has(parameter.name.toLowerCase())&¶meter.name.toLowerCase()!=='x-api-key'))lines.push(` --header ${shellQuote(`${parameter.name}: ${parameter.example??parameter.schema?.example??'YOUR_VALUE'}`)}`); const body=resolveReference(operation.requestBody,spec); const mediaType=body.content?.['application/json']?'application/json':Object.keys(body.content||{})[0]; const media=body.content?.[mediaType];