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
45 changes: 45 additions & 0 deletions src/data/apiRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
85 changes: 77 additions & 8 deletions src/data/apiRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
148 changes: 133 additions & 15 deletions src/routes/gatewayRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Expand Down Expand Up @@ -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");
});
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, ApiKey>();
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;
}
});
});


2 changes: 1 addition & 1 deletion src/routes/proxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading