62ff152642
Effect's default Respondable for HttpApiSchemaError returns 400 with an empty body. The renderer / SDK / curl get nothing actionable — just "GET /url → 400 Bad Request: (empty response body)". When a real user hit this on Windows yesterday (corrupted DB row → schema rejected the response), we spent ~an hour reverse-engineering the cause from the URL alone. PR #26457 previously tried to surface the reason in a structured body ({data, errors, success}) and got reverted in #26546 because some plugins broke. The proximate cause was the SDK throwing raw POJOs to plugins instead of Errors, which has since been fixed by `wrapClientError` (`50dcc4f1a`). Use the same NamedError shape every other 4xx/5xx in this API already uses (e.g. NotFoundError 404): {"name":"BadRequest","data":{"message":"...","kind":"Body"}} The SDK's wrapClientError extracts data.message automatically, so any caller that handles existing 404 NotFoundError bodies handles this identically — no new contract. Verified end-to-end: BEFORE status: 400 body: "" SDK Error.message: opencode server GET .../message?... → 400: (empty response body) AFTER status: 400 body: {"name":"BadRequest","data":{"message":"Expected number, got null at [0][\"parts\"][0][\"tokens\"][\"output\"]","kind":"Body"}} SDK Error.message: Expected number, got null at [0]["parts"][0]["tokens"]["output"] Includes a regression test that asserts the body shape on a real Body schema rejection (POST /sync/history with invalid aggregate).
52 lines
2.2 KiB
TypeScript
52 lines
2.2 KiB
TypeScript
/**
|
|
* Regression: a schema rejection used to come back as `400` with an empty
|
|
* body, leaving the renderer / SDK / curl with no way to tell which field
|
|
* failed. The schemaErrorLayer now returns a NamedError-shaped JSON body
|
|
* (same shape as 404 NotFoundError) so callers see the actual reason.
|
|
*/
|
|
import { afterEach, describe, expect } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { Server } from "../../src/server/server"
|
|
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
|
import { resetDatabase } from "../fixture/db"
|
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
import { it } from "../lib/effect"
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
await resetDatabase()
|
|
})
|
|
|
|
describe("schema-rejection wire shape", () => {
|
|
it.live(
|
|
"Body schema rejection returns NamedError-shaped JSON, not empty",
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
// POST /sync/history with `aggregate: -1` is an invalid Body shape
|
|
// (aggregate is a NamedString) and triggers the framework's
|
|
// HttpApiSchemaError on the Body kind.
|
|
const res = yield* Effect.promise(async () =>
|
|
Server.Default().app.request(SyncPaths.history, {
|
|
method: "POST",
|
|
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
|
body: JSON.stringify({ aggregate: -1 }),
|
|
}),
|
|
)
|
|
const body = yield* Effect.promise(async () => res.text())
|
|
expect(res.status).toBe(400)
|
|
expect(res.headers.get("content-type") ?? "").toContain("application/json")
|
|
const parsed = JSON.parse(body) as { name?: string; data?: { message?: string; kind?: string } }
|
|
expect(parsed.name).toBe("BadRequest")
|
|
expect(typeof parsed.data?.message).toBe("string")
|
|
expect(parsed.data?.message?.length ?? 0).toBeGreaterThan(0)
|
|
expect(parsed.data?.kind).toMatch(/^(Body|Payload)$/)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
})
|