diff --git a/app/api/chartmetric/[...path]/route.ts b/app/api/chartmetric/[...path]/route.ts new file mode 100644 index 00000000..8e98b6ee --- /dev/null +++ b/app/api/chartmetric/[...path]/route.ts @@ -0,0 +1,40 @@ +import { type NextRequest } from "next/server"; +import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest"; + +/** + * GET /api/chartmetric/[...path] + * + * Proxies GET requests to the Chartmetric API on behalf of an authenticated account. + * Deducts 1 credit per call. + * + * @param request - Incoming API request. + * @param context - Route context containing the Chartmetric path segments. + * @param context.params - Route params with Chartmetric path segments. + * @param context.params.path - Array of path segments to forward to Chartmetric. + * @returns The Chartmetric API response. + */ +export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const params = await context.params; + return proxyChartmetricRequest(request, params); +} + +/** + * POST /api/chartmetric/[...path] + * + * Proxies POST requests to the Chartmetric API on behalf of an authenticated account. + * Deducts 1 credit per call. + * + * @param request - Incoming API request. + * @param context - Route context containing the Chartmetric path segments. + * @param context.params - Route params with Chartmetric path segments. + * @param context.params.path - Array of path segments to forward to Chartmetric. + * @returns The Chartmetric API response. + */ +export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const params = await context.params; + return proxyChartmetricRequest(request, params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts b/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts new file mode 100644 index 00000000..0c0459a1 --- /dev/null +++ b/lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ + getChartmetricToken: vi.fn(), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("proxyChartmetricRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("error cases", () => { + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + + expect(result.status).toBe(401); + }); + + it("returns 402 when credits are insufficient", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token_abc", + }); + vi.mocked(deductCredits).mockRejectedValue( + new Error("Insufficient credits. Required: 5, Available: 0"), + ); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + const body = await result.json(); + + expect(result.status).toBe(402); + expect(body.status).toBe("error"); + expect(body.error).toBe("Insufficient credits for Chartmetric API call"); + }); + + it("returns 500 when Chartmetric API call fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token_abc", + }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 }); + vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token"); + mockFetch.mockRejectedValue(new Error("Network error")); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); + + describe("successful cases", () => { + it("successfully proxies a GET request and returns Chartmetric response", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token_abc", + }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 }); + vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token"); + + const chartmetricData = { id: 123, name: "Test Artist" }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => chartmetricData, + }); + + const request = new NextRequest("http://localhost/api/chartmetric/artist/123", { + method: "GET", + }); + + const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual(chartmetricData); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.chartmetric.com/api/artist/123", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer cm_access_token", + }), + }), + ); + expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_123", creditsToDeduct: 5 }); + }); + + it("successfully proxies a POST request with body", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_456", + orgId: null, + authToken: "token_xyz", + }); + vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 }); + vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token_2"); + + const chartmetricData = { success: true }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => chartmetricData, + }); + + const requestBody = { filter: "test" }; + const request = new NextRequest("http://localhost/api/chartmetric/search", { + method: "POST", + body: JSON.stringify(requestBody), + headers: { "Content-Type": "application/json" }, + }); + + const result = await proxyChartmetricRequest(request, { path: ["search"] }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual(chartmetricData); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.chartmetric.com/api/search", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer cm_access_token_2", + }), + }), + ); + }); + }); +}); diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts new file mode 100644 index 00000000..c2fc3615 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,33 @@ +/** + * Exchanges the Chartmetric refresh token for a short-lived access token. + * + * @returns The Chartmetric access token string. + * @throws Error if the token exchange fails or the env variable is missing. + */ +export async function getChartmetricToken(): Promise { + const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; + + if (!refreshToken) { + throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); + } + + const response = await fetch("https://api.chartmetric.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshtoken: refreshToken }), + }); + + if (!response.ok) { + throw new Error(`Chartmetric token exchange failed with status ${response.status}`); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + + if (!data.access_token) { + throw new Error("Chartmetric token response did not include an access_token"); + } + + return data.access_token; +} diff --git a/lib/chartmetric/proxyChartmetricRequest.ts b/lib/chartmetric/proxyChartmetricRequest.ts new file mode 100644 index 00000000..fe2e20bd --- /dev/null +++ b/lib/chartmetric/proxyChartmetricRequest.ts @@ -0,0 +1,105 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +/** + * Proxy handler for Chartmetric API requests. + * + * Authenticates the caller, deducts 5 credits, then forwards the request + * to the Chartmetric API using a server-side access token. + * + * Credit cost rationale: Chartmetric costs $350/month for API access. At 5 credits + * ($0.05) per call, we break even at ~7,000 calls/month (~233/day). A typical + * research task (6–7 API calls) costs 30–35 credits ($0.30–$0.35). + * + * @param request - The incoming NextRequest. + * @param params - Route params containing the Chartmetric path segments. + * @param params.path - Array of path segments to forward to the Chartmetric API. + * @returns The Chartmetric API response forwarded as JSON. + */ +export async function proxyChartmetricRequest( + request: NextRequest, + params: { path: string[] }, +): Promise { + // 1. Authenticate + const authResult = await validateAuthContext(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // 2. Deduct 5 credits per Chartmetric API call + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + if (message.toLowerCase().includes("insufficient credits")) { + return NextResponse.json( + { status: "error", error: "Insufficient credits for Chartmetric API call" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "error", error: "Failed to deduct credits" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + // 3. Get Chartmetric access token + let accessToken: string; + try { + accessToken = await getChartmetricToken(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { status: "error", error: `Failed to obtain Chartmetric token: ${message}` }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + // 4. Build the Chartmetric URL, preserving query params + const pathSegments = params.path.join("/"); + const originalUrl = new URL(request.url); + const chartmetricUrl = `https://api.chartmetric.com/api/${pathSegments}${originalUrl.search}`; + + // 5. Read the request body for non-GET methods + let body: string | undefined; + if (request.method !== "GET" && request.method !== "HEAD") { + try { + body = await request.text(); + } catch { + // If body reading fails, proceed without a body + } + } + + // 6. Forward the request to Chartmetric + try { + const chartmetricResponse = await fetch(chartmetricUrl, { + method: request.method, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + ...(body ? { body } : {}), + }); + + const data = await chartmetricResponse.json(); + + return NextResponse.json(data, { + status: chartmetricResponse.status, + headers: getCorsHeaders(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { status: "error", error: `Chartmetric API request failed: ${message}` }, + { status: 500, headers: getCorsHeaders() }, + ); + } +}