From ebe6087e8f0cac0e8a89560f2d3a8b4d28182925 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:21:19 -0400 Subject: [PATCH] fix(server): return structured validation errors (#26457) --- .../instance/httpapi/middleware/error.ts | 30 ++++++++++++++++++- .../opencode/test/server/httpapi-sync.test.ts | 22 +++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 6f3c33a647..523b141a54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/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( diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index b85658ea1e..c4d2397afd 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/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 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 + expect(body.success).toBe(false) + expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) + }) })