From 2310e36971f63de222a4e6f982d763f7c4f08acd Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Sun, 15 Mar 2026 15:09:42 +0800 Subject: [PATCH] refactor: unify fallback next json proxies --- web/src/app/api/_utils/backend-proxy.test.ts | 32 +++++++++++++ web/src/app/api/_utils/backend-proxy.ts | 2 + .../sessions/[sessionId]/route.test.ts | 29 +++++++++++ .../paperscool/sessions/[sessionId]/route.ts | 16 ++----- web/src/app/api/studio/cwd/route.test.ts | 48 +++++++++++++++++++ web/src/app/api/studio/cwd/route.ts | 41 +++++----------- web/src/app/api/studio/status/route.test.ts | 48 +++++++++++++++++++ web/src/app/api/studio/status/route.ts | 40 +++++----------- 8 files changed, 190 insertions(+), 66 deletions(-) create mode 100644 web/src/app/api/research/paperscool/sessions/[sessionId]/route.test.ts create mode 100644 web/src/app/api/studio/cwd/route.test.ts create mode 100644 web/src/app/api/studio/status/route.test.ts diff --git a/web/src/app/api/_utils/backend-proxy.test.ts b/web/src/app/api/_utils/backend-proxy.test.ts index eb28b393..f34ca82f 100644 --- a/web/src/app/api/_utils/backend-proxy.test.ts +++ b/web/src/app/api/_utils/backend-proxy.test.ts @@ -140,4 +140,36 @@ describe("backend proxy helpers", () => { expect(res.status).toBe(502) await expect(res.json()).resolves.toEqual({ detail: "Service unavailable" }) }) + + it("forwards cache directives to the upstream request", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + global.fetch = fetchMock as typeof fetch + + const req = new Request("https://localhost/api/research/paperscool/sessions/demo", { + method: "GET", + }) + const res = await proxyJson( + req, + "https://backend/api/research/paperscool/sessions/demo", + "GET", + { cache: "no-store" }, + ) + + expect(fetchMock).toHaveBeenCalledWith( + "https://backend/api/research/paperscool/sessions/demo", + { + method: "GET", + headers: { Accept: "application/json" }, + body: undefined, + cache: "no-store", + signal: expect.any(AbortSignal), + }, + ) + expect(res.status).toBe(200) + }) }) diff --git a/web/src/app/api/_utils/backend-proxy.ts b/web/src/app/api/_utils/backend-proxy.ts index 5d6ba43f..10a50ed8 100644 --- a/web/src/app/api/_utils/backend-proxy.ts +++ b/web/src/app/api/_utils/backend-proxy.ts @@ -11,6 +11,7 @@ type ProxyErrorContext = { type ProxyOptions = { accept?: string auth?: boolean + cache?: RequestCache contentType?: string onError?: (context: ProxyErrorContext) => Response timeoutMs?: number @@ -80,6 +81,7 @@ async function fetchUpstream( method, headers: await resolveHeaders(req, body, options), body, + cache: options.cache, signal: controller?.signal, }) } finally { diff --git a/web/src/app/api/research/paperscool/sessions/[sessionId]/route.test.ts b/web/src/app/api/research/paperscool/sessions/[sessionId]/route.test.ts new file mode 100644 index 00000000..bdeda42c --- /dev/null +++ b/web/src/app/api/research/paperscool/sessions/[sessionId]/route.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyJsonMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyJsonMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyJson: proxyJsonMock, +})) + +import { GET } from "./route" + +describe("paperscool session route", () => { + it("proxies the backend request without caching", async () => { + const req = new Request("http://localhost/api/research/paperscool/sessions/some/session") + proxyJsonMock.mockResolvedValueOnce(Response.json({ ok: true })) + + await GET(req, { params: Promise.resolve({ sessionId: "session/42" }) }) + + expect(proxyJsonMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/research/paperscool/sessions/session%2F42", + "GET", + { cache: "no-store" }, + ) + }) +}) diff --git a/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts b/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts index a7c51356..e0ff3fb0 100644 --- a/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts +++ b/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts @@ -1,17 +1,11 @@ -import { NextResponse } from "next/server" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -import { apiBaseUrl } from "@/app/api/research/_base" - -export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: string }> }) { +export async function GET(req: Request, ctx: { params: Promise<{ sessionId: string }> }) { const { sessionId } = await ctx.params - const upstream = await fetch( + return proxyJson( + req, `${apiBaseUrl()}/api/research/paperscool/sessions/${encodeURIComponent(sessionId)}`, + "GET", { cache: "no-store" }, ) - - const body = await upstream.text() - return new NextResponse(body, { - status: upstream.status, - headers: { "Content-Type": upstream.headers.get("content-type") || "application/json" }, - }) } diff --git a/web/src/app/api/studio/cwd/route.test.ts b/web/src/app/api/studio/cwd/route.test.ts new file mode 100644 index 00000000..9817e86e --- /dev/null +++ b/web/src/app/api/studio/cwd/route.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyJsonMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyJsonMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyJson: proxyJsonMock, +})) + +import { GET } from "./route" + +describe("studio cwd route", () => { + it("proxies the backend request with a fallback response", async () => { + const req = new Request("http://localhost/api/studio/cwd") + proxyJsonMock.mockResolvedValueOnce(Response.json({ cwd: "/workspace" })) + + await GET(req) + + expect(proxyJsonMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/studio/cwd", + "GET", + expect.objectContaining({ + onError: expect.any(Function), + }), + ) + + const options = vi.mocked(proxyJsonMock).mock.calls[0][3] + expect(options).toBeDefined() + + const fallback = options?.onError?.({ + error: new Error("offline"), + isTimeout: false, + upstreamUrl: "http://backend.example.com/api/studio/cwd", + }) + + expect(fallback).toBeInstanceOf(Response) + expect(fallback?.status).toBe(200) + await expect(fallback?.json()).resolves.toEqual({ + cwd: process.env.HOME || "/tmp", + source: "fallback", + error: "Failed to get working directory from backend", + }) + }) +}) diff --git a/web/src/app/api/studio/cwd/route.ts b/web/src/app/api/studio/cwd/route.ts index 80684cd1..10ea9da8 100644 --- a/web/src/app/api/studio/cwd/route.ts +++ b/web/src/app/api/studio/cwd/route.ts @@ -1,32 +1,17 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET() { - try { - const upstream = await fetch(`${apiBaseUrl()}/api/studio/cwd`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - - const data = await upstream.json() +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" - return Response.json(data, { - status: upstream.status, - }) - } catch (error) { - // Return a sensible default if backend is unavailable - return Response.json( - { - cwd: process.env.HOME || "/tmp", - source: "fallback", - error: "Failed to get working directory from backend", - }, - { status: 200 } - ) - } +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/studio/cwd`, "GET", { + onError: () => + Response.json( + { + cwd: process.env.HOME || "/tmp", + source: "fallback", + error: "Failed to get working directory from backend", + }, + { status: 200 }, + ), + }) } diff --git a/web/src/app/api/studio/status/route.test.ts b/web/src/app/api/studio/status/route.test.ts new file mode 100644 index 00000000..a07c2ff1 --- /dev/null +++ b/web/src/app/api/studio/status/route.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyJsonMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyJsonMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyJson: proxyJsonMock, +})) + +import { GET } from "./route" + +describe("studio status route", () => { + it("proxies the backend request with a fallback response", async () => { + const req = new Request("http://localhost/api/studio/status") + proxyJsonMock.mockResolvedValueOnce(Response.json({ claude_cli: true })) + + await GET(req) + + expect(proxyJsonMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/studio/status", + "GET", + expect.objectContaining({ + onError: expect.any(Function), + }), + ) + + const options = vi.mocked(proxyJsonMock).mock.calls[0][3] + expect(options).toBeDefined() + + const fallback = options?.onError?.({ + error: new Error("offline"), + isTimeout: false, + upstreamUrl: "http://backend.example.com/api/studio/status", + }) + + expect(fallback).toBeInstanceOf(Response) + expect(fallback?.status).toBe(500) + await expect(fallback?.json()).resolves.toEqual({ + claude_cli: false, + error: "Failed to check Claude CLI status", + fallback: "anthropic_api", + }) + }) +}) diff --git a/web/src/app/api/studio/status/route.ts b/web/src/app/api/studio/status/route.ts index 9c7c64de..b86820c9 100644 --- a/web/src/app/api/studio/status/route.ts +++ b/web/src/app/api/studio/status/route.ts @@ -1,31 +1,17 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET() { - try { - const upstream = await fetch(`${apiBaseUrl()}/api/studio/status`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - - const data = await upstream.json() +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" - return Response.json(data, { - status: upstream.status, - }) - } catch (error) { - return Response.json( - { - claude_cli: false, - error: "Failed to check Claude CLI status", - fallback: "anthropic_api" - }, - { status: 500 } - ) - } +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/studio/status`, "GET", { + onError: () => + Response.json( + { + claude_cli: false, + error: "Failed to check Claude CLI status", + fallback: "anthropic_api", + }, + { status: 500 }, + ), + }) }