diff --git a/.changeset/clean-pandas-respond.md b/.changeset/clean-pandas-respond.md new file mode 100644 index 0000000..fa4b1e7 --- /dev/null +++ b/.changeset/clean-pandas-respond.md @@ -0,0 +1,5 @@ +--- +"@api-wrappers/api-core": minor +--- + +Add a GraphQL response helper that preserves raw response headers, request context, plugin metadata, and GraphQL extensions. diff --git a/src/__tests__/graphqlWithResponse.test.ts b/src/__tests__/graphqlWithResponse.test.ts new file mode 100644 index 0000000..43e3a75 --- /dev/null +++ b/src/__tests__/graphqlWithResponse.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { BaseHttpClient } from "../client/BaseHttpClient"; +import type { RequestContext } from "../context/RequestContext"; +import { GraphQLRequestError } from "../graphql/GraphQLRequestError"; +import { graphqlWithResponse } from "../graphql/graphqlWithResponse"; + +const QUERY = "query Viewer { Viewer { id } }"; + +describe("graphqlWithResponse", () => { + it("returns data with the raw response and GraphQL extensions", async () => { + let captured: RequestContext | undefined; + const client = new BaseHttpClient({ + baseUrl: "https://api.test", + transport: { + async execute(ctx) { + captured = ctx; + return new Response( + JSON.stringify({ + data: { Viewer: { id: 1 } }, + extensions: { traceId: "trace-1" }, + }), + { + headers: { + "content-type": "application/json", + "x-ratelimit-remaining": "89", + }, + }, + ); + }, + }, + }); + + const result = await graphqlWithResponse<{ + Viewer: { id: number }; + }>(client, "/graphql", { + query: QUERY, + tags: ["viewer"], + }); + expect(captured).toBeDefined(); + const request = captured as RequestContext; + + expect(result.data.Viewer.id).toBe(1); + expect(result.extensions).toEqual({ traceId: "trace-1" }); + expect(result.response.headers.get("x-ratelimit-remaining")).toBe("89"); + expect(result.request).toBe(request); + expect(result.request.tags).toEqual(["viewer"]); + }); + + it("throws GraphQLRequestError for application errors", async () => { + const client = new BaseHttpClient({ + baseUrl: "https://api.test", + transport: { + async execute() { + return new Response( + JSON.stringify({ errors: [{ message: "Denied" }] }), + { headers: { "content-type": "application/json" } }, + ); + }, + }, + }); + + await expect( + graphqlWithResponse(client, "/graphql", { query: QUERY }), + ).rejects.toBeInstanceOf(GraphQLRequestError); + }); +}); diff --git a/src/graphql/graphqlWithResponse.ts b/src/graphql/graphqlWithResponse.ts new file mode 100644 index 0000000..e22d0cf --- /dev/null +++ b/src/graphql/graphqlWithResponse.ts @@ -0,0 +1,60 @@ +import type { ApiResponse, BaseHttpClient } from "../client/BaseHttpClient"; +import { mergeHeaders } from "../utils/mergeHeaders"; +import { GraphQLRequestError } from "./GraphQLRequestError"; +import type { GraphQLRequestOptions, GraphQLResponse } from "./types"; + +export interface GraphQLApiResponse extends ApiResponse { + /** Optional GraphQL response extensions returned by the server. */ + extensions?: Record; +} + +/** + * Executes a GraphQL operation while retaining the raw response, request + * context, plugin metadata, and GraphQL extensions. + */ +export async function graphqlWithResponse< + TData = unknown, + TVariables extends object = Record, +>( + client: Pick, + path: string, + options: GraphQLRequestOptions, +): Promise> { + const { + query, + variables, + operationName, + headers, + signal, + timeoutMs, + cacheKey, + tags, + } = options; + const result = await client.requestWithResponse>( + path, + { + method: "POST", + body: { + query, + ...(variables !== undefined && { variables }), + ...(operationName !== undefined && { operationName }), + }, + headers: mergeHeaders(headers, { "content-type": "application/json" }), + signal, + timeoutMs, + cacheKey, + tags, + }, + ); + const envelope = result.data; + + if (envelope.errors && envelope.errors.length > 0) { + throw new GraphQLRequestError(envelope.errors, envelope.data); + } + + return { + ...result, + data: envelope.data as TData, + extensions: envelope.extensions, + }; +} diff --git a/src/index.ts b/src/index.ts index 01b3c32..90ad10b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,6 +109,8 @@ export type { export { createGraphQLRequester } from "./graphql/createGraphQLRequester"; export { GraphQLRequestError } from "./graphql/GraphQLRequestError"; export { dedupeGraphQLFragmentDefinitions, gql } from "./graphql/gql"; +export type { GraphQLApiResponse } from "./graphql/graphqlWithResponse"; +export { graphqlWithResponse } from "./graphql/graphqlWithResponse"; export type { GraphQLErrorDetail, GraphQLRequestOptions,