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
1 change: 1 addition & 0 deletions lib/markdown-negotiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const MARKDOWN_USER_AGENT_SUBSTRINGS = [
const MARKDOWN_VARY_HEADERS = ['Accept', 'User-Agent'];

const EXCLUDED_EXACT_PATHS = new Set([
'/.well-known/api-catalog',
'/.well-known/llms.txt',
'/AGENTS',
'/AGENTS.md',
Expand Down
19 changes: 19 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ const config = {
destination: "https://steel.apidocumentation.com/api-reference",
permanent: true,
},
{
// A memorable docs URL for the spec, redirecting rather than mirroring:
// the API serves the spec with open CORS, so a local copy would only
// add one that goes stale between docs deploys.
source: "/openapi.json",
destination: "https://api.steel.dev/sdk-openapi.json",
permanent: true,
},
// {
// source: "/overview/steel-cli",
// destination: "https://github.com/steel-dev/cli",
Expand Down Expand Up @@ -201,6 +209,17 @@ const config = {
},
async headers() {
return [
{
// RFC 9727 requires the catalog to be served as a linkset. The file has
// no extension, so static serving would otherwise guess a type for it.
source: "/.well-known/api-catalog",
headers: [
{
key: "Content-Type",
value: 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
},
],
},
{
source: "/(.*)",
headers: [
Expand Down
27 changes: 27 additions & 0 deletions public/.well-known/api-catalog
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"linkset": [
{
"anchor": "https://docs.steel.dev/.well-known/api-catalog",
"item": [
{
"href": "https://api.steel.dev"
}
]
},
{
"anchor": "https://api.steel.dev",
"service-desc": [
{
"href": "https://api.steel.dev/sdk-openapi.json",
"type": "application/openapi+json"
}
],
"service-doc": [
{
"href": "https://docs.steel.dev/overview/intro-to-steel",
"type": "text/html"
}
]
}
]
}
76 changes: 76 additions & 0 deletions tests/api-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// ABOUTME: Tests for the RFC 9727 API catalog served at /.well-known/api-catalog,
// ABOUTME: covering the linkset shape and that markdown negotiation leaves it alone.
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

import { isNegotiableDocsPath } from '../lib/markdown-negotiation';

const CATALOG_PATH = '/.well-known/api-catalog';

type Link = { href: string; type?: string };
type LinksetEntry = {
anchor: string;
item?: Link[];
'service-desc'?: Link[];
'service-doc'?: Link[];
};

const catalog = JSON.parse(
readFileSync(
fileURLToPath(new URL('../public/.well-known/api-catalog', import.meta.url)),
'utf8',
),
) as { linkset: LinksetEntry[] };

function entryFor(anchor: string): LinksetEntry | undefined {
return catalog.linkset.find((entry) => entry.anchor === anchor);
}

describe('api catalog document', () => {
test('is a linkset anchored at its own well-known URI', () => {
expect(Array.isArray(catalog.linkset)).toBe(true);
expect(entryFor(`https://docs.steel.dev${CATALOG_PATH}`)).toBeDefined();
});

test('lists the Steel API as a catalog item', () => {
const items = entryFor(`https://docs.steel.dev${CATALOG_PATH}`)?.item ?? [];
expect(items.map((item) => item.href)).toContain('https://api.steel.dev');
});

test('describes the API with the canonical OpenAPI spec, not a docs copy', () => {
const [desc] = entryFor('https://api.steel.dev')?.['service-desc'] ?? [];

// Pointing at the spec the API itself serves keeps one canonical copy, so
// the catalog cannot advertise a spec that has drifted from the live API.
expect(desc?.href).toBe('https://api.steel.dev/sdk-openapi.json');
expect(desc?.type).toBe('application/openapi+json');
});

test('links human documentation for the API', () => {
const [doc] = entryFor('https://api.steel.dev')?.['service-doc'] ?? [];
expect(doc?.href).toStartWith('https://');
expect(doc?.type).toBe('text/html');
});

test('every link is an absolute https URL', () => {
for (const entry of catalog.linkset) {
const links = [
...(entry.item ?? []),
...(entry['service-desc'] ?? []),
...(entry['service-doc'] ?? []),
];
for (const link of links) {
expect(link.href).toStartWith('https://');
}
}
});
});

describe('api catalog and markdown negotiation', () => {
test('is excluded from markdown negotiation', () => {
// The path has no file extension, so without an explicit exclusion the
// middleware would treat it as a docs page and rewrite it into a 404.
expect(isNegotiableDocsPath(CATALOG_PATH)).toBe(false);
});
});
28 changes: 26 additions & 2 deletions tests/e2e/llm-endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// ABOUTME: End-to-end tests that boot the Next.js dev server and verify the
// ABOUTME: LLM-facing endpoints: .md-suffixed URLs, index pointer, llms-full.txt.
// ABOUTME: End-to-end tests that boot the Next.js dev server and verify the agent-facing
// ABOUTME: endpoints: .md URLs, index pointer, llms-full.txt, api-catalog, openapi redirect.
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { fileURLToPath } from 'node:url';

Expand Down Expand Up @@ -111,6 +111,30 @@ describe('.md suffix end-to-end', () => {
expect(await response.text()).toStartWith('> Full docs index: https://docs.steel.dev/llms.txt');
});

test('serves the API catalog as a linkset', async () => {
const response = await fetch(`${BASE_URL}/.well-known/api-catalog`, {
headers: { accept: 'application/linkset+json' },
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toStartWith('application/linkset+json');
const catalog = (await response.json()) as { linkset: Array<{ anchor: string }> };
expect(catalog.linkset.length).toBeGreaterThan(0);
});

test('serves the API catalog to markdown clients too, without rewriting it', async () => {
const response = await fetch(`${BASE_URL}/.well-known/api-catalog`, {
headers: { accept: 'text/markdown', 'user-agent': 'claude-code/1.0' },
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toStartWith('application/linkset+json');
});

test('redirects /openapi.json to the canonical spec', async () => {
const response = await fetch(`${BASE_URL}/openapi.json`, { redirect: 'manual' });
expect(response.status).toBe(308);
expect(response.headers.get('location')).toBe('https://api.steel.dev/sdk-openapi.json');
});

test('returns 404 for a .md URL with no matching page', async () => {
const response = await fetch(`${BASE_URL}/nonexistent-page.md`, {
headers: BROWSER_HEADERS,
Expand Down
Loading