diff --git a/src/legacy/helpers/runRequest.ts b/src/legacy/helpers/runRequest.ts index 0791f0b83..e568e8c51 100644 --- a/src/legacy/helpers/runRequest.ts +++ b/src/legacy/helpers/runRequest.ts @@ -71,12 +71,16 @@ export const runRequest = async (input: Input): Promise (executionResult } } -const parseResultFromResponse = async (response: Response, jsonSerializer: JsonSerializer) => { - const contentType = response.headers.get(CONTENT_TYPE_HEADER) - const text = await response.text() +const parseResultFromText = (text: string, contentType: string | null, jsonSerializer: JsonSerializer) => { if (contentType && isGraphQLContentType(contentType)) { return parseGraphQLExecutionResult(jsonSerializer.parse(text)) } else { diff --git a/src/legacy/helpers/types.ts b/src/legacy/helpers/types.ts index 10059f7fc..c6d7b7fa1 100644 --- a/src/legacy/helpers/types.ts +++ b/src/legacy/helpers/types.ts @@ -40,6 +40,12 @@ export interface GraphQLResponse { errors?: GraphQLError[] extensions?: unknown status: number + headers: Headers + /** + * The response body text. Useful for debugging non-GraphQL responses + * (e.g., 401/403 errors that return plain JSON instead of GraphQL). + */ + body: string [key: string]: unknown } @@ -62,6 +68,11 @@ export type TypedDocumentString<$Result, $Variables> = String & DocumentTypeDeco export interface GraphQLClientResponse { status: number headers: Headers + /** + * The response body text. Useful for debugging non-GraphQL responses + * (e.g., 401/403 errors that return plain JSON instead of GraphQL). + */ + body: string data: Data extensions?: unknown errors?: GraphQLError[] diff --git a/tests/legacy/batching.test.ts b/tests/legacy/batching.test.ts index 255ee3fb2..59c82b5d4 100644 --- a/tests/legacy/batching.test.ts +++ b/tests/legacy/batching.test.ts @@ -23,7 +23,7 @@ test(`minimal double query`, async () => { test(`basic error`, async () => { mockServer.res({ body: [{ errors }] }) await expect(batchRequests(mockServer.url, [{ document: `x` }])).rejects.toMatchInlineSnapshot( - `[Error: GraphQL Error (Code: 200): {"response":{"0":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{}},"request":{"query":["x"],"variables":[null]}}]`, + `[Error: GraphQL Error (Code: 200): {"response":{"0":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{},"body":"[{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}]"},"request":{"query":["x"],"variables":[null]}}]`, ) }) @@ -34,7 +34,7 @@ test(`successful query with another which make an error`, async () => { await expect( batchRequests(mockServer.url, [{ document: `{ me { id } }` }, { document: `x` }]), ).rejects.toMatchInlineSnapshot( - `[Error: GraphQL Error (Code: 200): {"response":{"0":{"data":{"me":{"id":"some-id"}}},"1":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{}},"request":{"query":["{ me { id } }","x"],"variables":[null,null]}}]`, + `[Error: GraphQL Error (Code: 200): {"response":{"0":{"data":{"me":{"id":"some-id"}}},"1":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{},"body":"[{\\"data\\":{\\"me\\":{\\"id\\":\\"some-id\\"}}},{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}]"},"request":{"query":["{ me { id } }","x"],"variables":[null,null]}}]`, ) }) diff --git a/tests/legacy/general.test.ts b/tests/legacy/general.test.ts index 9374b5de8..af32ac6bd 100644 --- a/tests/legacy/general.test.ts +++ b/tests/legacy/general.test.ts @@ -15,12 +15,12 @@ test(`minimal query`, async () => { test(`minimal raw query`, async () => { const mockRes = ctx.res({ body: { data, extensions: { version: `1` } } }).spec.body! - const { headers: _, ...result } = await rawRequest(ctx.url, `{ me { id } }`) + const { headers: _, body: __, ...result } = await rawRequest(ctx.url, `{ me { id } }`) expect(result).toEqual({ data: mockRes.data, extensions: mockRes.extensions, status: 200 }) }) test(`minimal raw query with response headers`, async () => { - const { headers: reqHeaders, body } = ctx.res({ + const { headers: reqHeaders, body: mockBody } = ctx.res({ headers: { 'Content-Type': `application/json`, 'X-Custom-Header': `test-custom-header`, @@ -31,8 +31,8 @@ test(`minimal raw query with response headers`, async () => { }, }).spec - const { headers, ...result } = await rawRequest(ctx.url, `{ me { id } }`) - expect(result).toEqual({ ...body, status: 200 }) + const { headers, body: _, ...result } = await rawRequest(ctx.url, `{ me { id } }`) + expect(result).toEqual({ ...mockBody, status: 200 }) expect(headers.get(`X-Custom-Header`)).toEqual(reqHeaders![`X-Custom-Header`]) }) @@ -41,7 +41,7 @@ test(`basic error`, async () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const res = await request(ctx.url, `x`).catch((x: unknown) => x) expect(res).toMatchInlineSnapshot( - `[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`, + `[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{},"body":"{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}"},"request":{"query":"x"}}]`, ) }) @@ -49,7 +49,7 @@ test(`basic error with raw request`, async () => { ctx.res({ body: { errors } }) const res: unknown = await rawRequest(ctx.url, `x`).catch((x: unknown) => x) expect(res).toMatchInlineSnapshot( - `[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`, + `[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{},"body":"{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]}}"},"request":{"query":"x"}}]`, ) }) diff --git a/tests/legacy/http-status-with-errors.test.ts b/tests/legacy/http-status-with-errors.test.ts index 735991394..2ebf4033e 100644 --- a/tests/legacy/http-status-with-errors.test.ts +++ b/tests/legacy/http-status-with-errors.test.ts @@ -155,4 +155,34 @@ describe(`HTTP 4xx/5xx status codes with GraphQL response body`, () => { expect(clientError.response.errors).toEqual(graphqlErrors) } }) + + test(`non-GraphQL response - headers and rawBody should be accessible`, async () => { + const nonGraphQLBody = { error: `Kill switch activated`, reason: `maintenance` } + + // Setup mock to return 503 with non-GraphQL response and custom header + // eslint-disable-next-line prefer-arrow/prefer-arrow-functions + ctx.server.use(`*`, function mock(req, res) { + res.status(503) + .set(`Kill-Switch`, `true`) + .set(`Content-Type`, `application/json`) + .send(JSON.stringify(nonGraphQLBody)) + }) + + const client = new GraphQLClient(ctx.url) + + try { + await client.request(`{ user { id } }`) + expect.fail(`Expected ClientError to be thrown`) + } catch (error) { + expect(error).toBeInstanceOf(ClientError) + const clientError = error as ClientError + expect(clientError.response.status).toBe(503) + // Verify headers are accessible + expect(clientError.response.headers.get(`Kill-Switch`)).toBe(`true`) + // Verify body is accessible for non-GraphQL responses + expect(clientError.response.body).toBe(JSON.stringify(nonGraphQLBody)) + // Can parse the body to get detailed error info + expect(JSON.parse(clientError.response.body)).toEqual(nonGraphQLBody) + } + }) })