Spaces:
Runtime error
Runtime error
| /** | |
| * API: OpenAPI "Try It" Proxy | |
| * POST — forwards a request to a local endpoint and returns the result | |
| */ | |
| import { z } from "zod"; | |
| import { NextRequest, NextResponse } from "next/server"; | |
| import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; | |
| import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; | |
| import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; | |
| const ALLOWED_TRY_PATH_PREFIXES = ["/api/", "/v1/", "/v1beta/", "/a2a", "/.well-known/agent.json"]; | |
| const BLOCKED_FORWARD_HEADERS = new Set([ | |
| "connection", | |
| "content-length", | |
| "cookie", | |
| "host", | |
| "keep-alive", | |
| "proxy-authenticate", | |
| "proxy-authorization", | |
| "te", | |
| "trailer", | |
| "transfer-encoding", | |
| "upgrade", | |
| "x-forwarded-for", | |
| "x-forwarded-host", | |
| "x-forwarded-proto", | |
| ]); | |
| const tryRequestSchema = z.object({ | |
| method: z | |
| .enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) | |
| .optional() | |
| .default("GET"), | |
| path: z | |
| .string() | |
| .min(1, "Path is required") | |
| .startsWith("/", "Path must start with /") | |
| .refine((value) => !value.startsWith("//"), "Path must be a same-origin path") | |
| .refine( | |
| (value) => ALLOWED_TRY_PATH_PREFIXES.some((prefix) => value.startsWith(prefix)), | |
| "Path must target an OmniRoute API endpoint" | |
| ), | |
| headers: z.record(z.string(), z.string()).optional().default({}), | |
| body: z.any().optional(), | |
| }); | |
| function getRequestOrigin(request: NextRequest) { | |
| return request.nextUrl?.origin || new URL(request.url).origin; | |
| } | |
| function buildForwardHeaders(headers: Record<string, string>) { | |
| const forwardHeaders: Record<string, string> = {}; | |
| for (const [key, value] of Object.entries(headers)) { | |
| const normalizedKey = key.trim().toLowerCase(); | |
| if (!normalizedKey || BLOCKED_FORWARD_HEADERS.has(normalizedKey)) continue; | |
| forwardHeaders[key] = value; | |
| } | |
| return forwardHeaders; | |
| } | |
| export async function POST(request: NextRequest) { | |
| const authError = await requireManagementAuth(request); | |
| if (authError) return authError; | |
| try { | |
| const rawBody = await request.json(); | |
| const validation = validateBody(tryRequestSchema, rawBody); | |
| if (isValidationFailure(validation)) { | |
| return NextResponse.json({ error: validation.error }, { status: 400 }); | |
| } | |
| const { method, path, headers, body: reqBody } = validation.data; | |
| const origin = getRequestOrigin(request); | |
| const targetUrl = new URL(path, origin); | |
| if (targetUrl.origin !== origin) { | |
| return NextResponse.json({ error: "Path must be same-origin" }, { status: 400 }); | |
| } | |
| const start = performance.now(); | |
| // Forward cookies/auth from the original request | |
| const forwardHeaders = buildForwardHeaders(headers as Record<string, string>); | |
| // Forward auth from the dashboard session | |
| const cookie = request.headers.get("cookie"); | |
| if (cookie && !forwardHeaders["Cookie"]) { | |
| forwardHeaders["Cookie"] = cookie; | |
| } | |
| if (reqBody && !forwardHeaders["Content-Type"]) { | |
| forwardHeaders["Content-Type"] = "application/json"; | |
| } | |
| const fetchOptions: RequestInit = { | |
| method: method.toUpperCase(), | |
| headers: forwardHeaders, | |
| }; | |
| if (reqBody && method.toUpperCase() !== "GET") { | |
| fetchOptions.body = typeof reqBody === "string" ? reqBody : JSON.stringify(reqBody); | |
| } | |
| const res = await fetch(targetUrl, fetchOptions); | |
| const latencyMs = Math.round(performance.now() - start); | |
| // Read response | |
| const contentType = res.headers.get("content-type") || ""; | |
| let responseBody: any; | |
| if (contentType.includes("application/json")) { | |
| responseBody = await res.json(); | |
| } else { | |
| const text = await res.text(); | |
| // Truncate very large responses | |
| responseBody = text.length > 10000 ? text.slice(0, 10000) + "\n... (truncated)" : text; | |
| } | |
| // Collect response headers | |
| const responseHeaders: Record<string, string> = {}; | |
| res.headers.forEach((value, key) => { | |
| responseHeaders[key] = value; | |
| }); | |
| return NextResponse.json({ | |
| status: res.status, | |
| statusText: res.statusText, | |
| headers: responseHeaders, | |
| body: responseBody, | |
| latencyMs, | |
| contentType, | |
| }); | |
| } catch (error: any) { | |
| return NextResponse.json( | |
| { | |
| status: 0, | |
| statusText: "Network Error", | |
| headers: {}, | |
| body: { error: sanitizeErrorMessage(error) || "Request failed" }, | |
| latencyMs: 0, | |
| contentType: "application/json", | |
| }, | |
| { status: 200 } // Return 200 so the frontend can display the error | |
| ); | |
| } | |
| } | |