Skip to content
Open
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
3 changes: 2 additions & 1 deletion app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
5 changes: 4 additions & 1 deletion components/docs/api-reference.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion lib/agent-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
62 changes: 2 additions & 60 deletions lib/docs-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
import type { ApiObject } from "./docs/apiObject.ts";

export const getDocSpec = cache(async (name: string): Promise<ApiObject> => {
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'&&parameter.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'&&parameter.required&&parameter.name!==scheme?.name&&parameter.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');
}
65 changes: 65 additions & 0 deletions lib/docs/__tests__/buildCurl.test.ts
Original file line number Diff line number Diff line change
@@ -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:");
});
});
18 changes: 18 additions & 0 deletions lib/docs/__tests__/resolveReference.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions lib/docs/__tests__/schemaExample.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 3 additions & 0 deletions lib/docs/apiObject.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
54 changes: 54 additions & 0 deletions lib/docs/buildCurl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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'&&parameter.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 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<string, string>();
const cookies = new Map<string, string>();
for (const scheme of schemes) {
if (scheme.scheme === 'bearer') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Bearer detection is case-sensitive. A spec declaring "scheme": "Bearer" (valid per OpenAPI, which treats the value case-insensitively) falls through to the x-api-key header. Compare with String(scheme.scheme).toLowerCase().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/docs/buildCurl.ts, line 22:

<comment>Bearer detection is case-sensitive. A spec declaring `"scheme": "Bearer"` (valid per OpenAPI, which treats the value case-insensitively) falls through to the `x-api-key` header. Compare with `String(scheme.scheme).toLowerCase()`.</comment>

<file context>
@@ -12,11 +12,28 @@ export function buildCurl({method,endpoint,spec,operation,pathItem={}}:{method:s
+ const authHeaders = new Map<string, string>();
+ const cookies = new Map<string, string>();
+ for (const scheme of schemes) {
+  if (scheme.scheme === 'bearer') {
+   authHeaders.set('Authorization', 'Bearer YOUR_TOKEN');
+  } else if (scheme.type === 'apiKey' && scheme.in === 'query') {
</file context>
Suggested change
if (scheme.scheme === 'bearer') {
if (scheme.scheme?.toLowerCase() === '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}`:''))}`];
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'&&parameter.required&&!authHeaderNames.has(parameter.name.toLowerCase())&&parameter.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');
}
2 changes: 1 addition & 1 deletion lib/docs/playground/resolvePlaygroundAuth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ApiObject } from "@/lib/docs-server";
import type { ApiObject } from "@/lib/docs/apiObject";
import type { PlaygroundAuth } from "./types";

/**
Expand Down
4 changes: 3 additions & 1 deletion lib/docs/playground/summarizeBody.ts
Original file line number Diff line number Diff line change
@@ -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 `{}`. */
Expand Down
3 changes: 2 additions & 1 deletion lib/docs/playground/summarizeOperation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
10 changes: 10 additions & 0 deletions lib/docs/resolveReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ApiObject } from "./apiObject.ts";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This new module has no direct unit test. Add lib/docs/__tests__/resolveReference.test.ts covering ~1/~0 pointer escapes, sibling keys overriding the target, external $ref passthrough, and a missing target. Per AGENTS.md, tests belong in __tests__/ next to the code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/docs/resolveReference.ts, line 3:

<comment>This new module has no direct unit test. Add `lib/docs/__tests__/resolveReference.test.ts` covering `~1`/`~0` pointer escapes, sibling keys overriding the target, external `$ref` passthrough, and a missing target. Per AGENTS.md, tests belong in `__tests__/` next to the code.</comment>

<file context>
@@ -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;
</file context>


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;
}
2 changes: 1 addition & 1 deletion lib/docs/schemaAnchorId.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ApiObject } from "../docs-server.ts";
import type { ApiObject } from "./apiObject.ts";
import { anchorSlug } from "./anchorSlug.ts";

/**
Expand Down
Loading
Loading