-
Notifications
You must be signed in to change notification settings - Fork 134
fix: surface a clear error on non-JSON API responses instead of crashing #1093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ralphstodomingo
wants to merge
9
commits into
main
Choose a base branch
from
fix/sdk-client-non-json-response
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
8b435f0
fix: surface a clear error on non-JSON API responses instead of crashing
095cff2
fix: preserve the original parse error as the Error cause
b9fe7e2
fix: ship the json guard through codegen; widen coverage; traceable e…
bcdbe47
chore: mark the build.ts json-guard step as an altimate change
ec5d602
fix: query-free request identity; reproducible guard block; v1 live c…
4b5335d
ci: restore the sdk gen tree after the reproducibility check
e6d0732
fix(sdk): post-codegen patches assert one site; cause.body only for m…
21f6529
fix(sdk): cause.body is the page title only; CI whitelists the tsc fa…
bcb8d15
fix(sdk): drop a page title that echoes the request target from cause…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| import { describe, expect, it, beforeAll, afterAll } from "bun:test" | ||
| import path from "path" | ||
| import { fileURLToPath } from "url" | ||
| import { createClient as createV2Client } from "../../sdk/js/src/v2/gen/client/client.gen" | ||
| import { createClient as createV1Client } from "../../sdk/js/src/gen/client/client.gen" | ||
| import { createOpencodeClient } from "../../sdk/js/src/v2/client" | ||
| import { errorData } from "../src/util/error" | ||
|
|
||
| // The JSON-parse guard lives in GENERATED code that `script/build.ts` wipes | ||
| // (clean: true) and re-applies on every release build. These tests pin the | ||
| // chain end to end: the needle still matches the generator TEMPLATE on disk | ||
| // (the real drift detector), both copies carry the guard, build.ts carries | ||
| // the re-apply step, and live-server tests drive the actual failure shapes | ||
| // against BOTH clients (v1 is the root `@opencode-ai/sdk` export plugins use). | ||
|
|
||
| const sdk = fileURLToPath(new URL("../../sdk/js/", import.meta.url)) | ||
| const read = (rel: string) => Bun.file(path.join(sdk, rel)).text() | ||
|
|
||
| describe("sdk json guard — codegen drift", () => { | ||
| it("the needle still matches @hey-api/client-fetch's template on disk", async () => { | ||
| // build.ts throws mid-release if this stops matching; catching it here | ||
| // moves the failure to CI (resolved through the sdk package root: the | ||
| // generator exports only ".", "./internal" and "./package.json") | ||
| const root = path.dirname(require.resolve("@hey-api/openapi-ts/package.json", { paths: [sdk] })) | ||
| const tpl = await Bun.file(path.join(root, "dist/clients/fetch/client.ts")).text() | ||
| // exactly one site: build.ts patches the first string match only, and | ||
| // asserts the count — a second site in a future template must fail here | ||
| expect(tpl.split(" data = text ? JSON.parse(text) : {};").length - 1).toBe(1) | ||
| }) | ||
|
|
||
| it("build.ts pins the needle literal and re-applies the guard", async () => { | ||
| const build = await read("script/build.ts") | ||
| expect(build).toContain('const jsonGuardNeedle = " data = text ? JSON.parse(text) : {};"') | ||
| expect(build).toContain("post-codegen patch expects exactly one site") | ||
| expect(build).toContain("but the body was not JSON") | ||
| }) | ||
|
|
||
| it("both generated clients carry the guard", async () => { | ||
| for (const rel of ["src/gen/client/client.gen.ts", "src/v2/gen/client/client.gen.ts"]) { | ||
| const src = await read(rel) | ||
| expect(src).toContain("guard JSON parse against non-JSON") | ||
| expect(src).toContain("but the body was not JSON") | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| describe("sdk json guard — live failure shapes", () => { | ||
| let server: ReturnType<typeof Bun.serve> | ||
| let base: string | ||
| const html = "<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head><body>502 Bad Gateway</body></html>" | ||
|
|
||
| beforeAll(() => { | ||
| server = Bun.serve({ | ||
| port: 0, | ||
| fetch(req) { | ||
| const p = new URL(req.url).pathname | ||
| if (p.endsWith("/lying-proxy")) | ||
| return new Response(html, { status: 200, headers: { "content-type": "application/json" } }) | ||
| if (p.endsWith("/echo-page")) | ||
| // an Express-style page echoes the request target, query included | ||
| return new Response(`<!DOCTYPE html><html><body><pre>Cannot GET ${new URL(req.url).pathname}${new URL(req.url).search}</pre></body></html>`, { | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| }) | ||
| if (p.endsWith("/echo-title")) | ||
| // a CDN/proxy page that renders the request target in its <title> | ||
| return new Response(`<!DOCTYPE html><html><head><title>Page not found at ${new URL(req.url).pathname}${new URL(req.url).search}</title></head><body>404</body></html>`, { | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| }) | ||
| if (p.endsWith("/echo-title-encoded")) | ||
| return new Response(`<!DOCTYPE html><html><head><title>Not found: ${encodeURIComponent(new URL(req.url).pathname + new URL(req.url).search)}</title></head><body>404</body></html>`, { | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| }) | ||
| if (p.endsWith("/truncated-json")) | ||
| return new Response('{"token":"SENTINEL_SECRET_VALUE","more":', { | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| }) | ||
| if (p.endsWith("/plain-text")) | ||
| return new Response("just text", { status: 200, headers: { "content-type": "text/plain" } }) | ||
| if (p.endsWith("/empty-chunked")) { | ||
| // a 200 with an empty streamed body and no Content-Length reaches | ||
| // the json switch arm (the 204 / Content-Length:0 early return | ||
| // does not cover it) | ||
| const body = new ReadableStream({ start(c) { c.close() } }) | ||
| return new Response(body, { status: 200, headers: { "content-type": "application/json" } }) | ||
| } | ||
| return new Response(html, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }) | ||
| }, | ||
| }) | ||
| base = `http://localhost:${server.port}` | ||
| }) | ||
| afterAll(() => server.stop(true)) | ||
|
|
||
| for (const [name, make] of [["v2", createV2Client], ["v1", createV1Client]] as const) { | ||
| it(`${name}: HTML mislabeled as application/json rejects with a traceable, query-free error`, async () => { | ||
| const client = make({ baseUrl: base }) | ||
| const err = await client | ||
| .get({ url: "/lying-proxy", query: { directory: "/Users/jdoe/secret-project" } }) | ||
| .then(() => null) | ||
| .catch((e: unknown) => e as Error & { cause?: { body?: string } }) | ||
| expect(err).not.toBeNull() | ||
| expect(err!.message).toContain("but the body was not JSON") | ||
| expect(err!.message).toContain("GET /lying-proxy") | ||
| expect(err!.message).not.toContain("directory=") | ||
| expect(err!.message).not.toContain("secret-project") | ||
| expect(err!.message).toContain("content-type application/json") | ||
| expect(err!.cause?.body).toContain("502 Bad Gateway") | ||
| // the markup case keeps its diagnostic through the repo's error serializer | ||
| expect(JSON.stringify(errorData(err))).toContain("502 Bad Gateway") | ||
| }) | ||
|
|
||
| it(`${name}: a page that echoes the request URL contributes nothing but its title`, async () => { | ||
| const client = make({ baseUrl: base }) | ||
| const err = await client | ||
| .get({ url: "/echo-page", query: { directory: "/Users/jdoe/secret-project" } }) | ||
| .then(() => null) | ||
| .catch((e: unknown) => e as Error & { cause?: { body?: string } }) | ||
| expect(err).not.toBeNull() | ||
| expect(err!.message).toContain("but the body was not JSON") | ||
| expect(err!.cause?.body).toBeUndefined() | ||
| expect(JSON.stringify(errorData(err))).not.toContain("secret-project") | ||
| expect(JSON.stringify(errorData(err))).not.toContain("directory=") | ||
| }) | ||
|
|
||
| it(`${name}: a <title> that echoes the request target is dropped, raw or percent-encoded`, async () => { | ||
| const client = make({ baseUrl: base }) | ||
| for (const url of ["/echo-title", "/echo-title-encoded"]) { | ||
| const err = await client | ||
| .get({ url, query: { directory: "/Users/jdoe/secret-project" } }) | ||
| .then(() => null) | ||
| .catch((e: unknown) => e as Error & { cause?: { body?: string } }) | ||
| expect(err).not.toBeNull() | ||
| expect(err!.cause?.body).toBeUndefined() | ||
| expect(JSON.stringify(errorData(err))).not.toContain("secret-project") | ||
| expect(JSON.stringify(errorData(err))).not.toContain("directory") | ||
| } | ||
| }) | ||
|
|
||
| it(`${name}: a malformed REAL JSON body never reaches serialized error data`, async () => { | ||
| const client = make({ baseUrl: base }) | ||
| const err = await client | ||
| .get({ url: "/truncated-json" }) | ||
| .then(() => null) | ||
| .catch((e: unknown) => e as Error & { cause?: { body?: string } }) | ||
| expect(err).not.toBeNull() | ||
| expect(err!.message).toContain("but the body was not JSON") | ||
| expect(err!.cause?.body).toBeUndefined() | ||
| // util/error.ts serializes `cause` into structured logs and stderr | ||
| expect(JSON.stringify(errorData(err))).not.toContain("SENTINEL_SECRET_VALUE") | ||
| expect(JSON.stringify(err!.cause)).not.toContain("SENTINEL_SECRET_VALUE") | ||
| }) | ||
| } | ||
|
|
||
| it("v1: parseAs text still dispatches through the split switch", async () => { | ||
| const client = createV1Client({ baseUrl: base }) | ||
| const res = await client.get({ url: "/plain-text", parseAs: "text" }) | ||
| expect(res.data).toBe("just text") | ||
| }) | ||
|
|
||
| it("v1: a chunked empty 200 yields {} (declared alignment with v2; was SyntaxError)", async () => { | ||
| const client = createV1Client({ baseUrl: base }) | ||
| const res = await client.get({ url: "/empty-chunked" }) | ||
| expect(res.data).toEqual({}) | ||
| }) | ||
|
|
||
| it("honestly-labeled text/html (with charset) rejects at the v2 interceptor", async () => { | ||
| const oc = createOpencodeClient({ baseUrl: base }) | ||
| const err = await oc.app | ||
| .log({ service: "t", level: "info", message: "x" }) | ||
| .then(() => null) | ||
| .catch((e: unknown) => e as Error) | ||
| expect(err).not.toBeNull() | ||
| expect(String(err)).toContain("text/html") | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.