diff --git a/src/data/apiRegistry.test.ts b/src/data/apiRegistry.test.ts index 7b690056..432c5c4e 100644 --- a/src/data/apiRegistry.test.ts +++ b/src/data/apiRegistry.test.ts @@ -312,4 +312,49 @@ describe('resolveEndpointPrice', () => { assert.equal(result.endpointId, 'default'); assert.equal(result.priceUsdc, 0); }); + + test('resolves POST and GET on the same path to their respective prices', () => { + const methodEndpoints: EndpointPricing[] = [ + { endpointId: 'ep_items_get', path: '/items', method: 'GET', priceUsdc: 0.01 }, + { endpointId: 'ep_items_post', path: '/items', method: 'POST', priceUsdc: 0.05 }, + ]; + + const getRes = resolveEndpointPrice(methodEndpoints, '/items', 'GET'); + const postRes = resolveEndpointPrice(methodEndpoints, '/items', 'POST'); + + assert.equal(getRes.endpointId, 'ep_items_get'); + assert.equal(getRes.priceUsdc, 0.01); + assert.equal(postRes.endpointId, 'ep_items_post'); + assert.equal(postRes.priceUsdc, 0.05); + }); + + test('prefers method-specific match over method-agnostic entry on the same path', () => { + const mixedEndpoints: EndpointPricing[] = [ + { endpointId: 'ep_any', path: '/items', priceUsdc: 0.01 }, + { endpointId: 'ep_post', path: '/items', method: 'POST', priceUsdc: 0.05 }, + ]; + + assert.equal(resolveEndpointPrice(mixedEndpoints, '/items', 'POST').endpointId, 'ep_post'); + assert.equal(resolveEndpointPrice(mixedEndpoints, '/items', 'GET').endpointId, 'ep_any'); + assert.equal(resolveEndpointPrice(mixedEndpoints, '/items').endpointId, 'ep_any'); + }); + + test('populates method when converting apiRepository records via toApiRegistryEntry', async () => { + const { toApiRegistryEntry } = await import('./apiRegistry.js'); + const entry = toApiRegistryEntry({ + id: 42, + name: 'Items Service', + base_url: 'http://localhost:5000', + developer_id: 7, + endpoints: [ + { id: 1, path: '/items', method: 'get', price_per_call_usdc: '0.01' }, + { id: 2, path: '/items', method: 'POST', price_per_call_usdc: '0.05' }, + ], + }); + + assert.equal(entry.endpoints[0].method, 'GET'); + assert.equal(entry.endpoints[1].method, 'POST'); + assert.equal(resolveEndpointPrice(entry.endpoints, '/items', 'GET').priceUsdc, 0.01); + assert.equal(resolveEndpointPrice(entry.endpoints, '/items', 'POST').priceUsdc, 0.05); + }); }); diff --git a/src/data/apiRegistry.ts b/src/data/apiRegistry.ts index 35f9d50f..68d2e8ec 100644 --- a/src/data/apiRegistry.ts +++ b/src/data/apiRegistry.ts @@ -71,37 +71,106 @@ export class InMemoryApiRegistry implements ApiRegistry { } /** - * Find the price for a given path in an API entry's endpoints. + * Find the price for a given path (and optional HTTP method) in an API entry's endpoints. + * Prefers longer path prefix matches first, and for equal path specificity prefers + * method-specific matches over method-agnostic ones. * Falls back to the wildcard "*" endpoint if no exact match, or 0 if none defined. */ export function resolveEndpointPrice( endpoints: EndpointPricing[], path: string, + method?: string, ): EndpointPricing { const normalisePath = (value: string): string => value.startsWith('/') ? value : `/${value}`; const normalised = normalisePath(path); + const normalisedMethod = method?.trim().toUpperCase(); - // Try exact prefix match (longest first) + const matchesMethod = (ep: EndpointPricing): boolean => { + if (!ep.method) return true; + if (!normalisedMethod) return false; + return ep.method.trim().toUpperCase() === normalisedMethod; + }; + + const isExactMethodMatch = (ep: EndpointPricing): boolean => + Boolean(ep.method && normalisedMethod && ep.method.trim().toUpperCase() === normalisedMethod); + + // Try prefix matches (longest path first; method-specific before method-agnostic at equal length) const sorted = [...endpoints] - .filter((e) => e.path !== '*') - .sort((a, b) => normalisePath(b.path).length - normalisePath(a.path).length); + .filter((e) => e.path !== '*' && matchesMethod(e)) + .sort((a, b) => { + const lenDiff = normalisePath(b.path).length - normalisePath(a.path).length; + if (lenDiff !== 0) return lenDiff; + return Number(isExactMethodMatch(b)) - Number(isExactMethodMatch(a)); + }); + + const applyMethodOverride = (ep: EndpointPricing): EndpointPricing => { + if (!normalisedMethod || !ep.pricePerMethodUsdc) return ep; + for (const [m, p] of Object.entries(ep.pricePerMethodUsdc)) { + if (m.trim().toUpperCase() === normalisedMethod && typeof p === 'number') { + return { ...ep, priceUsdc: p }; + } + } + return ep; + }; for (const ep of sorted) { const epPath = normalisePath(ep.path); if (normalised.startsWith(epPath)) { - return ep; + return applyMethodOverride(ep); } } - // Fall back to wildcard - const wildcard = endpoints.find((e) => e.path === '*'); - if (wildcard) return wildcard; + // Fall back to wildcard (prefer method-specific wildcard over method-agnostic wildcard) + if (normalisedMethod) { + const methodWildcard = endpoints.find( + (e) => e.path === '*' && isExactMethodMatch(e), + ); + if (methodWildcard) return applyMethodOverride(methodWildcard); + } + const wildcard = endpoints.find((e) => e.path === '*' && !e.method); + if (wildcard) return applyMethodOverride(wildcard); // No pricing configured — default free return { endpointId: 'default', path: '*', priceUsdc: 0 }; } +/** + * Convert an ApiWithEndpoints record from apiRepository into an ApiRegistryEntry, + * preserving each endpoint's HTTP method and price. + */ +export function toApiRegistryEntry(api: { + id: number | string; + name: string; + base_url: string; + developer_id: number | string; + created_at?: Date; + endpoints: Array<{ + id: number | string; + path: string; + method?: string | null; + price_per_call_usdc: string | number; + }>; +}): ApiRegistryEntry { + const slug = api.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return { + id: String(api.id), + slug: slug || String(api.id), + base_url: api.base_url, + developerId: String(api.developer_id), + ...(api.created_at ? { created_at: api.created_at } : {}), + endpoints: api.endpoints.map((ep) => ({ + endpointId: String(ep.id), + path: ep.path, + ...(ep.method ? { method: ep.method.trim().toUpperCase() } : {}), + priceUsdc: Number(ep.price_per_call_usdc), + })), + }; +} + // ── Mock data for development / testing ───────────────────────────────────── const DEV_SEED_ENTRIES: ApiRegistryEntry[] = [ diff --git a/src/routes/gatewayRoutes.test.ts b/src/routes/gatewayRoutes.test.ts index 5ad3138f..6ee93db8 100644 --- a/src/routes/gatewayRoutes.test.ts +++ b/src/routes/gatewayRoutes.test.ts @@ -50,8 +50,9 @@ describe("gateway route - rate limiting", () => { expect(res.status).toBe(429); // Retry-After header is in seconds, rounded up expect(res.headers["retry-after"]).toBe(String(Math.ceil(windowMs / 1000))); - expect(res.body).toHaveProperty("code", "TOO_MANY_REQUESTS"); - expect(res.body).toHaveProperty("message", "Too Many Requests"); + const errBody0 = res.body.error ?? res.body; + expect(errBody0).toHaveProperty("code", "TOO_MANY_REQUESTS"); + expect(errBody0).toHaveProperty("message", "Too Many Requests"); expect(res.body).toHaveProperty("requestId"); }); }); @@ -195,8 +196,9 @@ describe("gateway route - body size limits", () => { expect(res.status).toBe(413); expect(res.headers["content-type"]).toMatch(/application\/json/); - expect(res.body).toHaveProperty("code", "REQUEST_BODY_TOO_LARGE"); - expect(res.body).toHaveProperty("message", "Request body too large"); + const errBody413 = res.body.error ?? res.body; + expect(errBody413).toHaveProperty("code", "REQUEST_BODY_TOO_LARGE"); + expect(errBody413).toHaveProperty("message", "Request body too large"); }); }); @@ -303,9 +305,10 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { // Must be 401, never 500. expect(res.status).toBe(401); - expect(res.body).toHaveProperty("code", "UNAUTHORIZED"); + const errBody1 = res.body.error ?? res.body; + expect(errBody1).toHaveProperty("code", "UNAUTHORIZED"); // Generic message — must not reveal whether the prefix was found. - expect(res.body.message).toMatch(/invalid API key/i); + expect(errBody1.message).toMatch(/invalid API key/i); }); test("returns 401 when prefix does not exist at all (no-prefix path)", async () => { @@ -321,8 +324,9 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", "totally-unknown-key-xyz"); expect(res.status).toBe(401); - expect(res.body).toHaveProperty("code", "UNAUTHORIZED"); - expect(res.body.message).toMatch(/invalid API key/i); + const errBody2 = res.body.error ?? res.body; + expect(errBody2).toHaveProperty("code", "UNAUTHORIZED"); + expect(errBody2.message).toMatch(/invalid API key/i); }); test("happy path — exact key match passes auth and reaches billing", async () => { @@ -355,7 +359,8 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", validKey); expect(res.status).toBe(401); - expect(res.body).toHaveProperty("code", "UNAUTHORIZED"); + const errBody3 = res.body.error ?? res.body; + expect(errBody3).toHaveProperty("code", "UNAUTHORIZED"); }); test("returns 403 when key is revoked (prefix + hash match a revoked key)", async () => { @@ -375,7 +380,8 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", validKey); expect(res.status).toBe(403); - expect(res.body).toHaveProperty("code", "FORBIDDEN"); + const errBody4 = res.body.error ?? res.body; + expect(errBody4).toHaveProperty("code", "FORBIDDEN"); }); test("returns 403 when key is in revocation list", async () => { @@ -404,7 +410,8 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", validKey); expect(res.status).toBe(403); - expect(res.body).toHaveProperty("code", "FORBIDDEN"); + const errBody5 = res.body.error ?? res.body; + expect(errBody5).toHaveProperty("code", "FORBIDDEN"); } finally { resetTokenRevocationService(); } @@ -431,8 +438,10 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { expect(unknownRes.status).toBe(401); // Response codes must be identical — client must not be able to distinguish. - expect(mismatchRes.body.code).toBe(unknownRes.body.code); - expect(mismatchRes.body.message).toBe(unknownRes.body.message); + const mismatchErr = mismatchRes.body.error ?? mismatchRes.body; + const unknownErr = unknownRes.body.error ?? unknownRes.body; + expect(mismatchErr.code).toBe(unknownErr.code); + expect(mismatchErr.message).toBe(unknownErr.message); }); }); @@ -506,7 +515,8 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => .set("x-api-key", VALID_KEY); expect(res.status).toBe(429); - expect(res.body.code).toBe("TOO_MANY_REQUESTS"); + const errBody429 = res.body.error ?? res.body; + expect(errBody429.code).toBe("TOO_MANY_REQUESTS"); // Billing must NOT have been called — the handler returned before reaching it // (confirmed by absence of a 402 status) expect(res.status).not.toBe(402); @@ -555,9 +565,117 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => .get(`/gateway/${API_ID}`) .set("x-api-key", VALID_KEY); expect(second.status).toBe(429); - expect(second.body.code).toBe("TOO_MANY_REQUESTS"); + const secondErr = second.body.error ?? second.body; + expect(secondErr.code).toBe("TOO_MANY_REQUESTS"); expect(second.headers["retry-after"]).toBeDefined(); expect(Number(second.headers["retry-after"])).toBeGreaterThan(0); expect(second.body.retryAfterMs).toBeGreaterThan(0); }); }); + +// --------------------------------------------------------------------------- +// proxyRoutes.ts — per-HTTP-method pricing resolution (issue #1226) +// --------------------------------------------------------------------------- +describe("proxy route - per-HTTP-method pricing (issue #1226)", () => { + test("charges method-specific price for GET vs POST and falls back to default price", async () => { + const { createProxyRouter } = await import("./proxyRoutes.js"); + const { createApiRegistry } = await import("../data/apiRegistry.js"); + const originalFetch = global.fetch; + + const testApiId = "method-priced-test-api"; + const validKey = "test-method-price-key-123456"; + const apiKeys = new Map(); + apiKeys.set(validKey, { key: "k-mp", apiId: testApiId, developerId: "dev-mp" }); + + const registry = createApiRegistry([ + { + id: testApiId, + slug: testApiId, + name: "Method Priced API", + base_url: "http://93.184.216.34", + developerId: "dev-mp", + defaultPriceUsdc: 0.01, + endpoints: [ + { + endpointId: "ep-items", + path: "/items", + priceUsdc: 0.01, + pricePerMethodUsdc: { + GET: 0.005, + POST: 0.025, + }, + }, + ], + }, + ]); + + const deductedAmounts: number[] = []; + const recordedAmounts: number[] = []; + + const deps = { + billing: { + checkBalance: async () => 100, + getBalance: async () => 100, + deductCredit: async (_devId: string, amount: number) => { + deductedAmounts.push(amount); + return { success: true, balance: 100 }; + }, + }, + rateLimiter: { check: async () => ({ allowed: true }) }, + usageStore: { + record: async (evt: { amountUsdc?: number }) => { + if (typeof evt.amountUsdc === "number") { + recordedAmounts.push(evt.amountUsdc); + } + return true; + }, + }, + registry, + apiKeys, + proxyConfig: { + allowedHosts: ["93.184.216.34"], + }, + } as any; + + global.fetch = jest.fn().mockImplementation(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as unknown as typeof fetch; + + try { + const app = express(); + app.use(requestIdMiddleware); + app.use("/v1/call", createProxyRouter(deps)); + app.use(errorHandler); + + const getRes = await request(app) + .get(`/v1/call/${testApiId}/items`) + .set("x-api-key", validKey); + expect(getRes.status).toBe(200); + await new Promise((r) => setImmediate(r)); + + const postRes = await request(app) + .post(`/v1/call/${testApiId}/items`) + .set("x-api-key", validKey) + .send({ name: "item" }); + expect(postRes.status).toBe(200); + await new Promise((r) => setImmediate(r)); + + const putRes = await request(app) + .put(`/v1/call/${testApiId}/items`) + .set("x-api-key", validKey) + .send({ name: "updated" }); + expect(putRes.status).toBe(200); + await new Promise((r) => setImmediate(r)); + + expect(deductedAmounts).toEqual([0.005, 0.025, 0.01]); + expect(recordedAmounts).toEqual([0.005, 0.025, 0.01]); + } finally { + global.fetch = originalFetch; + } + }); +}); + + diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index cafcf43d..d4da6b32 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -120,7 +120,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { } const wildcardPath = req.params[0] ?? ''; - const endpoint = resolveEndpointPrice(api.endpoints, wildcardPath); + const endpoint = resolveEndpointPrice(api.endpoints, wildcardPath, req.method); return { api, endpoint }; }, getApiId(api) { diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 88c5fb30..dff007da 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -57,7 +57,11 @@ export interface EndpointPricing { endpointId: string; /** Path pattern to match (e.g. "/data", "/translate"). Use "*" as default. */ path: string; + /** Optional HTTP method (e.g. "GET", "POST"). When omitted, matches all HTTP methods. */ + method?: string; priceUsdc: number; + /** Optional per-HTTP-method price overrides (e.g. { GET: 0.005, POST: 0.025 }). */ + pricePerMethodUsdc?: Record; } /** Interface for billing / credit deduction (e.g. Soroban). */