diff --git a/.changeset/discovery-openapi-consolidation.md b/.changeset/discovery-openapi-consolidation.md new file mode 100644 index 00000000..d9b0c3c5 --- /dev/null +++ b/.changeset/discovery-openapi-consolidation.md @@ -0,0 +1,11 @@ +--- +'mppx': minor +--- + +Add OpenAPI-first discovery tooling via `mppx/discovery`, framework `discovery()` helpers, and `mppx discover validate`. + +This also changes `mppx/proxy` discovery routes: + +- `GET /openapi.json` is now the canonical machine-readable discovery document. +- `GET /llms.txt` remains available as the text-friendly discovery view. +- Legacy `/discover*` routes now return `410 Gone`. diff --git a/AGENTS.md b/AGENTS.md index 33990c2d..8389f8eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ Canonical specs live at [tempoxyz/payment-auth-spec](https://github.com/tempoxyz | **Intent** | [draft-payment-intent-session-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/intents/draft-payment-intent-session-00.md) | Pay-as-you-go streaming payments | | **Method** | [draft-tempo-charge-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/methods/tempo/draft-tempo-charge-00.md) | TIP-20 token transfers on Tempo | | **Method** | [draft-tempo-session-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/methods/tempo/draft-tempo-session-00.md) | Tempo payment channels for streaming | -| **Extension** | [draft-payment-discovery-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/extensions/draft-payment-discovery-00.md) | `/.well-known/payment` discovery | +| **Extension** | [draft-payment-discovery-00](https://paymentauth.org/draft-payment-discovery-00.html) | OpenAPI-first discovery via `/openapi.json` | ### Key Protocol Details diff --git a/package.json b/package.json index 32b69db3..26a47028 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,11 @@ "src": "./src/client/index.ts", "default": "./dist/client/index.js" }, + "./discovery": { + "types": "./dist/discovery/index.d.ts", + "src": "./src/discovery/index.ts", + "default": "./dist/discovery/index.js" + }, "./mcp-sdk/client": { "types": "./dist/mcp-sdk/client/index.d.ts", "src": "./src/mcp-sdk/client/index.ts", diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index f85f7ff0..f8f93de2 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -76,6 +76,217 @@ async function serve(argv: string[], options?: { env?: Record { + test('validates a local discovery document', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-')) + const file = path.join(dir, 'openapi.json') + fs.writeFileSync( + file, + JSON.stringify({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + requestBody: { + content: { 'application/json': { schema: { type: 'object' } } }, + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + }), + ) + + const { output, exitCode } = await serve(['discover', 'validate', file]) + expect(exitCode).toBeUndefined() + expect(output).toContain('Discovery document is valid.') + }) + + test('returns non-zero for invalid discovery documents', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-')) + const file = path.join(dir, 'openapi.json') + fs.writeFileSync( + file, + JSON.stringify({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + responses: { + '200': { description: 'OK' }, + }, + }, + }, + }, + }), + ) + + const { output, exitCode } = await serve(['discover', 'validate', file]) + expect(exitCode).toBe(1) + expect(output).toContain('[error]') + expect(output).toContain('402') + }) + + test( + 'validates remote discovery documents and reports warnings', + { timeout: 20_000 }, + async () => { + const body = JSON.stringify({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + }) + const server = await Http.createServer((_req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(body) + }) + + try { + const { output, exitCode } = await serve(['discover', 'validate', server.url]) + expect(exitCode).toBeUndefined() + expect(output).toContain('[warning]') + expect(output).toContain('requestBody') + expect(output).toContain('valid with 1 warning') + } finally { + server.close() + } + }, + ) + + test( + 'rejects oversized discovery documents via content-length', + { timeout: 20_000 }, + async () => { + const server = await Http.createServer((_req, res) => { + res.setHeader('Content-Type', 'application/json') + res.setHeader('Content-Length', String(11 * 1024 * 1024)) + res.end('{}') + }) + + try { + const { exitCode, output } = await serve(['discover', 'validate', server.url]) + expect(exitCode).toBe(1) + expect(output).toContain('10 MB') + } finally { + server.close() + } + }, + ) +}) + +describe('discover generate', () => { + test('generates from a pre-built OpenAPI document module', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-')) + const mod = path.join(dir, 'doc.mjs') + fs.writeFileSync( + mod, + `export default ${JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/pay': { + post: { + 'x-payment-info': { amount: '100', intent: 'charge', method: 'tempo' }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + })}`, + ) + + const { output, exitCode } = await serve(['discover', 'generate', mod]) + expect(exitCode).toBeUndefined() + const doc = JSON.parse(output) + expect(doc.openapi).toBe('3.1.0') + expect(doc.paths['/pay'].post['x-payment-info'].amount).toBe('100') + + fs.rmSync(dir, { recursive: true, force: true }) + }) + + test('writes to file with --output', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-')) + const mod = path.join(dir, 'doc.mjs') + const outFile = path.join(dir, 'openapi.json') + fs.writeFileSync( + mod, + `export default ${JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + })}`, + ) + + const { output, stderr, exitCode } = await serve([ + 'discover', + 'generate', + mod, + '--output', + outFile, + ]) + expect(exitCode).toBeUndefined() + expect(output).toBe('') + expect(stderr).toContain(outFile) + const written = JSON.parse(fs.readFileSync(outFile, 'utf-8')) + expect(written.openapi).toBe('3.1.0') + + fs.rmSync(dir, { recursive: true, force: true }) + }) + + test('errors when module not found', async () => { + const { output, exitCode } = await serve([ + 'discover', + 'generate', + '/tmp/nonexistent-mppx-module.mjs', + ]) + expect(exitCode).toBe(1) + expect(output).toContain('MODULE_NOT_FOUND') + }) + + test('errors when module has no mppx or openapi export', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-')) + const mod = path.join(dir, 'bad.mjs') + fs.writeFileSync(mod, 'export default { foo: "bar" }') + + const { output, exitCode } = await serve(['discover', 'generate', mod]) + expect(exitCode).toBe(1) + expect(output).toContain('INVALID_MODULE') + + fs.rmSync(dir, { recursive: true, force: true }) + }) +}) + describe('basic charge (examples/basic)', () => { test('happy path: makes payment and receives response', { timeout: 120_000 }, async () => { const { Actions } = await import('viem/tempo') diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 5f9f25cb..48b58f63 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -11,6 +11,7 @@ import { tempo as tempoMainnet } from 'viem/chains' import * as Challenge from '../Challenge.js' import { normalizeHeaders } from '../client/internal/Fetch.js' import * as Mppx from '../client/Mppx.js' +import { validate as validateDiscovery } from '../discovery/Validate.js' import { createDefaultStore, createKeychain, resolveAccountName } from './account.js' import { loadConfig, resolvePlugin } from './internal.js' import type { Plugin } from './plugins/plugin.js' @@ -915,7 +916,168 @@ export default defineConfig({ }, }) +const discover = Cli.create('discover', { + description: 'Discovery tooling', +}) + .command('generate', { + description: 'Generate a static OpenAPI discovery document from a module', + args: z.object({ + module: z.string().describe('Path to a module that default-exports a discovery config'), + }), + options: z.object({ + output: z.string().optional().describe('Write output to a file instead of stdout'), + }), + alias: { output: 'o' }, + async run(c) { + const modulePath = path.resolve(c.args.module) + if (!fs.existsSync(modulePath)) { + return c.error({ + code: 'MODULE_NOT_FOUND', + message: `Module not found: ${modulePath}`, + exitCode: 1, + }) + } + + let mod: Record + try { + mod = await import(modulePath) + } catch (error) { + return c.error({ + code: 'MODULE_IMPORT_FAILED', + message: `Failed to import module: ${(error as Error).message}`, + exitCode: 1, + }) + } + + const exported = (mod.default ?? mod) as Record + + // If the export is already a plain OpenAPI doc (has `openapi` key), use it directly. + // Otherwise, expect { mppx, ...GenerateConfig } and call generate(). + let doc: Record + if (typeof exported.openapi === 'string') { + doc = exported + } else { + const { generate } = await import('../discovery/OpenApi.js') + const mppx = exported.mppx as { methods: readonly any[]; realm: string } + if (!mppx) { + return c.error({ + code: 'INVALID_MODULE', + message: + 'Module must default-export an OpenAPI document (with `openapi` key) or an object with `mppx` (server instance) and `routes`.', + exitCode: 1, + }) + } + doc = generate(mppx, exported as any) + } + + const json = JSON.stringify(doc, null, 2) + if (c.options.output) { + const outPath = path.resolve(c.options.output) + fs.writeFileSync(outPath, `${json}\n`) + process.stderr.write(`Wrote ${outPath}\n`) + } else { + console.log(json) + } + }, + }) + .command('validate', { + description: 'Validate an OpenAPI discovery document from a file or URL', + args: z.object({ + input: z.string().describe('Path or URL to a discovery document'), + }), + async run(c) { + const input = c.args.input + let raw: string + if (/^https?:\/\//.test(input)) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30_000) + let response: Response + try { + response = await globalThis.fetch(input, { signal: controller.signal }) + } catch (error) { + clearTimeout(timeout) + const msg = + error instanceof DOMException && error.name === 'AbortError' + ? 'Request timed out after 30s' + : (error as Error).message + return c.error({ + code: 'DISCOVERY_FETCH_FAILED', + message: `Failed to fetch discovery document: ${msg}`, + exitCode: 1, + }) + } + clearTimeout(timeout) + if (!response.ok) { + return c.error({ + code: 'DISCOVERY_FETCH_FAILED', + message: `Failed to fetch discovery document: HTTP ${response.status}`, + exitCode: 1, + }) + } + const maxSize = 10 * 1024 * 1024 // 10 MB + const contentLength = response.headers.get('content-length') + if (contentLength && Number(contentLength) > maxSize) { + return c.error({ + code: 'DISCOVERY_TOO_LARGE', + message: `Discovery document exceeds 10 MB limit`, + exitCode: 1, + }) + } + raw = await response.text() + if (raw.length > maxSize) { + return c.error({ + code: 'DISCOVERY_TOO_LARGE', + message: `Discovery document exceeds 10 MB limit`, + exitCode: 1, + }) + } + } else { + const resolved = path.resolve(input) + if (!fs.existsSync(resolved)) { + return c.error({ + code: 'DISCOVERY_NOT_FOUND', + message: `Discovery document not found: ${resolved}`, + exitCode: 1, + }) + } + raw = fs.readFileSync(resolved, 'utf-8') + } + + let doc: unknown + try { + doc = JSON.parse(raw) + } catch (error) { + return c.error({ + code: 'DISCOVERY_INVALID_JSON', + message: `Invalid discovery JSON: ${(error as Error).message}`, + exitCode: 1, + }) + } + + const issues = validateDiscovery(doc) + for (const issue of issues) console.log(`[${issue.severity}] ${issue.path}: ${issue.message}`) + + const errorCount = issues.filter((issue) => issue.severity === 'error').length + const warningCount = issues.filter((issue) => issue.severity === 'warning').length + + if (errorCount > 0) { + return c.error({ + code: 'DISCOVERY_INVALID', + message: `Discovery document has ${errorCount} error(s) and ${warningCount} warning(s).`, + exitCode: 1, + }) + } + + console.log( + warningCount > 0 + ? `Discovery document is valid with ${warningCount} warning(s).` + : 'Discovery document is valid.', + ) + }, + }) + cli.command(account) +cli.command(discover) cli.command(init) cli.command(sign) diff --git a/src/discovery/Discovery.test.ts b/src/discovery/Discovery.test.ts new file mode 100644 index 00000000..a855bdbc --- /dev/null +++ b/src/discovery/Discovery.test.ts @@ -0,0 +1,152 @@ +import { DiscoveryDocument, PaymentInfo, ServiceInfo } from './Discovery.js' + +describe('PaymentInfo', () => { + test('parses a valid charge payment info', () => { + const result = PaymentInfo.safeParse({ + amount: '1000', + intent: 'charge', + method: 'tempo', + }) + expect(result.success).toBe(true) + expect(result.data).toEqual({ amount: '1000', intent: 'charge', method: 'tempo' }) + }) + + test('parses a session with null amount', () => { + const result = PaymentInfo.safeParse({ + amount: null, + intent: 'session', + method: 'tempo', + }) + expect(result.success).toBe(true) + expect(result.data?.amount).toBeNull() + }) + + test('accepts custom intents', () => { + const result = PaymentInfo.safeParse({ + amount: '100', + intent: 'subscribe', + method: 'tempo', + }) + expect(result.success).toBe(true) + expect(result.data?.intent).toBe('subscribe') + }) + + test('rejects invalid amount pattern', () => { + const result = PaymentInfo.safeParse({ + amount: '01', + intent: 'charge', + method: 'tempo', + }) + expect(result.success).toBe(false) + }) + + test('accepts x402 format with unknown fields', () => { + const result = PaymentInfo.safeParse({ + price: '0.54', + pricingMode: 'fixed', + protocols: ['x402', 'mpp'], + }) + expect(result.success).toBe(true) + }) +}) + +describe('ServiceInfo', () => { + test('parses a full service info', () => { + const result = ServiceInfo.safeParse({ + categories: ['ai', 'search'], + docs: { + apiReference: 'https://example.com/api', + homepage: 'https://example.com', + llms: 'https://example.com/llms.txt', + }, + }) + expect(result.success).toBe(true) + expect(result.data?.categories).toEqual(['ai', 'search']) + }) + + test('accepts relative paths for doc links', () => { + const result = ServiceInfo.safeParse({ + docs: { + llms: '/llms.txt', + apiReference: '/docs/api', + }, + }) + expect(result.success).toBe(true) + expect(result.data?.docs?.llms).toBe('/llms.txt') + }) + + test('rejects invalid doc URIs', () => { + const result = ServiceInfo.safeParse({ + docs: { + homepage: 'not-a-uri', + }, + }) + expect(result.success).toBe(false) + }) +}) + +describe('DiscoveryDocument', () => { + test('parses a minimal document', () => { + const result = DiscoveryDocument.safeParse({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + }) + expect(result.success).toBe(true) + }) + + test('parses a document with discovery extensions', () => { + const result = DiscoveryDocument.safeParse({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + 'x-service-info': { + categories: ['search'], + }, + }) + expect(result.success).toBe(true) + }) + + test('accepts path items with summary, parameters, and extensions', () => { + const result = DiscoveryDocument.safeParse({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/search': { + summary: 'Search endpoints', + parameters: [{ name: 'q', in: 'query' }], + 'x-custom': 'hello', + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + responses: { '402': { description: 'Payment Required' } }, + }, + }, + }, + }) + expect(result.success).toBe(true) + }) + + test('rejects missing info', () => { + const result = DiscoveryDocument.safeParse({ + openapi: '3.1.0', + }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/discovery/Discovery.ts b/src/discovery/Discovery.ts new file mode 100644 index 00000000..ee69e411 --- /dev/null +++ b/src/discovery/Discovery.ts @@ -0,0 +1,72 @@ +import * as z from '../zod.js' + +const uriOrPathPattern = /^([a-zA-Z][a-zA-Z\d+.-]*:\/\/\S+|\/\S*)$/ + +function uriOrPath() { + return z.string().check(z.regex(uriOrPathPattern, 'Invalid URI or path')) +} + +/** + * Schema for the `x-payment-info` OpenAPI extension on an operation. + * + * Only validates spec-defined fields when present; unknown fields are ignored. + * Discovery is advisory only. Runtime 402 challenges remain authoritative. + */ +export const PaymentInfo = z.looseObject({ + amount: z.optional( + z.union([z.null(), z.string().check(z.regex(/^(0|[1-9][0-9]*)$/, 'Invalid amount'))]), + ), + currency: z.optional(z.string()), + description: z.optional(z.string()), + intent: z.optional(z.string()), + method: z.optional(z.string()), +}) +export type PaymentInfo = z.infer + +const ServiceDocs = z.looseObject({ + apiReference: z.optional(uriOrPath()), + homepage: z.optional(uriOrPath()), + llms: z.optional(uriOrPath()), +}) + +/** + * Schema for the `x-service-info` OpenAPI extension at the document root. + */ +export const ServiceInfo = z.looseObject({ + categories: z.optional(z.array(z.string())), + docs: z.optional(ServiceDocs), +}) +export type ServiceInfo = z.infer + +const OperationObject = z.looseObject({ + 'x-payment-info': z.optional(PaymentInfo), + requestBody: z.optional(z.unknown()), + responses: z.optional(z.record(z.string(), z.unknown())), + summary: z.optional(z.string()), +}) + +const PathItem = z.looseObject({ + delete: z.optional(OperationObject), + get: z.optional(OperationObject), + head: z.optional(OperationObject), + options: z.optional(OperationObject), + patch: z.optional(OperationObject), + post: z.optional(OperationObject), + put: z.optional(OperationObject), + trace: z.optional(OperationObject), +}) + +/** + * Minimal schema for an OpenAPI discovery document annotated with + * `x-service-info` and per-operation `x-payment-info`. + */ +export const DiscoveryDocument = z.looseObject({ + openapi: z.string(), + info: z.looseObject({ + title: z.string(), + version: z.string(), + }), + 'x-service-info': z.optional(ServiceInfo), + paths: z.optional(z.record(z.string(), PathItem)), +}) +export type DiscoveryDocument = z.infer diff --git a/src/discovery/OpenApi.test.ts b/src/discovery/OpenApi.test.ts new file mode 100644 index 00000000..897bc7b9 --- /dev/null +++ b/src/discovery/OpenApi.test.ts @@ -0,0 +1,425 @@ +import * as Method from '../Method.js' +import * as Mppx from '../server/Mppx.js' +import * as z from '../zod.js' +import { generate } from './OpenApi.js' + +const charge = Method.toServer( + Method.from({ + intent: 'charge', + name: 'tempo', + schema: { + credential: { payload: z.object({ signature: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + recipient: z.string(), + }), + }, + }), + { + verify: async () => ({ + method: 'tempo', + reference: '', + status: 'success' as const, + timestamp: '', + }), + }, +) + +const session = Method.toServer( + Method.from({ + intent: 'session', + name: 'tempo', + schema: { + credential: { payload: z.object({ signature: z.string() }) }, + request: z.object({ + amount: z.union([z.null(), z.string()]), + recipient: z.string(), + }), + }, + }), + { + verify: async () => ({ + method: 'tempo', + reference: '', + status: 'success' as const, + timestamp: '', + }), + }, +) + +const subscribe = Method.toServer( + Method.from({ + intent: 'subscribe', + name: 'tempo', + schema: { + credential: { payload: z.object({ signature: z.string() }) }, + request: z.object({ + amount: z.string(), + }), + }, + }), + { + verify: async () => ({ + method: 'tempo', + reference: '', + status: 'success' as const, + timestamp: '', + }), + }, +) + +function createMppx(methods: methods) { + return Mppx.create({ + methods, + realm: 'test-realm', + secretKey: 'test-secret', + }) +} + +describe('generate', () => { + test('generates a valid OpenAPI 3.1.0 document for legacy route config', () => { + const mppx = createMppx([charge]) + const doc = generate(mppx, { + routes: [ + { + intent: 'charge', + method: 'get', + options: { amount: '100', currency: '0xUSDC', recipient: '0x123' }, + path: '/api/resource', + }, + ], + }) + + expect(doc).toMatchInlineSnapshot(` + { + "info": { + "title": "test-realm", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": { + "/api/resource": { + "get": { + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "x-payment-info": { + "amount": "100", + "currency": "0xUSDC", + "intent": "charge", + "method": "tempo", + "recipient": "0x123", + }, + }, + }, + }, + } + `) + }) + + test('supports handler-derived route config', () => { + const mppx = createMppx([charge]) + const handler = mppx.charge({ + amount: '50', + currency: 'usd', + description: 'Search credits', + recipient: '0x1', + }) + + const doc = generate(mppx, { + routes: [ + { + handler, + method: 'post', + path: '/api/search', + }, + ], + }) + + expect(doc).toMatchInlineSnapshot(` + { + "info": { + "title": "test-realm", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": { + "/api/search": { + "post": { + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "x-payment-info": { + "amount": "50", + "currency": "usd", + "intent": "charge", + "method": "tempo", + "recipient": "0x1", + }, + }, + }, + }, + } + `) + }) + + test('handles null amount for session intent', () => { + const mppx = createMppx([session]) + const doc = generate(mppx, { + routes: [ + { + intent: 'session', + method: 'post', + options: { amount: null, recipient: '0x123' }, + path: '/api/stream', + }, + ], + }) + + expect(doc).toMatchInlineSnapshot(` + { + "info": { + "title": "test-realm", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": { + "/api/stream": { + "post": { + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "x-payment-info": { + "amount": null, + "intent": "session", + "method": "tempo", + "recipient": "0x123", + }, + }, + }, + }, + } + `) + }) + + test('includes x-service-info when provided', () => { + const mppx = createMppx([charge]) + const doc = generate(mppx, { + routes: [], + serviceInfo: { + categories: ['ai'], + docs: { homepage: 'https://example.com' }, + }, + }) + + expect(doc).toMatchInlineSnapshot(` + { + "info": { + "title": "test-realm", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": {}, + "x-service-info": { + "categories": [ + "ai", + ], + "docs": { + "homepage": "https://example.com", + }, + }, + } + `) + }) + + test('multi-route document with mixed intents', () => { + const mppx = createMppx([charge, session]) + const doc = generate(mppx, { + info: { title: 'Multi-Route API', version: '2.0.0' }, + routes: [ + { + intent: 'charge', + method: 'post', + options: { amount: '500', currency: '0xUSDC', recipient: '0xABC' }, + path: '/api/search', + summary: 'Search the index', + requestBody: { + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + { + intent: 'session', + method: 'post', + options: { amount: null, recipient: '0xABC' }, + path: '/api/stream', + }, + { + intent: 'charge', + method: 'get', + options: { amount: '100', currency: '0xUSDC', recipient: '0xABC' }, + path: '/api/models', + }, + ], + serviceInfo: { + categories: ['ai', 'search'], + docs: { + apiReference: 'https://example.com/api', + homepage: 'https://example.com', + llms: 'https://example.com/llms.txt', + }, + }, + }) + + expect(doc).toMatchInlineSnapshot(` + { + "info": { + "title": "Multi-Route API", + "version": "2.0.0", + }, + "openapi": "3.1.0", + "paths": { + "/api/models": { + "get": { + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "x-payment-info": { + "amount": "100", + "currency": "0xUSDC", + "intent": "charge", + "method": "tempo", + "recipient": "0xABC", + }, + }, + }, + "/api/search": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "summary": "Search the index", + "x-payment-info": { + "amount": "500", + "currency": "0xUSDC", + "intent": "charge", + "method": "tempo", + "recipient": "0xABC", + }, + }, + }, + "/api/stream": { + "post": { + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "x-payment-info": { + "amount": null, + "intent": "session", + "method": "tempo", + "recipient": "0xABC", + }, + }, + }, + }, + "x-service-info": { + "categories": [ + "ai", + "search", + ], + "docs": { + "apiReference": "https://example.com/api", + "homepage": "https://example.com", + "llms": "https://example.com/llms.txt", + }, + }, + } + `) + }) + + test('passes through custom intents and extra params', () => { + const mppx = createMppx([subscribe]) + const doc = generate(mppx, { + routes: [ + { + intent: 'subscribe', + method: 'post', + options: { amount: '100', interval: 'monthly', recipient: '0xABC' }, + path: '/api/subscribe', + summary: 'Monthly subscription', + }, + ], + }) + + expect(doc).toMatchInlineSnapshot(` + { + "info": { + "title": "test-realm", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": { + "/api/subscribe": { + "post": { + "responses": { + "200": { + "description": "Successful response", + }, + "402": { + "description": "Payment Required", + }, + }, + "summary": "Monthly subscription", + "x-payment-info": { + "amount": "100", + "intent": "subscribe", + "interval": "monthly", + "method": "tempo", + "recipient": "0xABC", + }, + }, + }, + }, + } + `) + }) +}) diff --git a/src/discovery/OpenApi.ts b/src/discovery/OpenApi.ts new file mode 100644 index 00000000..dc8c93ad --- /dev/null +++ b/src/discovery/OpenApi.ts @@ -0,0 +1,224 @@ +import type * as Method from '../Method.js' +import type { ServiceInfo } from './Discovery.js' + +export type DiscoveryHandler = ((...args: any[]) => unknown) & { + _internal?: { + _canonicalRequest: Record + intent: string + name: string + } +} + +export type LegacyRouteConfig = { + intent: string + method: string + options: Record + path: string + requestBody?: Record + summary?: string +} + +export type HandlerRouteConfig = { + handler: DiscoveryHandler + method: string + path: string + requestBody?: Record + summary?: string +} + +export type RouteConfig = HandlerRouteConfig | LegacyRouteConfig + +export type GenerateConfig = { + info?: { title?: string; version?: string } | undefined + routes: RouteConfig[] + serviceInfo?: ServiceInfo | undefined +} + +export type GenerateProxyConfig = { + basePath?: string | undefined + info?: { title?: string; version?: string } | undefined + routes: Array<{ + method: string + path: string + payment: Record | null + requestBody?: Record + summary?: string + }> + serviceInfo?: ServiceInfo | undefined +} + +type ResolvedRoute = { + method: string + path: string + payment: Record | null + requestBody?: Record + summary?: string +} + +/** + * Generates an OpenAPI 3.1.0 discovery document from an mppx instance + * and route configuration. + */ +export function generate( + mppx: { methods: readonly Method.AnyServer[]; realm: string }, + config: GenerateConfig, +): Record { + const methods = mppx.methods + const methodsByKey = new Map() + const intentCount: Record = {} + + for (const mi of methods) { + methodsByKey.set(`${mi.name}/${mi.intent}`, mi) + intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1 + } + for (const mi of methods) { + if (intentCount[mi.intent] === 1) methodsByKey.set(mi.intent, mi) + } + + const routes = config.routes.map((route) => resolveRoute(route, methodsByKey)) + return createDocument({ + info: { + title: config.info?.title ?? mppx.realm, + version: config.info?.version ?? '1.0.0', + }, + routes, + serviceInfo: config.serviceInfo, + }) +} + +/** + * Generates an OpenAPI 3.1.0 discovery document for a proxy surface. + */ +export function generateProxy(config: GenerateProxyConfig): Record { + const routes = config.routes.map((route) => ({ + ...route, + path: withBasePath(config.basePath, route.path), + })) + + return createDocument({ + info: { + title: config.info?.title ?? 'API Proxy', + version: config.info?.version ?? '1.0.0', + }, + routes, + serviceInfo: config.serviceInfo, + }) +} + +function createDocument(config: { + info: { title: string; version: string } + routes: ResolvedRoute[] + serviceInfo?: ServiceInfo | undefined +}) { + const paths: Record> = {} + + for (const route of config.routes) { + const method = route.method.toLowerCase() + const operation: Record = { + responses: { + ...(route.payment ? { '402': { description: 'Payment Required' } } : {}), + '200': { description: 'Successful response' }, + }, + } + + if (route.payment) operation['x-payment-info'] = route.payment + if (route.summary) operation.summary = route.summary + if (route.requestBody) operation.requestBody = route.requestBody + + if (!paths[route.path]) paths[route.path] = {} + paths[route.path]![method] = operation + } + + const doc: Record = { + info: config.info, + openapi: '3.1.0', + paths, + } + if (config.serviceInfo) doc['x-service-info'] = config.serviceInfo + return doc +} + +function resolveRoute( + route: RouteConfig, + methodsByKey: Map, +): ResolvedRoute { + if ('handler' in route) { + const internal = route.handler._internal + if (!internal) + throw new Error( + `Route ${route.method.toUpperCase()} ${route.path} is missing discovery metadata`, + ) + return { + method: route.method, + path: route.path, + payment: paymentInfoFromCanonical({ + canonicalRequest: internal._canonicalRequest, + intent: internal.intent, + method: internal.name, + }), + ...(route.requestBody ? { requestBody: route.requestBody } : {}), + ...(route.summary ? { summary: route.summary } : {}), + } + } + + const mi = methodsByKey.get(route.intent) + if (!mi) { + throw new Error( + `Unknown intent "${route.intent}" for route ${route.method.toUpperCase()} ${route.path}. Available: ${[...methodsByKey.keys()].join(', ')}`, + ) + } + + return { + method: route.method, + path: route.path, + payment: paymentInfoFromCanonical({ + canonicalRequest: route.options, + intent: mi.intent, + method: mi.name, + }), + ...(route.requestBody ? { requestBody: route.requestBody } : {}), + ...(route.summary ? { summary: route.summary } : {}), + } +} + +function paymentInfoFromCanonical(route: { + canonicalRequest: Record + intent: string + method: string +}) { + const { canonicalRequest, intent, method } = route + const methodDetails = (canonicalRequest.methodDetails ?? {}) as Record + + const amount = pickString(canonicalRequest.amount) ?? pickString(methodDetails.amount) ?? null + const currency = pickString(canonicalRequest.currency) ?? pickString(methodDetails.currency) + const description = pickString(canonicalRequest.description) + + const base: Record = { + amount, + ...(currency ? { currency } : {}), + ...(description ? { description } : {}), + intent, + method, + } + + // Forward any extra canonical params that aren't already covered. + const reserved = new Set(['amount', 'currency', 'description', 'methodDetails']) + for (const [key, value] of Object.entries(canonicalRequest)) { + if (!reserved.has(key) && value !== undefined) base[key] = value + } + + return base +} + +function pickString(value: unknown) { + return typeof value === 'string' ? value : undefined +} + +function withBasePath(basePath: string | undefined, path: string) { + if (!basePath) return path + const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}` + const trimmedBasePath = normalizedBasePath.endsWith('/') + ? normalizedBasePath.slice(0, -1) + : normalizedBasePath + return `${trimmedBasePath}${path}` +} diff --git a/src/discovery/Validate.test.ts b/src/discovery/Validate.test.ts new file mode 100644 index 00000000..2a2e2110 --- /dev/null +++ b/src/discovery/Validate.test.ts @@ -0,0 +1,188 @@ +import { validate } from './Validate.js' + +function makeDoc(overrides: Record = {}) { + return { + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + requestBody: { + content: { 'application/json': { schema: { type: 'object' } } }, + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + ...overrides, + } +} + +describe('validate', () => { + test('returns no errors for a valid document', () => { + const errors = validate(makeDoc()) + expect(errors.filter((error) => error.severity === 'error')).toHaveLength(0) + }) + + test('returns error for missing 402 response', () => { + const errors = validate( + makeDoc({ + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + requestBody: {}, + responses: { + '200': { description: 'OK' }, + }, + }, + }, + }, + }), + ) + + expect(errors.find((error) => error.severity === 'error')?.message).toContain('402') + }) + + test('returns warning for missing requestBody', () => { + const errors = validate( + makeDoc({ + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + }), + ) + + expect(errors.find((error) => error.severity === 'warning')?.message).toContain('requestBody') + }) + + test('returns structural errors for invalid top-level document', () => { + const errors = validate({ openapi: '3.1.0' }) + expect(errors.length).toBeGreaterThan(0) + expect(errors[0]!.severity).toBe('error') + }) + + test('returns errors for invalid extension values', () => { + const errors = validate( + makeDoc({ + 'x-service-info': { + docs: { homepage: 'not-a-uri' }, + }, + paths: { + '/search': { + post: { + 'x-payment-info': { + amount: '01', + intent: 'subscribe', + method: 'tempo', + }, + responses: { + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + }), + ) + + expect(errors.some((error) => error.severity === 'error')).toBe(true) + }) + + test('ignores path-item-level fields like summary and parameters', () => { + const errors = validate( + makeDoc({ + paths: { + '/search': { + summary: 'Search endpoints', + parameters: [{ name: 'q', in: 'query' }], + 'x-custom': 'value', + post: { + 'x-payment-info': { + amount: '100', + intent: 'charge', + method: 'tempo', + }, + requestBody: { + content: { 'application/json': { schema: { type: 'object' } } }, + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + }), + ) + + expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0) + }) + + test('validates proxy-generated docs with relative llms path', () => { + const errors = validate({ + info: { title: 'API Proxy', version: '1.0.0' }, + openapi: '3.1.0', + paths: {}, + 'x-service-info': { + categories: ['gateway'], + docs: { + apiReference: 'https://example.com/api', + homepage: 'https://example.com', + llms: '/llms.txt', + }, + }, + }) + + expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0) + }) + + test('accepts x-payment-info with unknown fields', () => { + const errors = validate({ + info: { title: 'Test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/api/call': { + post: { + 'x-payment-info': { + price: '0.54', + pricingMode: 'fixed', + protocols: ['x402', 'mpp'], + }, + requestBody: { + content: { 'application/json': { schema: { type: 'object' } } }, + }, + responses: { + '200': { description: 'OK' }, + '402': { description: 'Payment Required' }, + }, + }, + }, + }, + }) + expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0) + }) +}) diff --git a/src/discovery/Validate.ts b/src/discovery/Validate.ts new file mode 100644 index 00000000..5b9aef15 --- /dev/null +++ b/src/discovery/Validate.ts @@ -0,0 +1,76 @@ +import { DiscoveryDocument, PaymentInfo } from './Discovery.js' + +export type ValidationError = { + message: string + path: string + severity: 'error' | 'warning' +} + +/** + * Validates a discovery document structurally and semantically. + */ +export function validate(doc: unknown): ValidationError[] { + const errors: ValidationError[] = [] + + const result = DiscoveryDocument.safeParse(doc) + if (!result.success) { + for (const issue of result.error.issues) { + errors.push({ + message: issue.message, + path: issue.path.map(String).join('.') || '(root)', + severity: 'error', + }) + } + return errors + } + + const parsed = result.data + const paths = parsed.paths + if (!paths) return errors + + for (const [pathKey, pathItem] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(pathItem as Record)) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) continue + const op = operation as Record + + const opPath = `paths.${pathKey}.${method}` + const rawPaymentInfo = op['x-payment-info'] + if (!rawPaymentInfo) continue + + const paymentResult = PaymentInfo.safeParse(rawPaymentInfo) + if (!paymentResult.success) { + for (const issue of paymentResult.error.issues) { + errors.push({ + message: issue.message, + path: `${opPath}.x-payment-info.${issue.path.map(String).join('.')}`, + severity: 'error', + }) + } + continue + } + + const responses = op.responses as Record | undefined + if (!responses || !('402' in responses)) { + errors.push({ + message: 'Operation with x-payment-info MUST have a 402 response', + path: `${opPath}.responses`, + severity: 'error', + }) + } + + const methodUpper = method.toUpperCase() + if ( + !op.requestBody && + (methodUpper === 'POST' || methodUpper === 'PUT' || methodUpper === 'PATCH') + ) { + errors.push({ + message: 'Operation with x-payment-info has no requestBody', + path: opPath, + severity: 'warning', + }) + } + } + } + + return errors +} diff --git a/src/discovery/index.ts b/src/discovery/index.ts new file mode 100644 index 00000000..fd9c2e31 --- /dev/null +++ b/src/discovery/index.ts @@ -0,0 +1,3 @@ +export * from './Discovery.js' +export * from './OpenApi.js' +export * from './Validate.js' diff --git a/src/middlewares/elysia.test.ts b/src/middlewares/elysia.test.ts index 4313c216..c7ee5080 100644 --- a/src/middlewares/elysia.test.ts +++ b/src/middlewares/elysia.test.ts @@ -3,7 +3,7 @@ import * as http from 'node:http' import { Elysia } from 'elysia' import { Receipt } from 'mppx' import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client' -import { Mppx } from 'mppx/elysia' +import { Mppx, discovery } from 'mppx/elysia' import { tempo as tempo_server } from 'mppx/server' import { describe, expect, test } from 'vp/test' import { accounts, asset, client } from '~test/tempo/viem.js' @@ -87,4 +87,29 @@ describe('charge', () => { server.close() }) + + test('serves /openapi.json from discovery plugin', async () => { + const app = new Elysia().use( + discovery(mppx, { + info: { title: 'Elysia API', version: '1.0.0' }, + routes: [{ handler: mppx.charge({ amount: '1' }), method: 'get', path: '/' }], + }), + ) + + const server = await createServer(app) + const response = await globalThis.fetch(`${server.url}/openapi.json`) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('public, max-age=300') + + const body = (await response.json()) as Record + expect(body.info).toEqual({ title: 'Elysia API', version: '1.0.0' }) + expect(body.paths['/'].get['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + intent: 'charge', + method: 'tempo', + }) + + server.close() + }) }) diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index d26a6a0c..6fe42893 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -1,5 +1,6 @@ -import type { Context } from 'elysia' +import { Elysia, type Context } from 'elysia' +import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -68,3 +69,36 @@ export function payment( if (header) set.headers['Payment-Receipt'] = header } } + +export type DiscoveryConfig = Omit & { + path?: string + routes?: RouteConfig[] +} + +const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' } + +/** + * Returns an Elysia plugin that serves an OpenAPI discovery document. + */ +export function discovery( + mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string }, + config: DiscoveryConfig = {}, +) { + const mountPath = config.path ?? '/openapi.json' + + const cached = JSON.stringify( + generate(mppx, { + ...(config.info ? { info: config.info } : {}), + routes: config.routes ?? [], + ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}), + }), + ) + + return new Elysia().get( + mountPath, + () => + new Response(cached, { + headers: { ...discoveryHeaders, 'Content-Type': 'application/json' }, + }), + ) +} diff --git a/src/middlewares/express.test.ts b/src/middlewares/express.test.ts index ce7f2524..ff879437 100644 --- a/src/middlewares/express.test.ts +++ b/src/middlewares/express.test.ts @@ -1,7 +1,7 @@ import express from 'express' import { Receipt } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' -import { Mppx, payment } from 'mppx/express' +import { Mppx, discovery, payment } from 'mppx/express' import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' @@ -81,6 +81,34 @@ describe('charge', () => { server.close() }) + + test('serves /openapi.json from a handler-derived route config', async () => { + const app = express() + const pay = mppx.charge({ amount: '1' }) + app.get('/', pay, (_req, res) => { + res.json({ fortune: 'You will be rich' }) + }) + discovery(app, mppx, { + info: { title: 'Express API', version: '1.2.3' }, + routes: [{ handler: pay, method: 'get', path: '/' }], + }) + + const server = await createServer(app) + const response = await globalThis.fetch(`${server.url}/openapi.json`) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('public, max-age=300') + + const body = (await response.json()) as Record + expect(body.info).toEqual({ title: 'Express API', version: '1.2.3' }) + expect(body.paths['/'].get['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + intent: 'charge', + method: 'tempo', + }) + + server.close() + }) }) describe('session', () => { diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index e7de1231..4985aac7 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -1,10 +1,12 @@ import type { + Express, Request as ExpressRequest, Response as ExpressResponse, NextFunction, RequestHandler, } from 'express' +import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -84,3 +86,35 @@ export function payment( next() } } + +export type DiscoveryConfig = Omit & { + path?: string + routes?: RouteConfig[] +} + +const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' } + +/** + * Mounts a `GET /openapi.json` route that serves an OpenAPI discovery document. + */ +export function discovery( + app: Express, + mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string }, + config: DiscoveryConfig = {}, +): void { + const mountPath = config.path ?? '/openapi.json' + + const cached = JSON.stringify( + generate(mppx, { + ...(config.info ? { info: config.info } : {}), + routes: config.routes ?? [], + ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}), + }), + ) + + app.get(mountPath, (_req: ExpressRequest, res: ExpressResponse) => { + res.setHeader('Cache-Control', discoveryHeaders['Cache-Control']) + res.setHeader('Content-Type', 'application/json') + res.end(cached) + }) +} diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 28c49d06..a8c51b5c 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -2,7 +2,7 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' import { Receipt } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' -import { Mppx } from 'mppx/hono' +import { Mppx, discovery } from 'mppx/hono' import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' @@ -74,6 +74,28 @@ describe('charge', () => { server.close() }) + + test('serves /openapi.json via auto discovery', async () => { + const app = new Hono() + app.get('/', mppx.charge({ amount: '1' }), (c) => c.json({ fortune: 'You will be rich' })) + discovery(app, mppx, { auto: true, info: { title: 'Auto API', version: '2.0.0' } }) + + const server = await createServer(app) + const response = await globalThis.fetch(`${server.url}/openapi.json`) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('public, max-age=300') + + const body = (await response.json()) as Record + expect(body.info).toEqual({ title: 'Auto API', version: '2.0.0' }) + expect(body.paths['/'].get['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + intent: 'charge', + method: 'tempo', + }) + + server.close() + }) }) describe('session', () => { diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index 75c1d1ba..de3deb5b 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -1,5 +1,6 @@ -import type { MiddlewareHandler } from 'hono' +import type { Hono, MiddlewareHandler } from 'hono' +import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -61,3 +62,74 @@ export function payment( c.res = result.withReceipt(c.res) } } + +export type DiscoveryConfig = Omit & { + auto?: boolean + path?: string + routes?: RouteConfig[] +} + +const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' } + +/** + * Mounts a `GET /openapi.json` route that serves an OpenAPI discovery document. + * + * When `auto` is true, routes are introspected from Hono's internal `app.routes` + * array. This is a **best-effort / experimental** convenience — `app.routes` is + * not part of Hono's stable public API and may change across versions. Prefer + * passing explicit `routes` for production use. + */ +export function discovery( + app: Hono, + mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string }, + config: DiscoveryConfig = {}, +): void { + const mountPath = config.path ?? '/openapi.json' + + let cached: string | undefined + + app.get(mountPath, (c) => { + if (!cached) { + const routes = config.routes ?? (config.auto ? introspectRoutes(app) : []) + const doc = generate(mppx, { + ...(config.info ? { info: config.info } : {}), + routes, + ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}), + }) + cached = JSON.stringify(doc) + } + + c.header('Cache-Control', discoveryHeaders['Cache-Control']) + c.header('Content-Type', 'application/json') + return c.body(cached) + }) +} + +function introspectRoutes(app: Hono): RouteConfig[] { + const routes: RouteConfig[] = [] + const appRoutes = (app as any).routes as + | { handler: any; method: string; path: string }[] + | undefined + + if (!appRoutes) return routes + + const seen = new Set() + + for (const route of appRoutes) { + const internal = (route.handler as { _internal?: Record } | undefined) + ?._internal + if (!internal) continue + + const key = `${route.method}:${route.path}:${internal.name}/${internal.intent}` + if (seen.has(key)) continue + seen.add(key) + + routes.push({ + handler: route.handler, + method: route.method, + path: route.path, + }) + } + + return routes +} diff --git a/src/middlewares/internal/mppx.ts b/src/middlewares/internal/mppx.ts index ad8d02b1..629d908c 100644 --- a/src/middlewares/internal/mppx.ts +++ b/src/middlewares/internal/mppx.ts @@ -1,13 +1,16 @@ +import type { DiscoveryHandler } from '../../discovery/OpenApi.js' import type * as Method from '../../Method.js' import type * as Mppx from '../../server/Mppx.js' export type AnyMethodFn = Mppx.AnyMethodFn export type AnyServer = Method.AnyServer +type DiscoveryMeta = Pick + /** Recursively wraps nested handler objects one level deep. */ type WrapNested = { [key in keyof obj]: obj[key] extends (options: infer options) => any - ? (o: options) => handler + ? (o: options) => handler & DiscoveryMeta : obj[key] } @@ -21,7 +24,7 @@ export type Wrap = { | 'transport' ? mppx[key] : mppx[key] extends (options: infer options) => any - ? (o: options) => handler + ? (o: options) => handler & DiscoveryMeta : mppx[key] extends Record any> ? WrapNested : mppx[key] @@ -43,14 +46,19 @@ export function wrap, handler>( for (const mi of mppx.methods as readonly Method.AnyServer[]) { const key = `${mi.name}/${mi.intent}` const methodFn = (mppx as any)[key] - result[key] = (options: any) => wrapper(methodFn, options) + const wrapWithMeta = (options: any) => { + const configured = methodFn(options) + const handler = wrapper(methodFn, options) as any + if (configured._internal) handler._internal = configured._internal + return handler + } + result[key] = wrapWithMeta // Also set shorthand intent key if Mppx registered it (no collision) - if ((mppx as any)[mi.intent]) result[mi.intent] = (options: any) => wrapper(methodFn, options) + if ((mppx as any)[mi.intent]) result[mi.intent] = wrapWithMeta // Build nested handlers: wrapped.tempo.charge(...) if (!result[mi.name] || typeof result[mi.name] !== 'object') result[mi.name] = {} as Record - ;(result[mi.name] as Record)[mi.intent] = (options: any) => - wrapper(methodFn, options) + ;(result[mi.name] as Record)[mi.intent] = wrapWithMeta } return result as never } diff --git a/src/middlewares/nextjs.test.ts b/src/middlewares/nextjs.test.ts index b7a95016..25d2c4a0 100644 --- a/src/middlewares/nextjs.test.ts +++ b/src/middlewares/nextjs.test.ts @@ -2,7 +2,7 @@ import * as http from 'node:http' import { Receipt } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' -import { Mppx } from 'mppx/nextjs' +import { Mppx, discovery } from 'mppx/nextjs' import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' @@ -89,6 +89,31 @@ describe('charge', () => { server.close() }) + + test('serves /openapi.json from a handler-derived route config', async () => { + const pay = mppx.charge({ amount: '1' }) + const server = await createServer( + discovery(mppx, { + info: { title: 'Next API', version: '3.0.0' }, + routes: [{ handler: pay, method: 'get', path: '/' }], + }), + ) + + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('public, max-age=300') + + const body = (await response.json()) as Record + expect(body.info).toEqual({ title: 'Next API', version: '3.0.0' }) + expect(body.paths['/'].get['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + intent: 'charge', + method: 'tempo', + }) + + server.close() + }) }) describe('session', () => { diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index da85bf3e..89eb4181 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -1,3 +1,4 @@ +import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -64,3 +65,30 @@ export function payment( return result.withReceipt(response) } } + +export type DiscoveryConfig = Omit & { + routes?: RouteConfig[] +} + +const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' } + +/** + * Creates a route handler that serves an OpenAPI discovery document. + */ +export function discovery( + mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string }, + config: DiscoveryConfig = {}, +): RouteHandler { + const cached = JSON.stringify( + generate(mppx, { + ...(config.info ? { info: config.info } : {}), + routes: config.routes ?? [], + ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}), + }), + ) + + return () => + new Response(cached, { + headers: { ...discoveryHeaders, 'Content-Type': 'application/json' }, + }) +} diff --git a/src/proxy/Proxy.test.ts b/src/proxy/Proxy.test.ts index 351bf3ed..308d12db 100644 --- a/src/proxy/Proxy.test.ts +++ b/src/proxy/Proxy.test.ts @@ -65,11 +65,19 @@ function createUpstream(handler: (req: Request) => Response | Promise) } describe('create', () => { - test('behavior: GET /discover/all returns service discovery JSON', async () => { + test('behavior: GET /openapi.json returns discovery JSON', async () => { const proxy = ApiProxy.create({ + categories: ['gateway'], + docs: { + apiReference: 'https://gateway.example.com/reference', + homepage: 'https://gateway.example.com', + }, + title: 'My AI Gateway', + version: '2.0.0', services: [ Service.from('api', { baseUrl: 'https://api.example.com', + categories: ['compute'], routes: { 'GET /v1/models': true, 'POST /v1/generate': mppx_server.charge({ amount: '1', description: 'Generate text' }), @@ -84,73 +92,40 @@ describe('create', () => { }) proxyServer = await Http.createServer(proxy.listener) - const res = await fetch(`${proxyServer.url}/discover/all`) + const res = await fetch(`${proxyServer.url}/openapi.json`) expect(res.status).toBe(200) - expect(await res.json()).toMatchInlineSnapshot(` - [ - { - "id": "api", - "routes": [ - { - "method": "GET", - "path": "/api/v1/models", - "pattern": "GET /api/v1/models", - "payment": null, - }, - { - "method": "POST", - "path": "/api/v1/generate", - "pattern": "POST /api/v1/generate", - "payment": { - "amount": "1000000", - "currency": "0x20c0000000000000000000000000000000000001", - "decimals": 6, - "description": "Generate text", - "intent": "charge", - "method": "tempo", - "recipient": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }, - }, - { - "method": "POST", - "path": "/api/v1/stream", - "pattern": "POST /api/v1/stream", - "payment": { - "amount": "1000000", - "currency": "0x20c0000000000000000000000000000000000001", - "decimals": 6, - "description": "Stream text", - "intent": "session", - "method": "tempo", - "recipient": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "unitType": "token", - }, - }, - ], - }, - ] - `) - }) - - test('behavior: GET /discover returns JSON by default', async () => { - const proxy = ApiProxy.create({ - services: [ - Service.from('api', { - baseUrl: 'https://api.example.com', - routes: { - 'GET /v1/models': true, - }, - }), - ], + expect(res.headers.get('cache-control')).toBe('public, max-age=300') + const body = (await res.json()) as Record + expect(body.openapi).toBe('3.1.0') + expect(body.info).toEqual({ title: 'My AI Gateway', version: '2.0.0' }) + expect(body['x-service-info']).toEqual({ + categories: ['gateway'], + docs: { + apiReference: 'https://gateway.example.com/reference', + homepage: 'https://gateway.example.com', + llms: '/llms.txt', + }, + }) + expect(body.paths['/api/v1/models'].get.responses['200']).toEqual({ + description: 'Successful response', + }) + expect(body.paths['/api/v1/generate'].post['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + description: 'Generate text', + intent: 'charge', + method: 'tempo', + }) + expect(body.paths['/api/v1/stream'].post['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + description: 'Stream text', + intent: 'session', + method: 'tempo', }) - proxyServer = await Http.createServer(proxy.listener) - - const res = await fetch(`${proxyServer.url}/discover`) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`) }) - test('behavior: GET /discover returns llms.txt for markdown clients', async () => { + test('behavior: GET /llms.txt returns text docs linked to OpenAPI discovery', async () => { const proxy = ApiProxy.create({ title: 'My AI Gateway', description: 'A paid proxy for LLM and AI services.', @@ -186,9 +161,7 @@ describe('create', () => { }) proxyServer = await Http.createServer(proxy.listener) - const res = await fetch(`${proxyServer.url}/discover`, { - headers: { Accept: 'text/plain' }, - }) + const res = await fetch(`${proxyServer.url}/llms.txt`) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') expect(await res.text()).toMatchInlineSnapshot(` @@ -198,20 +171,20 @@ describe('create', () => { ## Services - - [OpenAI](/discover/openai.md): Chat completions, embeddings, image generation, and audio transcription. - - [Anthropic](/discover/anthropic.md): Claude language models for messages and completions. + - OpenAI: Chat completions, embeddings, image generation, and audio transcription. + - Anthropic: Claude language models for messages and completions. - [See all service definitions](/discover/all.md)" + [OpenAPI discovery](/openapi.json)" `) }) - test('behavior: GET /discover/:id returns single service', async () => { + test('behavior: GET /openapi.json respects basePath', async () => { const proxy = ApiProxy.create({ + basePath: '/proxy', services: [ Service.from('api', { baseUrl: 'https://api.example.com', routes: { - 'GET /v1/models': true, 'POST /v1/generate': mppx_server.charge({ amount: '1', description: 'Generate text' }), }, }), @@ -219,205 +192,16 @@ describe('create', () => { }) proxyServer = await Http.createServer(proxy.listener) - const res = await fetch(`${proxyServer.url}/discover/api`) - expect(res.status).toBe(200) - expect(await res.json()).toMatchInlineSnapshot(` - { - "id": "api", - "routes": [ - { - "method": "GET", - "path": "/api/v1/models", - "pattern": "GET /api/v1/models", - "payment": null, - }, - { - "method": "POST", - "path": "/api/v1/generate", - "pattern": "POST /api/v1/generate", - "payment": { - "amount": "1000000", - "currency": "0x20c0000000000000000000000000000000000001", - "decimals": 6, - "description": "Generate text", - "intent": "charge", - "method": "tempo", - "recipient": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - }, - }, - ], - } - `) - }) - - test('behavior: GET /discover/all.md returns full markdown with routes', async () => { - const proxy = ApiProxy.create({ - services: [ - openai({ - apiKey: 'sk-test', - routes: { - 'POST /v1/chat/completions': mppx_server.charge({ - amount: '0.05', - description: 'Chat completion', - }), - 'GET /v1/models': true, - }, - }), - anthropic({ - apiKey: 'sk-ant-test', - routes: { - 'POST /v1/messages': mppx_server.charge({ - amount: '0.03', - description: 'Send message', - }), - }, - }), - ], - }) - proxyServer = await Http.createServer(proxy.listener) - - const res = await fetch(`${proxyServer.url}/discover/all.md`) + const res = await fetch(`${proxyServer.url}/proxy/openapi.json`) expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8') - expect(await res.text()).toMatchInlineSnapshot(` - "# Services - - ## [OpenAI](/discover/openai.md) - - Chat completions, embeddings, image generation, and audio transcription. - - ### Routes - - - \`POST /openai/v1/chat/completions\`: Chat completion - - Type: charge - - Price: 0.05 (50000 units, 6 decimals) - - Currency: 0x20c0000000000000000000000000000000000001 - - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=POST%20%2Fv1%2Fchat%2Fcompletions - - - \`GET /openai/v1/models\` - - Type: free - - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=GET%20%2Fv1%2Fmodels - - ## [Anthropic](/discover/anthropic.md) - - Claude language models for messages and completions. - - ### Routes - - - \`POST /anthropic/v1/messages\`: Send message - - Type: charge - - Price: 0.03 (30000 units, 6 decimals) - - Currency: 0x20c0000000000000000000000000000000000001 - " - `) - }) - - test('behavior: GET /discover/:id.md returns markdown', async () => { - const proxy = ApiProxy.create({ - services: [ - openai({ - apiKey: 'sk-test', - routes: { - 'POST /v1/chat/completions': mppx_server.charge({ - amount: '0.05', - description: 'Chat completion', - }), - 'GET /v1/models': true, - }, - }), - anthropic({ - apiKey: 'sk-ant-test', - routes: { - 'POST /v1/messages': mppx_server.charge({ amount: '0.03' }), - }, - }), - ], - }) - proxyServer = await Http.createServer(proxy.listener) - - const res = await fetch(`${proxyServer.url}/discover/openai.md`) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8') - expect(await res.text()).toMatchInlineSnapshot(` - "# OpenAI - - > Documentation: https://context7.com/websites/platform_openai/llms.txt - - Chat completions, embeddings, image generation, and audio transcription. - - ## Routes - - - \`POST /openai/v1/chat/completions\`: Chat completion - - Type: charge - - Price: 0.05 (50000 units, 6 decimals) - - Currency: 0x20c0000000000000000000000000000000000001 - - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=POST%20%2Fv1%2Fchat%2Fcompletions - - - \`GET /openai/v1/models\` - - Type: free - - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=GET%20%2Fv1%2Fmodels - " - `) - }) - - test('behavior: GET /discover/:id with Accept: text/markdown returns markdown', async () => { - const proxy = ApiProxy.create({ - services: [ - openai({ - apiKey: 'sk-test', - routes: { 'GET /v1/models': true }, - }), - anthropic({ - apiKey: 'sk-ant-test', - routes: { - 'POST /v1/messages': mppx_server.charge({ amount: '0.03' }), - }, - }), - ], - }) - proxyServer = await Http.createServer(proxy.listener) - - const res = await fetch(`${proxyServer.url}/discover/anthropic`, { - headers: { Accept: 'text/markdown' }, - }) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8') - }) - - test('behavior: GET /discover/:id without Accept returns JSON', async () => { - const proxy = ApiProxy.create({ - services: [ - openai({ - apiKey: 'sk-test', - routes: { 'GET /v1/models': true }, - }), - anthropic({ - apiKey: 'sk-ant-test', - routes: { - 'POST /v1/messages': mppx_server.charge({ amount: '0.03' }), - }, - }), - ], + const body = (await res.json()) as Record + expect(body.paths['/proxy/api/v1/generate'].post['x-payment-info']).toMatchObject({ + amount: '1000000', + currency: asset, + description: 'Generate text', + intent: 'charge', + method: 'tempo', }) - proxyServer = await Http.createServer(proxy.listener) - - const res = await fetch(`${proxyServer.url}/discover/openai`) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`) - }) - - test('behavior: GET /discover/:id.md returns 404 for unknown', async () => { - const proxy = ApiProxy.create({ services: [] }) - proxyServer = await Http.createServer(proxy.listener) - const res = await fetch(`${proxyServer.url}/discover/unknown.md`) - expect(res.status).toBe(404) - }) - - test('behavior: GET /discover/:id returns 404 for unknown', async () => { - const proxy = ApiProxy.create({ services: [] }) - proxyServer = await Http.createServer(proxy.listener) - const res = await fetch(`${proxyServer.url}/discover/unknown`) - expect(res.status).toBe(404) }) test('behavior: returns 404 for unknown service', async () => { diff --git a/src/proxy/Proxy.ts b/src/proxy/Proxy.ts index 1561eaac..b9170642 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -2,6 +2,7 @@ import type * as http from 'node:http' import { createFetchProxy } from '@remix-run/fetch-proxy' +import { generateProxy } from '../discovery/OpenApi.js' import * as Request from '../server/Request.js' import * as Headers from './internal/Headers.js' import * as Route from './internal/Route.js' @@ -55,6 +56,24 @@ export function create(config: create.Config): Proxy { }), ) + // Pre-generate static discovery responses once at startup. + const openApiJson = JSON.stringify( + generateProxy({ + basePath: config.basePath, + info: { + title: config.title ?? 'API Proxy', + version: config.version ?? '1.0.0', + }, + routes: buildDiscoveryRoutes(config.services), + serviceInfo: buildServiceInfo(config), + }), + ) + const llmsTxt = Service.toLlmsTxt(config.services, { + title: config.title, + description: config.description, + openApiPath: withBasePath(config.basePath, '/openapi.json'), + }) + async function handle(request: globalThis.Request): Promise { const url = new URL(request.url) @@ -62,68 +81,22 @@ export function create(config: create.Config): Proxy { if (!pathname) return new Response('Not Found', { status: 404 }) - if (request.method === 'GET' && pathname === '/llms.txt') - return new Response( - Service.toLlmsTxt(config.services, { - title: config.title, - description: config.description, - }), - { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, - ) - - if (request.method === 'GET' && pathname === '/discover.md') - return new Response( - Service.toLlmsTxt(config.services, { - title: config.title, - description: config.description, - }), - { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, - ) - - if (request.method === 'GET' && (pathname === '/discover' || pathname === '/discover/')) { - if (wantsMarkdown(request)) - return new Response( - Service.toLlmsTxt(config.services, { - title: config.title, - description: config.description, - }), - { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, - ) - return Response.json(config.services.map(Service.serialize)) - } - if ( request.method === 'GET' && - (pathname === '/discover/all' || pathname === '/discover/all/') + (pathname === '/openapi.json' || pathname === '/openapi.json/') ) { - if (wantsMarkdown(request)) - return new Response(Service.toServicesMarkdown(config.services), { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - }) - return Response.json(config.services.map(Service.serialize)) - } - - if (request.method === 'GET' && pathname === '/discover/all.md') - return new Response(Service.toServicesMarkdown(config.services), { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + return new Response(openApiJson, { + headers: { + 'Cache-Control': 'public, max-age=300', + 'Content-Type': 'application/json', + }, }) - - { - // List service - const match = - pathname.match(/^\/discover\/([^/]+)\.md$/) ?? pathname.match(/^\/discover\/([^/]+)\/?$/) - if (request.method === 'GET' && match) { - const service = config.services.find((s) => s.id === match[1]) - if (!service) return new Response('Not Found', { status: 404 }) - const wantsText = pathname.endsWith('.md') || wantsMarkdown(request) - if (wantsText) - return new Response(Service.toMarkdown(service), { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - }) - return Response.json(Service.serialize(service)) - } } + if (request.method === 'GET' && pathname === '/llms.txt') + return new Response(llmsTxt, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) const parsed = Route.parse(pathname) if (!parsed) return new Response('Not Found', { status: 404 }) @@ -177,14 +150,20 @@ export declare namespace create { export type Config = { /** Base path prefix to strip before routing (e.g. `'/api/proxy'`). */ basePath?: string | undefined + /** Free-form categories for root discovery metadata. */ + categories?: string[] | undefined /** Short description of the proxy shown in `llms.txt`. */ description?: string | undefined + /** Structured documentation links for root discovery metadata. */ + docs?: Service.Docs | undefined /** Custom `fetch` implementation. Defaults to `globalThis.fetch`. */ fetch?: typeof globalThis.fetch | undefined /** Services to proxy. Each service is mounted at `/{serviceId}/`. */ services: Service.Service[] /** Human-readable title for the proxy shown in `llms.txt`. */ title?: string | undefined + /** Version to include in the generated OpenAPI document. */ + version?: string | undefined } } @@ -230,41 +209,40 @@ async function proxyUpstream(options: proxyUpstream.Options): Promise return upstreamRes } -const aiUserAgents = [ - 'GPTBot', - 'OAI-SearchBot', - 'ChatGPT-User', - 'anthropic-ai', - 'ClaudeBot', - 'claude-web', - 'PerplexityBot', - 'Perplexity-User', - 'Google-Extended', - 'Googlebot', - 'Bingbot', - 'Amazonbot', - 'Applebot', - 'Applebot-Extended', - 'FacebookBot', - 'meta-externalagent', - 'Bytespider', - 'DuckAssistBot', - 'cohere-ai', - 'AI2Bot', - 'CCBot', - 'Diffbot', - 'YouBot', - 'MistralAI-User', - 'GoogleAgent-Mariner', -] - -const terminalUserAgents = ['curl', 'Wget', 'HTTPie', 'httpie-go', 'mppx', 'presto', 'xh'] - -function wantsMarkdown(request: globalThis.Request): boolean { - const accept = request.headers.get('accept') - if (accept && (accept.includes('text/markdown') || accept.includes('text/plain'))) return true - const ua = request.headers.get('user-agent') ?? '' - if (aiUserAgents.some((agent) => ua.includes(agent))) return true - if (terminalUserAgents.some((agent) => ua.includes(agent))) return true - return false +function buildDiscoveryRoutes(services: Service.Service[]) { + return services.flatMap((service) => + Object.entries(service.routes).map(([pattern, endpoint]) => { + const tokens = pattern.trim().split(/\s+/) + const hasMethod = tokens.length >= 2 + const path = hasMethod ? tokens.slice(1).join(' ') : tokens[0] + return { + method: hasMethod ? tokens[0]! : 'GET', + path: `/${service.id}${path}`, + payment: endpoint ? Service.paymentOf(endpoint) : null, + } + }), + ) +} + +function buildServiceInfo(config: create.Config): { categories?: string[]; docs?: Service.Docs } { + const categories = + config.categories ?? + Array.from(new Set(config.services.flatMap((service) => service.categories ?? []))) + + const docs = { + ...(config.docs ?? {}), + llms: config.docs?.llms ?? withBasePath(config.basePath, '/llms.txt'), + } + + return { + ...(categories.length > 0 ? { categories } : {}), + docs, + } +} + +function withBasePath(basePath: string | undefined, path: string) { + if (!basePath) return path + const normalized = basePath.startsWith('/') ? basePath : `/${basePath}` + const trimmed = normalized.endsWith('/') ? normalized.slice(0, -1) : normalized + return `${trimmed}${path}` } diff --git a/src/proxy/Service.test.ts b/src/proxy/Service.test.ts index ae6cdbf6..9fcc2141 100644 --- a/src/proxy/Service.test.ts +++ b/src/proxy/Service.test.ts @@ -102,6 +102,28 @@ describe('custom', () => { }) }) +describe('paymentOf', () => { + test('behavior: returns null for free passthrough endpoint', () => { + expect(Service.paymentOf(true)).toBeNull() + }) + + test('behavior: returns null for paid handler without _internal metadata', () => { + const handler: Service.IntentHandler = async () => ({ + status: 200 as const, + withReceipt: (r: T) => r, + }) + expect(Service.paymentOf(handler)).toBeNull() + }) + + test('behavior: returns null for paid endpoint object without _internal metadata', () => { + const handler: Service.IntentHandler = async () => ({ + status: 200 as const, + withReceipt: (r: T) => r, + }) + expect(Service.paymentOf({ pay: handler, options: {} })).toBeNull() + }) +}) + describe('getOptions', () => { test('behavior: returns options from endpoint object', () => { const handler: Service.IntentHandler = async () => ({ diff --git a/src/proxy/Service.ts b/src/proxy/Service.ts index 82ec7d9e..04b422d9 100644 --- a/src/proxy/Service.ts +++ b/src/proxy/Service.ts @@ -4,12 +4,14 @@ import { Value } from 'ox' export type Service = { /** Base URL of the upstream service (e.g. `'https://api.openai.com'`). */ baseUrl: string + /** Free-form service categories for discovery metadata. */ + categories?: string[] | undefined /** Short description of the service. */ description?: string | undefined + /** Structured service documentation links for discovery metadata. */ + docs?: Docs | undefined /** Unique identifier used as the URL prefix (e.g. `'openai'` → `/{id}/...`). */ id: string - /** Returns a documentation URL. Called with no argument for the service root, or with a route pattern for per-endpoint docs. */ - docsLlmsUrl?: ((options: { route?: string | undefined }) => string | undefined) | undefined /** Hook to modify the upstream request before sending (e.g. inject auth headers). */ rewriteRequest?: ((req: Request, ctx: Context) => Request | Promise) | undefined /** Hook to modify the upstream response before returning to the client. */ @@ -20,6 +22,12 @@ export type Service = { title?: string | undefined } +export type Docs = { + apiReference?: string | undefined + homepage?: string | undefined + llms?: string | undefined +} + /** * An endpoint definition. * @@ -80,9 +88,10 @@ export function from(id: string, config: from.Config const rewriteFromConfig = resolveRewriteRequest(config) return { baseUrl: config.baseUrl, + categories: config.categories, description: config.description, + docs: resolveDocs(config), id, - docsLlmsUrl: resolveLlmsUrl(config.docsLlmsUrl), routes: config.routes, title: config.title, rewriteRequest: config.rewriteRequest @@ -102,8 +111,12 @@ export declare namespace from { baseUrl: string /** Shorthand: inject `Authorization: Bearer {token}` header. */ bearer?: string | undefined + /** Free-form service categories for discovery metadata. */ + categories?: string[] | undefined /** Short description of the service. */ description?: string | undefined + /** Structured service documentation links for discovery metadata. */ + docs?: Docs | undefined /** Shorthand: inject custom headers. */ headers?: Record | undefined /** Documentation URL for the service. String for a static base URL, or a function receiving an optional endpoint pattern. */ @@ -157,32 +170,14 @@ function resolveRewriteRequest( return undefined } -/** Serializes a service for discovery responses. */ -export function serialize(s: Service) { - return { - description: s.description, - id: s.id, - docsLlmsUrl: s.docsLlmsUrl?.({}), - routes: Object.entries(s.routes).map(([pattern, endpoint]) => { - const tokens = pattern.trim().split(/\s+/) - const hasMethod = tokens.length >= 2 - const path = hasMethod ? tokens.slice(1).join(' ') : tokens[0] - return { - docsLlmsUrl: s.docsLlmsUrl?.({ route: pattern }), - method: hasMethod ? tokens[0] : undefined, - path: `/${s.id}${path}`, - pattern: hasMethod ? `${tokens[0]} /${s.id}${path}` : `/${s.id}${path}`, - payment: endpoint ? resolvePayment(endpoint) : null, - } - }), - title: s.title, - } -} - /** Renders an llms.txt markdown string for a list of services. */ export function toLlmsTxt( services: Service[], - options?: { title?: string | undefined; description?: string | undefined }, + options?: { + description?: string | undefined + openApiPath?: string | undefined + title?: string | undefined + }, ): string { const lines: string[] = [ `# ${options?.title ?? 'API Proxy'}`, @@ -197,65 +192,13 @@ export function toLlmsTxt( for (const s of services) { const label = s.title ?? s.id const desc = s.description ? `: ${s.description}` : '' - lines.push(`- [${label}](/discover/${s.id}.md)${desc}`) + lines.push(`- ${label}${desc}`) } - lines.push('', '[See all service definitions](/discover/all.md)') + lines.push('', `[OpenAPI discovery](${options?.openApiPath ?? '/openapi.json'})`) return lines.join('\n') } -/** Renders a full markdown listing of all services with their routes. */ -export function toServicesMarkdown(services: Service[]): string { - const lines: string[] = ['# Services', ''] - - if (services.length === 0) return lines.join('\n') - - for (const s of services) { - lines.push(`## [${s.title ?? s.id}](/discover/${s.id}.md)`, '') - if (s.description) lines.push(s.description, '') - pushRoutes(lines, s) - } - - return lines.join('\n') -} - -/** Renders a markdown string for a single service. */ -export function toMarkdown(s: Service): string { - const docsLlmsUrl = s.docsLlmsUrl?.({}) - const lines: string[] = [`# ${s.title ?? s.id}`, ''] - if (docsLlmsUrl) lines.push(`> Documentation: ${docsLlmsUrl}`, '') - if (s.description) lines.push(s.description, '') - pushRoutes(lines, s, '##') - return lines.join('\n') -} - -function pushRoutes(lines: string[], s: Service, heading: '##' | '###' = '###') { - lines.push(`${heading} Routes`, '') - const serialized = serialize(s) - for (const route of serialized.routes) { - const p = route.payment as Record | null - const desc = p?.description ? `: ${p.description}` : '' - lines.push(`- \`${route.pattern}\`${desc}`) - if (!p) { - lines.push(' - Type: free') - } else { - lines.push(` - Type: ${p.intent}`) - if (p.amount) { - const perUnit = p.unitType ? `/${p.unitType}` : '' - if (p.decimals !== undefined) { - const price = Number(p.amount) / 10 ** Number(p.decimals) - lines.push(` - Price: ${price}${perUnit} (${p.amount} units, ${p.decimals} decimals)`) - } else { - lines.push(` - Units: ${p.amount}${perUnit}`) - } - } - if (p.currency) lines.push(` - Currency: ${p.currency}`) - } - if (route.docsLlmsUrl) lines.push(` - Docs: ${route.docsLlmsUrl}`) - lines.push('') - } -} - /** Extracts per-endpoint options from an endpoint definition. */ export function getOptions(endpoint: Endpoint): EndpointOptions | undefined { if (typeof endpoint === 'object' && endpoint !== null && 'options' in endpoint) @@ -263,10 +206,10 @@ export function getOptions(endpoint: Endpoint): EndpointOptions | undefined { return undefined } -function resolvePayment(endpoint: Endpoint): Record | null { +export function paymentOf(endpoint: Endpoint): Record | null { if (endpoint === true) return null const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay - if (!('_internal' in handler)) return {} + if (!('_internal' in handler)) return null const { name, intent, @@ -283,10 +226,21 @@ function resolvePayment(endpoint: Endpoint): Record | null { return { intent, method: name, ...rest, ...(amount !== undefined && { amount }) } } -function resolveLlmsUrl( +function resolveDocs(config: from.Config): Docs | undefined { + if (config.docs) { + return { + ...config.docs, + ...(config.docs.llms ? {} : { llms: resolveLlmsFromLegacy(config.docsLlmsUrl) }), + } + } + const llms = resolveLlmsFromLegacy(config.docsLlmsUrl) + return llms ? { llms } : undefined +} + +function resolveLlmsFromLegacy( input: string | ((options: { route?: string | undefined }) => string | undefined) | undefined, -): Service['docsLlmsUrl'] { +): string | undefined { if (!input) return undefined - if (typeof input === 'function') return input - return ({ route }) => (route ? undefined : input) + if (typeof input === 'string') return input + return input({}) ?? undefined } diff --git a/src/proxy/internal/Route.test.ts b/src/proxy/internal/Route.test.ts index 3248279f..ab361480 100644 --- a/src/proxy/internal/Route.test.ts +++ b/src/proxy/internal/Route.test.ts @@ -24,6 +24,14 @@ describe('pathname', () => { Route.pathname(new URL('http://localhost/other/openai/v1/models'), '/api/proxy'), ).toBeNull() }) + + test('error: returns null for basePath prefix collision', () => { + expect(Route.pathname(new URL('http://localhost/proxy2/openai/v1/models'), '/proxy')).toBeNull() + }) + + test('behavior: returns empty string when pathname equals basePath', () => { + expect(Route.pathname(new URL('http://localhost/proxy'), '/proxy')).toBe('') + }) }) describe('parse', () => { diff --git a/src/proxy/internal/Route.ts b/src/proxy/internal/Route.ts index d949eda1..c4feb452 100644 --- a/src/proxy/internal/Route.ts +++ b/src/proxy/internal/Route.ts @@ -5,7 +5,7 @@ export function pathname(url: URL, basePath?: string): string | null { let pathname = url.pathname if (basePath) { const base = basePath.replace(/\/+$/, '') - if (!pathname.startsWith(base)) return null + if (!(pathname === base || pathname.startsWith(`${base}/`))) return null pathname = pathname.slice(base.length) } return pathname diff --git a/src/proxy/services/anthropic.ts b/src/proxy/services/anthropic.ts index 2ea67b59..ca14b9a4 100644 --- a/src/proxy/services/anthropic.ts +++ b/src/proxy/services/anthropic.ts @@ -20,7 +20,12 @@ import * as Service from '../Service.js' export function anthropic(config: anthropic.Config) { return Service.from('anthropic', { baseUrl: config.baseUrl ?? 'https://api.anthropic.com', + categories: ['ai'], description: 'Claude language models for messages and completions.', + docs: { + apiReference: 'https://docs.anthropic.com/en/api/getting-started', + homepage: 'https://docs.anthropic.com/en/docs/intro-to-claude', + }, rewriteRequest(request, ctx) { const apiKey = ctx.apiKey ?? config.apiKey request.headers.set('x-api-key', apiKey) diff --git a/src/proxy/services/openai.ts b/src/proxy/services/openai.ts index 8a2084a5..fc87cb4b 100644 --- a/src/proxy/services/openai.ts +++ b/src/proxy/services/openai.ts @@ -20,11 +20,13 @@ import * as Service from '../Service.js' export function openai(config: openai.Config) { return Service.from('openai', { baseUrl: config.baseUrl ?? 'https://api.openai.com', + categories: ['ai'], description: 'Chat completions, embeddings, image generation, and audio transcription.', - docsLlmsUrl: ({ route }) => - route - ? `https://context7.com/websites/platform_openai/llms.txt?topic=${encodeURIComponent(route)}` - : 'https://context7.com/websites/platform_openai/llms.txt', + docs: { + apiReference: 'https://platform.openai.com/docs/api-reference', + homepage: 'https://platform.openai.com/docs', + llms: 'https://context7.com/websites/platform_openai/llms.txt', + }, rewriteRequest(request, ctx) { const apiKey = ctx.apiKey ?? config.apiKey request.headers.set('Authorization', `Bearer ${apiKey}`) diff --git a/src/proxy/services/stripe.test.ts b/src/proxy/services/stripe.test.ts index 5cf32a89..24761266 100644 --- a/src/proxy/services/stripe.test.ts +++ b/src/proxy/services/stripe.test.ts @@ -129,26 +129,4 @@ describe('stripe', () => { const res = await fetch(`${proxyServer.url}/stripe/v1/unknown`) expect(res.status).toBe(404) }) - - test('behavior: docsLlmsUrl returns route-specific URL', () => { - const service = stripe({ - apiKey, - routes: { - 'POST /v1/charges': mppx_server.charge({ amount: '1', decimals: 6 }), - }, - }) - expect(service.docsLlmsUrl?.({ route: 'POST /v1/charges' })).toBe( - 'https://context7.com/websites/stripe/llms.txt?topic=POST%20%2Fv1%2Fcharges', - ) - }) - - test('behavior: docsLlmsUrl returns fallback URL without route', () => { - const service = stripe({ - apiKey, - routes: { - 'POST /v1/charges': mppx_server.charge({ amount: '1', decimals: 6 }), - }, - }) - expect(service.docsLlmsUrl?.({ route: undefined })).toBe('https://docs.stripe.com/llms.txt') - }) }) diff --git a/src/proxy/services/stripe.ts b/src/proxy/services/stripe.ts index 60d7ebab..08f8c4ce 100644 --- a/src/proxy/services/stripe.ts +++ b/src/proxy/services/stripe.ts @@ -20,11 +20,13 @@ import * as Service from '../Service.js' export function stripe(config: stripe.Config) { return Service.from('stripe', { baseUrl: config.baseUrl ?? 'https://api.stripe.com', + categories: ['payments'], description: 'Payment processing, customers, subscriptions, and invoices.', - docsLlmsUrl: ({ route }) => - route - ? `https://context7.com/websites/stripe/llms.txt?topic=${encodeURIComponent(route)}` - : 'https://docs.stripe.com/llms.txt', + docs: { + apiReference: 'https://docs.stripe.com/api', + homepage: 'https://docs.stripe.com', + llms: 'https://docs.stripe.com/llms.txt', + }, rewriteRequest(request, ctx) { const apiKey = ctx.apiKey ?? config.apiKey request.headers.set('Authorization', `Basic ${btoa(`${apiKey}:`)}`) diff --git a/vite.config.ts b/vite.config.ts index 68ca31ab..4aa37c2a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import { playwright } from 'vp/test/browser-playwright' // Shared aliases used by both projects const alias = { 'mppx/client': path.resolve(import.meta.dirname, 'src/client'), + 'mppx/discovery': path.resolve(import.meta.dirname, 'src/discovery'), 'mppx/mcp-sdk/client': path.resolve(import.meta.dirname, 'src/mcp-sdk/client'), 'mppx/mcp-sdk/server': path.resolve(import.meta.dirname, 'src/mcp-sdk/server'), 'mppx/proxy': path.resolve(import.meta.dirname, 'src/proxy'),