diff --git a/web/src/app/api/_utils/backend-proxy.test.ts b/web/src/app/api/_utils/backend-proxy.test.ts index 0121637c..eb28b393 100644 --- a/web/src/app/api/_utils/backend-proxy.test.ts +++ b/web/src/app/api/_utils/backend-proxy.test.ts @@ -1,45 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -const { withBackendAuthMock } = vi.hoisted(() => ({ +const { backendBaseUrlMock, withBackendAuthMock } = vi.hoisted(() => ({ + backendBaseUrlMock: vi.fn(() => "https://backend.test"), withBackendAuthMock: vi.fn(), })) vi.mock("./auth-headers", () => ({ + backendBaseUrl: backendBaseUrlMock, withBackendAuth: withBackendAuthMock, })) -import { apiBaseUrl, proxyText } from "./backend-proxy" +import { apiBaseUrl, proxyJson, proxyText } from "./backend-proxy" describe("apiBaseUrl", () => { - const originalPaperbotApiBaseUrl = process.env.PAPERBOT_API_BASE_URL - - afterEach(() => { - if (originalPaperbotApiBaseUrl === undefined) { - delete process.env.PAPERBOT_API_BASE_URL - } else { - process.env.PAPERBOT_API_BASE_URL = originalPaperbotApiBaseUrl - } - }) - - it("uses PAPERBOT_API_BASE_URL when present", () => { - process.env.PAPERBOT_API_BASE_URL = "https://paperbot-api" - expect(apiBaseUrl()).toBe("https://paperbot-api") - }) - - it("falls back to localhost", () => { - delete process.env.PAPERBOT_API_BASE_URL - const fallback = new URL(apiBaseUrl()) - - expect(fallback.hostname).toBe("127.0.0.1") - expect(fallback.port).toBe("8000") + it("uses the shared backend base URL helper", () => { + expect(apiBaseUrl()).toBe("https://backend.test") + expect(backendBaseUrlMock).toHaveBeenCalledTimes(1) }) }) -describe("proxyText", () => { +describe("backend proxy helpers", () => { const originalFetch = global.fetch beforeEach(() => { vi.resetAllMocks() + backendBaseUrlMock.mockReturnValue("https://backend.test") }) afterEach(() => { @@ -62,12 +47,13 @@ describe("proxyText", () => { method: "GET", headers: { Accept: "application/json" }, body: undefined, + signal: expect.any(AbortSignal), }) expect(await res.text()).toBe(JSON.stringify({ ok: true })) expect(res.status).toBe(200) }) - it("adds backend auth headers for protected writes", async () => { + it("proxies JSON writes with backend auth when requested", async () => { withBackendAuthMock.mockResolvedValue({ Accept: "application/json", "Content-Type": "application/json", @@ -83,24 +69,75 @@ describe("proxyText", () => { global.fetch = fetchMock as typeof fetch const req = new Request("https://localhost/api/runbook/delete", { - method: "POST", + method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: "/workspace/demo" }), + body: JSON.stringify({ display_name: "PaperBot" }), }) - const res = await proxyText(req, "https://backend/api/runbook/delete", "POST", { + const res = await proxyJson(req, "https://backend/api/auth/me", "PATCH", { auth: true, }) expect(withBackendAuthMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith("https://backend/api/runbook/delete", { - method: "POST", + expect(fetchMock).toHaveBeenCalledWith("https://backend/api/auth/me", { + method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/json", authorization: "Bearer test-token", }, - body: JSON.stringify({ path: "/workspace/demo" }), + body: JSON.stringify({ display_name: "PaperBot" }), + signal: expect.any(AbortSignal), }) expect(res.status).toBe(202) }) + + it("returns an empty response when the upstream returns 204", async () => { + withBackendAuthMock.mockResolvedValue({ + Accept: "application/json", + authorization: "Bearer test-token", + }) + + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })) + global.fetch = fetchMock as typeof fetch + + const req = new Request("https://localhost/api/auth/me", { method: "DELETE" }) + const res = await proxyJson(req, "https://backend/api/auth/me", "DELETE", { + auth: true, + }) + + expect(fetchMock).toHaveBeenCalledWith("https://backend/api/auth/me", { + method: "DELETE", + headers: { + Accept: "application/json", + authorization: "Bearer test-token", + }, + body: undefined, + signal: expect.any(AbortSignal), + }) + expect(res.status).toBe(204) + expect(await res.text()).toBe("") + }) + + it("supports custom error handlers for upstream failures", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("boom") + }) + global.fetch = fetchMock as typeof fetch + + const res = await proxyJson( + new Request("https://localhost/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "paperbot@example.com" }), + }), + "https://backend/api/auth/login", + "POST", + { + onError: () => Response.json({ detail: "Service unavailable" }, { status: 502 }), + }, + ) + + expect(res.status).toBe(502) + await expect(res.json()).resolves.toEqual({ detail: "Service unavailable" }) + }) }) diff --git a/web/src/app/api/_utils/backend-proxy.ts b/web/src/app/api/_utils/backend-proxy.ts index 5e69ede6..5d6ba43f 100644 --- a/web/src/app/api/_utils/backend-proxy.ts +++ b/web/src/app/api/_utils/backend-proxy.ts @@ -1,45 +1,181 @@ -import { withBackendAuth } from "./auth-headers" +import { backendBaseUrl, withBackendAuth } from "./auth-headers" -type ProxyTextOptions = { +export type ProxyMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" + +type ProxyErrorContext = { + error: unknown + isTimeout: boolean + upstreamUrl: string +} + +type ProxyOptions = { accept?: string auth?: boolean + contentType?: string + onError?: (context: ProxyErrorContext) => Response + timeoutMs?: number +} + +type TextProxyOptions = ProxyOptions & { responseContentType?: string + responseHeaders?: HeadersInit } +const DEFAULT_TIMEOUT_MS = 120_000 + export function apiBaseUrl(): string { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" + return backendBaseUrl() +} + +export async function proxyJson( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: TextProxyOptions = {}, +): Promise { + return proxyText(req, upstreamUrl, method, { + accept: options.accept ?? "application/json", + responseContentType: options.responseContentType ?? "application/json", + ...options, + }) } export async function proxyText( req: Request, upstreamUrl: string, - method: string, - options: ProxyTextOptions = {}, + method: ProxyMethod, + options: TextProxyOptions = {}, +): Promise { + const requestOptions = { + accept: "application/json", + ...options, + } + + try { + const upstream = await fetchUpstream(req, upstreamUrl, method, requestOptions) + const text = await upstream.text() + + return buildTextResponse(text, upstream, { + responseContentType: requestOptions.responseContentType, + responseHeaders: requestOptions.responseHeaders, + }) + } catch (error) { + return handleProxyError(error, upstreamUrl, requestOptions.onError) + } +} + +async function fetchUpstream( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: ProxyOptions, ): Promise { - const normalizedMethod = method.toUpperCase() - const headers: Record = { - Accept: options.accept || "application/json", + const controller = options.timeoutMs === 0 ? null : new AbortController() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null + const body = await resolveBody(req, method) + + try { + return await fetch(upstreamUrl, { + method, + headers: await resolveHeaders(req, body, options), + body, + signal: controller?.signal, + }) + } finally { + if (timeout) { + clearTimeout(timeout) + } } +} - let body: string | undefined - if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") { - body = await req.text() - headers["Content-Type"] = req.headers.get("content-type") || "application/json" +async function resolveHeaders( + req: Request, + body: string | undefined, + options: ProxyOptions, +): Promise { + const headers: Record = {} + + if (options.accept) { + headers.Accept = options.accept } - const upstreamHeaders = options.auth ? await withBackendAuth(req, headers) : headers - const upstream = await fetch(upstreamUrl, { - method: normalizedMethod, - headers: upstreamHeaders, - body, - }) - const text = await upstream.text() + if (body !== undefined) { + headers["Content-Type"] = + options.contentType || req.headers.get("content-type") || "application/json" + } + + if (options.auth) { + return withBackendAuth(req, headers) + } + + return headers +} + +async function resolveBody( + req: Request, + method: ProxyMethod, +): Promise { + if (method === "GET" || method === "HEAD" || req.body === null) { + return undefined + } + + return req.text() +} + +function buildTextResponse( + text: string, + upstream: Response, + options: Pick, +): Response { + const headers = new Headers(options.responseHeaders) + headers.set("Cache-Control", "no-cache") + + const contentType = + upstream.headers.get("content-type") || options.responseContentType + + if (contentType && upstream.status !== 204 && text.length > 0) { + headers.set("Content-Type", contentType) + } + + if (upstream.status === 204 || text.length === 0) { + return new Response(null, { + status: upstream.status, + headers, + }) + } return new Response(text, { status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || options.responseContentType || "application/json", - "Cache-Control": "no-cache", - }, + headers, }) } + +function handleProxyError( + error: unknown, + upstreamUrl: string, + onError?: (context: ProxyErrorContext) => Response, +): Response { + const isTimeout = error instanceof Error && error.name === "AbortError" + const context = { + error, + isTimeout, + upstreamUrl, + } + + if (onError) { + return onError(context) + } + + const detail = error instanceof Error ? error.message : String(error) + + return Response.json( + { + detail: isTimeout + ? `Upstream API timed out: ${upstreamUrl}` + : `Upstream API unreachable: ${upstreamUrl}`, + error: detail, + }, + { status: 502 }, + ) +} diff --git a/web/src/app/api/auth/forgot-password/route.ts b/web/src/app/api/auth/forgot-password/route.ts index 7135b7de..1a54fdc0 100644 --- a/web/src/app/api/auth/forgot-password/route.ts +++ b/web/src/app/api/auth/forgot-password/route.ts @@ -1,13 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const res = await fetch(`${backendBaseUrl()}/api/auth/forgot-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/forgot-password`, "POST") } diff --git a/web/src/app/api/auth/login-check/route.ts b/web/src/app/api/auth/login-check/route.ts index 410c4bb0..357e7c66 100644 --- a/web/src/app/api/auth/login-check/route.ts +++ b/web/src/app/api/auth/login-check/route.ts @@ -1,19 +1,9 @@ export const runtime = "nodejs" -import { backendBaseUrl } from "../../_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const res = await fetch(`${backendBaseUrl()}/api/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - }).catch(() => null) - - if (!res) return Response.json({ detail: "Service unavailable" }, { status: 502 }) - const text = await res.text() - return new Response(text, { - status: res.status, - headers: { "Content-Type": "application/json" }, + return proxyJson(req, `${apiBaseUrl()}/api/auth/login`, "POST", { + onError: () => Response.json({ detail: "Service unavailable" }, { status: 502 }), }) } diff --git a/web/src/app/api/auth/me/change-password/route.ts b/web/src/app/api/auth/me/change-password/route.ts index e593324f..01235d59 100644 --- a/web/src/app/api/auth/me/change-password/route.ts +++ b/web/src/app/api/auth/me/change-password/route.ts @@ -1,14 +1,7 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl, withBackendAuth } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const headers = await withBackendAuth(req, { "Content-Type": "application/json" }) - const res = await fetch(`${backendBaseUrl()}/api/auth/me/change-password`, { - method: "POST", - headers, - body: JSON.stringify(body), +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me/change-password`, "POST", { + auth: true, }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) } diff --git a/web/src/app/api/auth/me/route.ts b/web/src/app/api/auth/me/route.ts index f74601f2..fe441a69 100644 --- a/web/src/app/api/auth/me/route.ts +++ b/web/src/app/api/auth/me/route.ts @@ -1,27 +1,13 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl, withBackendAuth } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function GET(req: NextRequest) { - const headers = await withBackendAuth(req) - const res = await fetch(`${backendBaseUrl()}/api/auth/me`, { headers }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me`, "GET", { auth: true }) } -export async function PATCH(req: NextRequest) { - const body = await req.json() - const headers = await withBackendAuth(req, { "Content-Type": "application/json" }) - const res = await fetch(`${backendBaseUrl()}/api/auth/me`, { - method: "PATCH", - headers, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function PATCH(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me`, "PATCH", { auth: true }) } -export async function DELETE(req: NextRequest) { - const headers = await withBackendAuth(req) - const res = await fetch(`${backendBaseUrl()}/api/auth/me`, { method: "DELETE", headers }) - return new NextResponse(null, { status: res.status }) +export async function DELETE(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me`, "DELETE", { auth: true }) } diff --git a/web/src/app/api/auth/register/route.ts b/web/src/app/api/auth/register/route.ts index bfea4723..51f069c2 100644 --- a/web/src/app/api/auth/register/route.ts +++ b/web/src/app/api/auth/register/route.ts @@ -1,13 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const res = await fetch(`${backendBaseUrl()}/api/auth/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/register`, "POST") } diff --git a/web/src/app/api/auth/reset-password/route.ts b/web/src/app/api/auth/reset-password/route.ts index 25e68124..8bc8e77e 100644 --- a/web/src/app/api/auth/reset-password/route.ts +++ b/web/src/app/api/auth/reset-password/route.ts @@ -1,13 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const res = await fetch(`${backendBaseUrl()}/api/auth/reset-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/reset-password`, "POST") } diff --git a/web/src/app/api/sandbox/status/route.ts b/web/src/app/api/sandbox/status/route.ts index 9dc74c85..fb9f92cd 100644 --- a/web/src/app/api/sandbox/status/route.ts +++ b/web/src/app/api/sandbox/status/route.ts @@ -1,18 +1,7 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET() { - const upstream = await fetch(`${apiBaseUrl()}/api/sandbox/status`, { method: "GET" }) - const text = await upstream.text() +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/sandbox/status`, "GET") }