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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions typescript/.changeset/openapi-auto-hosting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@x402/core": minor
"@x402/express": minor
"@x402/hono": minor
"@x402/fastify": minor
"@x402/next": minor
---

Auto-serve OpenAPI spec at `/openapi.json` for x402 resource servers. Adds `@x402/core/openapi` module that generates an OpenAPI 3.1.0 spec from route config with `x-payment-info` extensions. All framework middlewares now register the endpoint automatically (opt-out with `openAPIOptions: false`).
10 changes: 10 additions & 0 deletions typescript/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@
"default": "./dist/cjs/utils/index.js"
}
},
"./openapi": {
"import": {
"types": "./dist/esm/openapi/index.d.mts",
"default": "./dist/esm/openapi/index.mjs"
},
"require": {
"types": "./dist/cjs/openapi/index.d.ts",
"default": "./dist/cjs/openapi/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/esm/schemas/index.d.mts",
Expand Down
43 changes: 29 additions & 14 deletions typescript/packages/core/src/http/x402HTTPResourceServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,33 @@ export interface RouteConfig {
*/
export type RoutesConfig = Record<string, RouteConfig> | RouteConfig;

/**
* Type guard: returns true when routes is a single RouteConfig,
* false when it's a Record<string, RouteConfig> (route pattern map).
*
* @param routes - The routes configuration to check
* @returns True if routes is a single RouteConfig
*/
export function isSingleRouteConfig(routes: RoutesConfig): routes is RouteConfig {
return "accepts" in routes;
}

/**
* Normalize RoutesConfig into a consistent Record<string, RouteConfig>.
*
* A single RouteConfig is wrapped as { [defaultPattern]: config }.
*
* @param routes - The route configuration (single or map)
* @param defaultPattern - Pattern to use for single RouteConfig (default: "*")
* @returns A normalized record of route patterns to configs
*/
export function normalizeRoutes(
routes: RoutesConfig,
defaultPattern: string = "*",
): Record<string, RouteConfig> {
return isSingleRouteConfig(routes) ? { [defaultPattern]: routes } : routes;
}

/**
* Hook that runs on every request to a protected route, before payment processing.
* Can grant access without payment, deny the request, or continue to payment flow.
Expand Down Expand Up @@ -325,13 +352,7 @@ export class x402HTTPResourceServer {
this.ResourceServer = ResourceServer;
this.routesConfig = routes;

// Handle both single route and multiple routes
const normalizedRoutes =
typeof routes === "object" && !("accepts" in routes)
? (routes as Record<string, RouteConfig>)
: { "*": routes as RouteConfig };

for (const [pattern, config] of Object.entries(normalizedRoutes)) {
for (const [pattern, config] of Object.entries(normalizeRoutes(routes))) {
const parsed = this.parseRoutePattern(pattern);
this.compiledRoutes.push({
verb: parsed.verb,
Expand Down Expand Up @@ -749,13 +770,7 @@ export class x402HTTPResourceServer {
private validateRouteConfiguration(): RouteValidationError[] {
const errors: RouteValidationError[] = [];

// Normalize routes to array of [pattern, config] pairs
const normalizedRoutes =
typeof this.routesConfig === "object" && !("accepts" in this.routesConfig)
? Object.entries(this.routesConfig as Record<string, RouteConfig>)
: [["*", this.routesConfig as RouteConfig] as [string, RouteConfig]];

for (const [pattern, config] of normalizedRoutes) {
for (const [pattern, config] of Object.entries(normalizeRoutes(this.routesConfig))) {
// Warn if wildcard routes are used with discovery extensions
const pathPart = pattern.includes(" ") ? pattern.split(/\s+/)[1] : pattern;
if (
Expand Down
78 changes: 78 additions & 0 deletions typescript/packages/core/src/openapi/bazaar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Bazaar extension extraction: pulls input/output schemas from bazaar discovery data.
*/
import type { JsonSchemaProperty } from "./schemas";

/**
* Schemas extracted from a bazaar discovery extension declaration.
*/
export interface BazaarSchemas {
inputSchema?: JsonSchemaProperty;
outputSchema?: JsonSchemaProperty;
pathParamsSchema?: JsonSchemaProperty;
}

/**
* Safely access a nested object property by key, returning typed result or undefined.
*
* @param obj - The object to access
* @param key - The property key to look up
* @returns The nested object value, or undefined if not found or not an object
*/
function getNestedObject(obj: unknown, key: string): Record<string, unknown> | undefined {
if (obj !== null && typeof obj === "object" && key in (obj as Record<string, unknown>)) {
const value = (obj as Record<string, unknown>)[key];
if (value !== null && typeof value === "object") {
return value as Record<string, unknown>;
}
}
return undefined;
}

/**
* Extract input/output/pathParams schemas from a bazaar discovery extension.
*
* Reads from the `schema` section (JSON Schema declarations) which is always
* generated by `declareDiscoveryExtension`. This is preferred over the `info`
* section which only contains example values.
*
* @param extensions - The route config extensions object
* @returns Extracted schemas for input, output, and path parameters
*/
export function extractBazaarSchemas(extensions?: Record<string, unknown>): BazaarSchemas {
if (!extensions || !("bazaar" in extensions)) return {};

const bazaar = getNestedObject(extensions, "bazaar");
if (!bazaar) return {};

const schema = getNestedObject(bazaar, "schema");
if (!schema) return {};

const result: BazaarSchemas = {};

const schemaProps = getNestedObject(schema, "properties");
if (!schemaProps) return result;

const inputProp = getNestedObject(schemaProps, "input");
if (inputProp) {
const inputFields = getNestedObject(inputProp, "properties");
if (inputFields) {
if (inputFields.queryParams) {
result.inputSchema = inputFields.queryParams as JsonSchemaProperty;
}
if (inputFields.body) {
result.inputSchema = inputFields.body as JsonSchemaProperty;
}
if (inputFields.pathParams) {
result.pathParamsSchema = inputFields.pathParams as JsonSchemaProperty;
}
}
}

const outputProp = getNestedObject(schemaProps, "output");
if (outputProp) {
result.outputSchema = outputProp as JsonSchemaProperty;
}

return result;
}
94 changes: 94 additions & 0 deletions typescript/packages/core/src/openapi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* OpenAPI spec generation for x402 resource servers.
*
* Converts x402 RoutesConfig into an OpenAPI 3.1.0 document compatible
* with agentcash-discovery's parser (x-payment-info, parameters, schemas).
*/
import {
type RoutesConfig,
type RouteConfig,
isSingleRouteConfig,
} from "../http/x402HTTPResourceServer";
import type { OpenAPIDoc, PathItem } from "./schemas";
import { parseRoutePattern, DEFAULT_ROUTE, type ParsedRoute } from "./route";
import { extractBazaarSchemas } from "./bazaar";
import { buildOperation } from "./operation";

export interface OpenAPIOptions {
/** Title for the API (used in info.title) */
title?: string;
/** Version string (used in info.version) */
version?: string;
/** Description of the API */
description?: string;
/** Server URL (e.g., "https://api.example.com") */
serverUrl?: string;
/** Guidance for the API */
guidance?: string;
}

/**
* Adds an operation to the paths map at the given route.
*
* @param paths - The paths map to add the operation to
* @param route - The parsed route with method, path, and params
* @param routeConfig - The x402 route configuration
*/
function addRoute(
paths: Record<string, PathItem>,
route: ParsedRoute,
routeConfig: RouteConfig,
): void {
const bazaar = extractBazaarSchemas(routeConfig.extensions);
const operation = buildOperation(routeConfig, route.pathParams, bazaar);

if (!paths[route.path]) {
paths[route.path] = {};
}
paths[route.path][route.method] = operation;
}

/**
* Generate an OpenAPI 3.1.0 specification from x402 RoutesConfig.
*
* The generated spec is compatible with agentcash-discovery's OpenAPI parser,
* including x-payment-info on each operation for price and protocol metadata.
*
* @param routes - The x402 route configuration
* @param options - Optional metadata for the spec
* @returns A typed OpenAPI 3.1.0 document
*/
export function generateOpenAPISpec(
routes: RoutesConfig,
options: OpenAPIOptions = {},
): OpenAPIDoc {
const { title = "x402 API", version = "1.0.0", description, serverUrl, guidance } = options;

const paths: Record<string, PathItem> = {};

if (isSingleRouteConfig(routes)) {
// Single RouteConfig applies to all routes — map it to GET /
addRoute(paths, DEFAULT_ROUTE, routes);
} else {
for (const [pattern, routeConfig] of Object.entries(routes)) {
addRoute(paths, parseRoutePattern(pattern), routeConfig);
}
}

const spec: OpenAPIDoc = {
openapi: "3.1.0",
info: {
title,
version,
...(description ? { description } : {}),
...(guidance ? { "x-guidance": guidance } : {}),
},
...(serverUrl ? { servers: [{ url: serverUrl }] } : {}),
paths,
};

return spec;
}

// Re-export types for consumers
export type { OpenAPIDoc } from "./schemas";
106 changes: 106 additions & 0 deletions typescript/packages/core/src/openapi/operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* OpenAPI operation builder: constructs typed Operation objects from route configs.
*/
import type { RouteConfig } from "../http/x402HTTPResourceServer";
import type { Operation, Parameter, JsonSchemaProperty } from "./schemas";
import type { BazaarSchemas } from "./bazaar";
import { buildPaymentInfo } from "./payment";

/**
* Build an OpenAPI operation from a route config, path params, and bazaar schemas.
*
* @param routeConfig - The x402 route configuration
* @param pathParams - Extracted path parameter names from the route pattern
* @param bazaar - Schemas extracted from the bazaar discovery extension
* @returns A typed OpenAPI operation object
*/
export function buildOperation(
routeConfig: RouteConfig,
pathParams: string[],
bazaar: BazaarSchemas,
): Operation {
const parameters = buildParameters(pathParams, bazaar);

const operation: Operation = {
"x-payment-info": buildPaymentInfo(routeConfig.accepts),
responses: {
"200": {
description: "Successful response",
...(bazaar.outputSchema
? {
content: {
[routeConfig.mimeType || "application/json"]: {
schema: bazaar.outputSchema,
},
},
}
: {}),
},
"402": {
description: "Payment Required",
},
},
};

if (routeConfig.description) {
operation.summary = routeConfig.description;
}

if (parameters.length > 0) {
operation.parameters = parameters;
}

return operation;
}

/**
* Build OpenAPI parameters from path params and bazaar input schemas.
*
* @param pathParams - Path parameter names from the route pattern
* @param bazaar - Schemas extracted from the bazaar discovery extension
* @returns An array of OpenAPI parameter objects
*/
function buildParameters(pathParams: string[], bazaar: BazaarSchemas): Parameter[] {
const parameters: Parameter[] = [];

// Path parameters
for (const name of pathParams) {
const paramSchema = getPathParamSchema(bazaar.pathParamsSchema, name);

parameters.push({
in: "path",
name,
required: true,
schema: paramSchema || { type: "string" },
});
}

// Query parameters from bazaar input schema
if (bazaar.inputSchema?.properties) {
for (const [name, schema] of Object.entries(bazaar.inputSchema.properties)) {
parameters.push({
in: "query",
name,
required: false,
schema: schema || { type: "string" },
});
}
}

return parameters;
}

/**
* Look up a specific path parameter's schema from the bazaar pathParamsSchema.
*
* @param pathParamsSchema - The path parameters schema object
* @param paramName - The parameter name to look up
* @returns The JSON Schema for the parameter, or undefined if not found
*/
function getPathParamSchema(
pathParamsSchema: JsonSchemaProperty | undefined,
paramName: string,
): JsonSchemaProperty | undefined {
if (!pathParamsSchema?.properties) return undefined;
return pathParamsSchema.properties[paramName];
}
Loading
Loading