Skip to content
Merged
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
32 changes: 32 additions & 0 deletions web/src/app/api/_utils/backend-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,36 @@
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

Check warning on line 151 in web/src/app/api/_utils/backend-proxy.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwVeQxP-CYkGMXfCLf&open=AZzwVeQxP-CYkGMXfCLf&pullRequest=408

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)
})
})
2 changes: 2 additions & 0 deletions web/src/app/api/_utils/backend-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type ProxyErrorContext = {
type ProxyOptions = {
accept?: string
auth?: boolean
cache?: RequestCache
contentType?: string
onError?: (context: ProxyErrorContext) => Response
timeoutMs?: number
Expand Down Expand Up @@ -80,6 +81,7 @@ async function fetchUpstream(
method,
headers: await resolveHeaders(req, body, options),
body,
cache: options.cache,
signal: controller?.signal,
})
} finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -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" },
)
})
})
Original file line number Diff line number Diff line change
@@ -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" },
})
}
48 changes: 48 additions & 0 deletions web/src/app/api/studio/cwd/route.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
41 changes: 13 additions & 28 deletions web/src/app/api/studio/cwd/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
),
Comment on lines +7 to +15

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better observability and easier debugging, it would be beneficial to log the error details provided by the onError context. The original implementation didn't log the error, but since you're refactoring to a more robust proxy helper, this is a good opportunity to add logging.

    onError: (context) => {
      console.error("Studio CWD proxy failed:", context.error)
      return Response.json(
        {
          cwd: process.env.HOME || "/tmp",
          source: "fallback",
          error: "Failed to get working directory from backend",
        },
        { status: 200 },
      )
    },

})
}
48 changes: 48 additions & 0 deletions web/src/app/api/studio/status/route.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
40 changes: 13 additions & 27 deletions web/src/app/api/studio/status/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
),
Comment on lines +7 to +15

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the cwd route, it would be beneficial for observability to log the error details provided by the onError context here. This will help in debugging issues with the upstream service.

    onError: (context) => {
      console.error("Studio status proxy failed:", context.error)
      return Response.json(
        {
          claude_cli: false,
          error: "Failed to check Claude CLI status",
          fallback: "anthropic_api",
        },
        { status: 500 },
      )
    },

})
}
Loading