Skip to content

Commit ebe6087

Browse files
authored
fix(server): return structured validation errors (anomalyco#26457)
1 parent dc978cb commit ebe6087

2 files changed

Lines changed: 50 additions & 2 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,41 @@ import { NamedError } from "@opencode-ai/core/util/error"
66
import * as Log from "@opencode-ai/core/util/log"
77
import { Cause, Effect } from "effect"
88
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
9+
import { HttpApiError } from "effect/unstable/httpapi"
10+
import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError"
911

1012
const log = Log.create({ service: "server" })
1113

14+
function badRequestResponse() {
15+
return HttpServerResponse.jsonUnsafe(
16+
{
17+
data: {},
18+
errors: [],
19+
success: false,
20+
},
21+
{ status: 400 },
22+
)
23+
}
24+
25+
function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) {
26+
if (response.status !== 400 || response.body._tag !== "Empty") return response
27+
return badRequestResponse()
28+
}
29+
1230
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
1331
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
1432
effect.pipe(
33+
Effect.catch((error) => {
34+
if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse())
35+
return Effect.fail(error)
36+
}),
37+
Effect.map(normalizeEmptyBadRequest),
1538
Effect.catchCause((cause) => {
39+
const schemaError = cause.reasons
40+
.filter(Cause.isDieReason)
41+
.find((reason) => HttpApiSchemaError.is(reason.defect))
42+
if (schemaError) return Effect.succeed(badRequestResponse())
43+
1644
const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
1745
if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
1846
if (HttpServerError.isHttpServerError(reason.defect)) return false
@@ -35,7 +63,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
3563
return 500
3664
}),
3765
}),
38-
)
66+
)
3967
}
4068
if (error instanceof Session.BusyError) {
4169
return Effect.succeed(

packages/opencode/test/server/httpapi-sync.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
2-
import { Effect } from "effect"
2+
import { Context, Effect } from "effect"
33
import { Flag } from "@opencode-ai/core/flag/flag"
44
import { Instance } from "../../src/project/instance"
55
import { WithInstance } from "../../src/project/with-instance"
66
import { Server } from "../../src/server/server"
77
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
8+
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
89
import { Session } from "@/session/session"
910
import * as Log from "@opencode-ai/core/util/log"
1011
import { resetDatabase } from "../fixture/db"
@@ -14,6 +15,7 @@ void Log.init({ print: false })
1415

1516
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
1617
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
18+
const context = Context.empty() as Context.Context<unknown>
1719

1820
function app(httpapi = true) {
1921
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
@@ -128,4 +130,22 @@ describe("sync HttpApi", () => {
128130
expect(httpapi.status).toBe(400)
129131
}
130132
})
133+
134+
test("returns structured validation errors", async () => {
135+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
136+
const response = await ExperimentalHttpApiServer.webHandler().handler(
137+
new Request(`http://localhost${SyncPaths.history}`, {
138+
method: "POST",
139+
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
140+
body: JSON.stringify({ aggregate: -1 }),
141+
}),
142+
context,
143+
)
144+
145+
expect(response.status).toBe(400)
146+
expect(response.headers.get("content-type") ?? "").toContain("application/json")
147+
const body = (await response.json()) as Record<string, unknown>
148+
expect(body.success).toBe(false)
149+
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
150+
})
131151
})

0 commit comments

Comments
 (0)