-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add Chartmetric proxy endpoint with credit deduction #318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Comment on lines
+16
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add runtime Zod validation for route params before proxying. Line 16 and Line 33 only use TypeScript typing; ✅ Proposed validation pattern-import { type NextRequest } from "next/server";
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest";
+
+const chartmetricParamsSchema = z.object({
+ path: z.array(z.string().min(1)).min(1),
+});
+
+function validateChartmetricParams(input: unknown) {
+ const parsed = chartmetricParamsSchema.safeParse(input);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { status: "error", error: "Invalid Chartmetric path" },
+ { status: 400 },
+ );
+ }
+ return parsed.data;
+}
@@
export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
- const params = await context.params;
- return proxyChartmetricRequest(request, params);
+ const validated = validateChartmetricParams(await context.params);
+ if (validated instanceof NextResponse) return validated;
+ return proxyChartmetricRequest(request, validated);
}
@@
export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
- const params = await context.params;
- return proxyChartmetricRequest(request, params);
+ const validated = validateChartmetricParams(await context.params);
+ if (validated instanceof NextResponse) return validated;
+ return proxyChartmetricRequest(request, validated);
}As per coding guidelines: "All API endpoints should use a validate function for input parsing using Zod for schema validation." Also applies to: 33-35 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| }), | ||
| }), | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> { | ||
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NextResponse> { | ||
| // 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() }, | ||
| ); | ||
| } | ||
|
Comment on lines
+36
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Credits deducted before confirming upstream success. Currently, credits are deducted prior to the Chartmetric API call. If the upstream request fails (network error, 5xx, rate limit, etc.), the caller loses 5 credits without receiving data. This could lead to user frustration. Consider one of these approaches:
If the current behavior is intentional (e.g., to cover costs of failed calls), it's worth documenting in the JSDoc or user-facing API docs. 🤖 Prompt for AI Agents |
||
|
|
||
| // 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() }, | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JSDoc says 1 credit but implementation deducts 5 credits.
The documentation states "Deducts 1 credit per call" (lines 8 and 24), but
proxyChartmetricRequestactually deducts 5 credits. This inconsistency will mislead API consumers.📝 Update JSDoc to reflect actual credit cost
Apply the same fix to the POST handler's JSDoc (line 24).
🤖 Prompt for AI Agents