瀏覽代碼

fix(server): return structured validation errors (#26457)

Kit Langton 4 月之前
父節點
當前提交
ebe6087e8f

+ 29 - 1
packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

@@ -6,13 +6,41 @@ import { NamedError } from "@opencode-ai/core/util/error"
 import * as Log from "@opencode-ai/core/util/log"
 import { Cause, Effect } from "effect"
 import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
+import { HttpApiError } from "effect/unstable/httpapi"
+import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError"
 
 const log = Log.create({ service: "server" })
 
+function badRequestResponse() {
+  return HttpServerResponse.jsonUnsafe(
+    {
+      data: {},
+      errors: [],
+      success: false,
+    },
+    { status: 400 },
+  )
+}
+
+function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) {
+  if (response.status !== 400 || response.body._tag !== "Empty") return response
+  return badRequestResponse()
+}
+
 // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
 export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
   effect.pipe(
+    Effect.catch((error) => {
+      if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse())
+      return Effect.fail(error)
+    }),
+    Effect.map(normalizeEmptyBadRequest),
     Effect.catchCause((cause) => {
+      const schemaError = cause.reasons
+        .filter(Cause.isDieReason)
+        .find((reason) => HttpApiSchemaError.is(reason.defect))
+      if (schemaError) return Effect.succeed(badRequestResponse())
+
       const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
         if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
         if (HttpServerError.isHttpServerError(reason.defect)) return false
@@ -35,7 +63,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
               return 500
             }),
           }),
-        )
+)
       }
       if (error instanceof Session.BusyError) {
         return Effect.succeed(

+ 21 - 1
packages/opencode/test/server/httpapi-sync.test.ts

@@ -1,10 +1,11 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { Effect } from "effect"
+import { Context, Effect } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Instance } from "../../src/project/instance"
 import { WithInstance } from "../../src/project/with-instance"
 import { Server } from "../../src/server/server"
 import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { Session } from "@/session/session"
 import * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
@@ -14,6 +15,7 @@ void Log.init({ print: false })
 
 const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
 const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
+const context = Context.empty() as Context.Context<unknown>
 
 function app(httpapi = true) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
@@ -128,4 +130,22 @@ describe("sync HttpApi", () => {
       expect(httpapi.status).toBe(400)
     }
   })
+
+  test("returns structured validation errors", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const response = await ExperimentalHttpApiServer.webHandler().handler(
+      new Request(`http://localhost${SyncPaths.history}`, {
+        method: "POST",
+        headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
+        body: JSON.stringify({ aggregate: -1 }),
+      }),
+      context,
+    )
+
+    expect(response.status).toBe(400)
+    expect(response.headers.get("content-type") ?? "").toContain("application/json")
+    const body = (await response.json()) as Record<string, unknown>
+    expect(body.success).toBe(false)
+    expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
+  })
 })