From 6b50db5e22232b4366b6d3da20743fe54adbce5e Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Tue, 3 Nov 2026 12:56:58 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat(core):=20=F0=9F=8E=B8=20adapter-declar?= =?UTF-8?q?ed=20request=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapters can declare additional createRequest fields with a new generic, so they are type checked only on clients using that adapter. The devtools endpoint getter also receives the request options to read those fields. Refs #153 Co-Authored-By: Claude Opus 5.5 --- .../adapter/adapter.request-options.spec.ts | 116 ++++++++++++++++++ packages/core/src/adapter/adapter.ts | 46 +++++-- packages/core/src/adapter/adapter.types.ts | 10 +- packages/core/src/client/client.ts | 3 +- packages/core/src/types/adapter.types.ts | 29 +++-- 5 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 packages/core/__tests__/features/adapter/adapter.request-options.spec.ts diff --git a/packages/core/__tests__/features/adapter/adapter.request-options.spec.ts b/packages/core/__tests__/features/adapter/adapter.request-options.spec.ts new file mode 100644 index 00000000..2621d4c6 --- /dev/null +++ b/packages/core/__tests__/features/adapter/adapter.request-options.spec.ts @@ -0,0 +1,116 @@ +import type { AdapterInstance, DeclareAdapterType, DefaultMapperType, QueryParamsType } from "adapter"; +import { Adapter } from "adapter"; +import { Client } from "client"; +import type { stringifyQueryParams } from "http-adapter"; +import type { ExtractAdapterRequestOptionsType } from "types"; +import { expectTypeOf } from "vitest"; + +import { HttpMethods } from "../../../src/constants/http.constants"; + +type CustomRequestOptions = { document?: string }; + +const createAdapter = () => + new Adapter< + { timeout?: number }, + "GET", + number, + { headers: Record }, + QueryParamsType | string, + undefined, + string, + (endpoint: string) => string, + typeof stringifyQueryParams, + DefaultMapperType, + DefaultMapperType, + CustomRequestOptions + >({ + name: "test-adapter", + defaultMethod: HttpMethods.GET, + defaultExtra: { headers: {} }, + systemErrorStatus: 0, + systemErrorExtra: { headers: {} }, + }); + +describe("Adapter [ Request options ]", () => { + describe("when creating requests", () => { + it("should keep the additional field in the request options", () => { + const client = new Client({ url: "http://localhost" }).setAdapter(createAdapter()); + + const request = client.createRequest()({ endpoint: "/test", document: "value" }); + + expect(request.requestOptions).toMatchObject({ endpoint: "/test", document: "value" }); + }); + + it("should keep the additional field on clones and after serialization", () => { + const client = new Client({ url: "http://localhost" }).setAdapter(createAdapter()); + + const request = client.createRequest()({ endpoint: "/test", document: "value" }); + const cloned = request.setRetry(1).clone(); + const restored = client.fromJSON(JSON.parse(JSON.stringify(request.toJSON()))); + + expect(cloned.requestOptions).toMatchObject({ document: "value" }); + expect(restored.requestOptions).toMatchObject({ document: "value" }); + }); + }); + + describe("when using the devtools endpoint getter", () => { + it("should allow to read the request options", () => { + const adapter = createAdapter().setDevtoolsEndpointGetter( + (endpoint, requestOptions) => requestOptions?.document ?? endpoint, + ); + + expect(adapter.unstable_devtoolsEndpointGetter("/test")).toBe("/test"); + expect(adapter.unstable_devtoolsEndpointGetter("/test", { document: "value" })).toBe("value"); + }); + }); + + describe("when checking types", () => { + it("should extract the declared request options", () => { + type TestAdapter = ReturnType; + + expectTypeOf>().toEqualTypeOf(); + }); + + it("should support declaring the request options", () => { + type Declared = DeclareAdapterType<{ + adapterOptions: Record; + methodType: "GET"; + statusType: number; + extra: Record; + endpointType: string; + requestOptions: CustomRequestOptions; + }>; + + expectTypeOf>().toEqualTypeOf(); + }); + + it("should default to no additional fields", () => { + type Default = DeclareAdapterType<{ + adapterOptions: Record; + methodType: "GET"; + statusType: number; + extra: Record; + endpointType: string; + }>; + + expectTypeOf>().toEqualTypeOf<{}>(); + expectTypeOf>().toEqualTypeOf<{}>(); + }); + + it("should reject not declared fields and validate the declared ones", () => { + const client = new Client({ url: "http://localhost" }).setAdapter(createAdapter()); + const http = new Client({ url: "http://localhost" }); + + const create = () => { + // @ts-expect-error wrong type of the declared field + client.createRequest()({ endpoint: "/test", document: 1 }); + // @ts-expect-error field not declared by the adapter + client.createRequest()({ endpoint: "/test", other: 1 }); + // @ts-expect-error field not declared by the default adapter + http.createRequest()({ endpoint: "/test", document: "value" }); + }; + + expect(create).toBeFunction(); + }); + }); +}); diff --git a/packages/core/src/adapter/adapter.ts b/packages/core/src/adapter/adapter.ts index 31c777a1..ab862a3d 100644 --- a/packages/core/src/adapter/adapter.ts +++ b/packages/core/src/adapter/adapter.ts @@ -39,6 +39,7 @@ export class Adapter< QueryParamsMapperType extends QueryParamsMapper | DefaultMapperType = DefaultMapperType, HeaderMapperType extends HeaderMappingType | DefaultMapperType = DefaultMapperType, PayloadMapperType extends AdapterPayloadMappingType | DefaultMapperType = DefaultMapperType, + RequestOptions extends Record = {}, > { /** Fetching function */ public unstable_fetcher: AdapterFetcherType< @@ -53,7 +54,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >; @@ -80,6 +82,12 @@ export class Adapter< public unstable_payloadMapperConfig: Parameters[1]; public unstable_endpointMapperConfig: Parameters[1]; + /** + * Type-only carrier of the additional `createRequest` fields declared by this adapter. + * It has no runtime value - adapters read those fields from `request.requestOptions`. + */ + public unstable_requestOptionsType?: RequestOptions = undefined; + constructor( public options: { name: string; @@ -147,7 +155,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >; } @@ -161,7 +170,9 @@ export class Adapter< * Get formatted endpoint name of the request. * Helpful in displaying long endpoints like in case of graphql schemas etc. */ - public unstable_devtoolsEndpointGetter: (endpoint: string) => string = (endpoint) => endpoint; + public unstable_devtoolsEndpointGetter: (endpoint: string, requestOptions?: Record) => string = ( + endpoint, + ) => endpoint; /** * ******************** @@ -182,7 +193,7 @@ export class Adapter< }; /** Set a function that formats the endpoint string for display in devtools. */ - setDevtoolsEndpointGetter = (callback: (endpoint: string) => string) => { + setDevtoolsEndpointGetter = (callback: (endpoint: string, requestOptions?: Record) => string) => { this.unstable_devtoolsEndpointGetter = callback; return this; }; @@ -217,7 +228,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >; } @@ -251,7 +263,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, NewMapper, - PayloadMapperType + PayloadMapperType, + RequestOptions >; }; @@ -275,7 +288,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - NewMapper + NewMapper, + RequestOptions >; }; @@ -299,7 +313,8 @@ export class Adapter< NewEndpointMapper, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions >; }; @@ -325,7 +340,8 @@ export class Adapter< EndpointMapperType, NewQueryParamsMapper, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions >; }; @@ -381,7 +397,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >, ) { @@ -408,7 +425,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >; } @@ -445,7 +463,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >({ request, @@ -470,7 +489,8 @@ export class Adapter< EndpointMapperType, QueryParamsMapperType, HeaderMapperType, - PayloadMapperType + PayloadMapperType, + RequestOptions > >(bindings); } diff --git a/packages/core/src/adapter/adapter.types.ts b/packages/core/src/adapter/adapter.types.ts index ce89d303..ce5443bb 100644 --- a/packages/core/src/adapter/adapter.types.ts +++ b/packages/core/src/adapter/adapter.types.ts @@ -21,7 +21,7 @@ import type { getAdapterBindings } from "./adapter.bindings"; * Base Adapter */ -export type AdapterInstance = Adapter; +export type AdapterInstance = Adapter; export type AdapterGenericType< AdapterOptions, @@ -35,6 +35,7 @@ export type AdapterGenericType< QueryParamsMapperType extends QueryParamsMapper | DefaultMapperType = DefaultMapperType, HeaderMapperType extends HeaderMappingType | DefaultMapperType = DefaultMapperType, PayloadMapperType extends AdapterPayloadMappingType | DefaultMapperType = DefaultMapperType, + RequestOptions extends Record = {}, > = { adapterOptions: AdapterOptions; methodType: MethodType; @@ -47,10 +48,12 @@ export type AdapterGenericType< queryParamsMapperType?: QueryParamsMapperType; headerMapperType?: HeaderMapperType; payloadMapperType?: PayloadMapperType; + /** Additional fields accepted by `createRequest` on clients using this adapter */ + requestOptions?: RequestOptions; }; export type DeclareAdapterType< - Properties extends AdapterGenericType, + Properties extends AdapterGenericType, > = Adapter< TypeWithDefaults, TypeWithDefaults, @@ -62,7 +65,8 @@ export type DeclareAdapterType< TypeWithDefaults, TypeWithDefaults, TypeWithDefaults, - NonNullable> + NonNullable>, + TypeWithDefaults >; export type AdapterFetcherType = ( diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 43a6e5cd..34bf14e8 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -28,6 +28,7 @@ import type { HydrateDataType, HydrationOptions, ExtractAdapterDefaultQueryParamsType, + ExtractAdapterRequestOptionsType, } from "types"; import { getUniqueRequestId } from "utils"; @@ -505,7 +506,7 @@ export class Client< AdapterOptions extends ExtractAdapterOptionsType, MethodType extends ExtractAdapterMethodType, >( - params: RequestOptionsType, + params: RequestOptionsType & ExtractAdapterRequestOptionsType, ) => { type Endpoint = TypeWithDefaults; diff --git a/packages/core/src/types/adapter.types.ts b/packages/core/src/types/adapter.types.ts index bc7811ea..ba74bf27 100644 --- a/packages/core/src/types/adapter.types.ts +++ b/packages/core/src/types/adapter.types.ts @@ -1,27 +1,34 @@ import type { Adapter, AdapterInstance } from "adapter"; export type ExtractAdapterOptionsType = - T extends Adapter ? O : never; + T extends Adapter ? O : never; export type ExtractAdapterMethodType = - T extends Adapter ? M : never; + T extends Adapter ? M : never; export type ExtractAdapterStatusType = - T extends Adapter ? S : never; + T extends Adapter ? S : never; export type ExtractAdapterExtraType = - T extends Adapter ? A : never; + T extends Adapter ? A : never; export type ExtractAdapterQueryParamsType = - T extends Adapter ? Q : never; + T extends Adapter ? Q : never; export type ExtractAdapterDefaultQueryParamsType = - T extends Adapter ? Q : never; + T extends Adapter ? Q : never; export type ExtractAdapterEndpointType = - T extends Adapter ? E : never; + T extends Adapter ? E : never; export type ExtractAdapterEndpointMapperType = - T extends Adapter ? EM : never; + T extends Adapter ? EM : never; export type ExtractAdapterQueryParamsMapperType = - T extends Adapter ? QPM : never; + T extends Adapter ? QPM : never; export type ExtractAdapterHeaderMapperType = - T extends Adapter ? HM : never; + T extends Adapter ? HM : never; export type ExtractAdapterPayloadMapperType = - T extends Adapter ? PM : never; + T extends Adapter ? PM : never; +/** `any` (e.g. `AdapterInstance`) resolves to `{}`, otherwise it would erase the type of the whole `createRequest` params */ +export type ExtractAdapterRequestOptionsType = + T extends Adapter + ? 0 extends 1 & RO + ? {} + : RO + : never; // Special type only for selecting appropriate AdapterType union version (check FirebaseAdapterType). export type ExtractUnionAdapter< From 27ddf01f17d5ab99ef6918c290d977813137cab8 Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Tue, 3 Nov 2026 12:57:03 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(graphql):=20=F0=9F=8E=B8=20document=20?= =?UTF-8?q?option=20and=20query=20params=20in=20the=20url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the document option the endpoint becomes a URL path appended to client.url. Query params go through the adapter mapper and are appended to the URL, on GET query and variables win on collision. Keys contain the operation name from the document and the path. Without the document option the endpoint still holds the document and the URL is client.url. Closes #153 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/e2e/e2e.graphql-server.ts | 6 +- .../__tests__/e2e/e2e.graphql.spec.ts | 63 ++- .../features/adapter/adapter.browser.spec.ts | 103 +++++ .../features/adapter/adapter.document.spec.ts | 383 ++++++++++++++++++ .../features/adapter/adapter.types.spec.ts | 115 ++++++ .../src/adapter/adapter.types.ts | 18 +- .../src/adapter/adapter.utils.ts | 121 ++++-- .../src/adapter/http-adapter.fetch.ts | 30 +- 8 files changed, 800 insertions(+), 39 deletions(-) create mode 100644 packages/adapter-graphql/__tests__/features/adapter/adapter.document.spec.ts create mode 100644 packages/adapter-graphql/__tests__/features/adapter/adapter.types.spec.ts diff --git a/packages/adapter-graphql/__tests__/e2e/e2e.graphql-server.ts b/packages/adapter-graphql/__tests__/e2e/e2e.graphql-server.ts index e25bc1ac..3436aba9 100644 --- a/packages/adapter-graphql/__tests__/e2e/e2e.graphql-server.ts +++ b/packages/adapter-graphql/__tests__/e2e/e2e.graphql-server.ts @@ -18,6 +18,7 @@ export type GraphQLServerOptions = { export const createE2EGraphQLServer = (options: GraphQLServerOptions) => { let server: http.Server | null = null; let port = 0; + let lastRequestUrl = ""; const schema: GraphQLSchema = buildSchema(options.typeDefs); const rootValue = options.resolvers; @@ -33,6 +34,7 @@ export const createE2EGraphQLServer = (options: GraphQLServerOptions) => { }; const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => { + lastRequestUrl = req.url || ""; res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); @@ -131,5 +133,7 @@ export const createE2EGraphQLServer = (options: GraphQLServerOptions) => { const getUrl = () => `http://127.0.0.1:${port}`; - return { startServer, stopServer, getUrl }; + const getLastRequestUrl = () => lastRequestUrl; + + return { startServer, stopServer, getUrl, getLastRequestUrl }; }; diff --git a/packages/adapter-graphql/__tests__/e2e/e2e.graphql.spec.ts b/packages/adapter-graphql/__tests__/e2e/e2e.graphql.spec.ts index 6e1fa0df..d05dff3f 100644 --- a/packages/adapter-graphql/__tests__/e2e/e2e.graphql.spec.ts +++ b/packages/adapter-graphql/__tests__/e2e/e2e.graphql.spec.ts @@ -76,7 +76,7 @@ type GqlResponse = { response: T; error: { message: string }[] }; // ─── Server Setup ──────────────────────────────────────────────────── -const { startServer, stopServer } = createE2EGraphQLServer({ typeDefs, resolvers }); +const { startServer, stopServer, getLastRequestUrl } = createE2EGraphQLServer({ typeDefs, resolvers }); let baseUrl: string; @@ -484,3 +484,64 @@ describe("E2E GraphQL [ Multiple Operations ]", () => { expect(result2.data!.user.username).toBe("jane"); }); }); + +describe("E2E GraphQL [ Document and query params ]", () => { + const USER_QUERY = "query User($id: ID!) { user(id: $id) { id username } }"; + type UserResponse = GqlResponse<{ user: { id: string; username: string } }> & { + payload: { id: string }; + }; + + it("should send POST to the path with query params and the variables in the body", async () => { + const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter()); + const request = client.createRequest()({ endpoint: "/User", document: USER_QUERY }); + + const { data, error } = await request + .setQueryParams({ source: "app", build: "1.2.3" }) + .send({ payload: { id: "1" } }); + + expect(error).toBeNull(); + expect(data).toStrictEqual({ user: { id: "1", username: "john" } }); + expect(getLastRequestUrl()).toBe("/graphql/User?source=app&build=1.2.3"); + }); + + it("should send GET to the path with everything in the query string", async () => { + const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter()); + const request = client.createRequest()({ + endpoint: "/User", + document: USER_QUERY, + method: GraphqlMethod.GET, + }); + + const { data, error } = await request + .setQueryParams({ source: "app", query: "ignored", variables: "ignored" }) + .send({ payload: { id: "2" } }); + + expect(error).toBeNull(); + expect(data).toStrictEqual({ user: { id: "2", username: "jane" } }); + + const { pathname, searchParams } = new URL(getLastRequestUrl(), baseUrl); + expect(pathname).toBe("/graphql/User"); + expect(searchParams.get("source")).toBe("app"); + expect(searchParams.getAll("query")).toStrictEqual([USER_QUERY]); + expect(searchParams.getAll("variables")).toStrictEqual([JSON.stringify({ id: "2" })]); + }); + + it("should keep the client url when document is omitted", async () => { + const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter()); + const request = client.createRequest()({ endpoint: USER_QUERY }); + + const { data } = await request.send({ payload: { id: "1" } } as any); + + expect(data).toStrictEqual({ user: { id: "1", username: "john" } }); + expect(getLastRequestUrl()).toBe("/graphql"); + }); + + it("should apply query params when document is omitted", async () => { + const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter()); + const request = client.createRequest()({ endpoint: USER_QUERY }); + + await request.setQueryParams({ source: "app" }).send({ payload: { id: "1" } } as any); + + expect(getLastRequestUrl()).toBe("/graphql?source=app"); + }); +}); diff --git a/packages/adapter-graphql/__tests__/features/adapter/adapter.browser.spec.ts b/packages/adapter-graphql/__tests__/features/adapter/adapter.browser.spec.ts index 298c4c90..c778f607 100644 --- a/packages/adapter-graphql/__tests__/features/adapter/adapter.browser.spec.ts +++ b/packages/adapter-graphql/__tests__/features/adapter/adapter.browser.spec.ts @@ -256,4 +256,107 @@ describe("Graphql Adapter [ Browser ]", () => { global.fetch = originalFetch; }); + + describe("when using the document option", () => { + const getFetchUrl = (spy: { mock: { calls: unknown[][] } }) => String(spy.mock.calls[0][0]); + + it("should send the request to the path of the operation", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/User", + document: getUserQuery, + }); + const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } }); + const spy = vi.spyOn(globalThis, "fetch"); + + const { data, error, status } = await operation.setQueryParams({ source: "app", build: "1.2.3" }).send(); + + expect(expected.data).toStrictEqual(data); + expect(error).toBe(null); + expect(status).toBe(200); + expect(getFetchUrl(spy)).toBe("https://shared-base-url/graphql/User?source=app&build=1.2.3"); + }); + + it("should not reach the handler of a different path", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/User", + document: getUserQuery, + }); + const other = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/Other", + document: getUserQuery, + }); + mockRequest(other, { data: { username: "other", firstName: "Other" } }); + const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } }); + + const { data } = await operation.send(); + + expect(data).toStrictEqual(expected.data); + }); + + it("should send mutations", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse; payload: LoginMutationVariables }>()({ + endpoint: "/Login", + document: loginMutation, + }); + const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } }); + + const { data, error } = await operation.send({ payload: { username: "Kacper", password: "Kacper1234" } }); + + expect(expected.data).toStrictEqual(data); + expect(error).toBe(null); + }); + + it("should run parallel requests against different paths without changing the client url", async () => { + const first = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/First", + document: getUserQuery, + }); + const second = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/Second", + document: getUserQuery, + }); + const firstExpected = mockRequest(first, { data: { username: "first", firstName: "First" } }); + const secondExpected = mockRequest(second, { data: { username: "second", firstName: "Second" } }); + + const [firstResponse, secondResponse] = await Promise.all([first.send(), second.send()]); + + expect(firstResponse.data).toStrictEqual(firstExpected.data); + expect(secondResponse.data).toStrictEqual(secondExpected.data); + expect(client.url).toBe("https://shared-base-url/graphql"); + }); + + it("should not share the cache between paths", async () => { + const cacheClient = new Client({ url: "https://shared-base-url/graphql", mode: "client" }).setAdapter( + GraphqlAdapter(), + ); + const first = cacheClient.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/First", + document: getUserQuery, + }); + const second = cacheClient.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/Second", + document: getUserQuery, + }); + const firstExpected = mockRequest(first, { data: { username: "first", firstName: "First" } }); + const secondExpected = mockRequest(second, { data: { username: "second", firstName: "Second" } }); + + await first.send(); + await second.send(); + + expect(first.read()?.data).toStrictEqual(firstExpected.data); + expect(second.read()?.data).toStrictEqual(secondExpected.data); + }); + }); + + describe("when using query params without the document option", () => { + it("should append them to the client url", async () => { + const expected = mockRequest(request, { data: { username: "prc", firstName: "Maciej" } }); + const spy = vi.spyOn(globalThis, "fetch"); + + const { data } = await request.setQueryParams({ source: "app" }).send(); + + expect(expected.data).toStrictEqual(data); + expect(String(spy.mock.calls[0][0])).toBe("https://shared-base-url/graphql?source=app"); + }); + }); }); diff --git a/packages/adapter-graphql/__tests__/features/adapter/adapter.document.spec.ts b/packages/adapter-graphql/__tests__/features/adapter/adapter.document.spec.ts new file mode 100644 index 00000000..14039740 --- /dev/null +++ b/packages/adapter-graphql/__tests__/features/adapter/adapter.document.spec.ts @@ -0,0 +1,383 @@ +import { Client, stringifyDefaultOptions } from "@hyper-fetch/core"; +import { GraphqlAdapter, GraphqlMethod, getGqlDocument, getRequestValues, gqlEndpointNameMapper } from "adapter"; +import gql from "graphql-tag"; + +import { getUserQuery, getUserQueryString } from "../../constants/queries.constants"; + +const url = "https://shared-base-url/graphql"; +const printedQuery = "query GetUser {\n username {\n username\n firstName\n }\n}"; + +const getQueryString = (fullUrl: string) => new URL(fullUrl).searchParams; + +describe("Graphql Adapter [ Document and query params ]", () => { + const createClient = () => new Client({ url }).setAdapter(GraphqlAdapter()); + + let client = createClient(); + + beforeEach(() => { + client = createClient(); + }); + + describe("When document is omitted", () => { + it("should keep the endpoint as the document and the client url as the url", () => { + const request = client.createRequest()({ endpoint: getUserQuery }); + + const { fullUrl, payload } = getRequestValues(request); + + expect(fullUrl).toBe(url); + expect(payload).toBe(JSON.stringify({ query: printedQuery })); + expect(getGqlDocument(request)).toBe(printedQuery); + }); + + it("should keep the same keys as before", () => { + const request = client.createRequest()({ endpoint: getUserQueryString }); + + expect(request.cacheKey).toBe("POST_GetUser--"); + expect(request.queryKey).toBe("POST_GetUser--"); + expect(request.abortKey).toBe("POST_GetUser-false"); + }); + + it("should append query params to the POST url", () => { + const request = client + .createRequest<{ queryParams: { source: string } }>()({ endpoint: getUserQuery }) + .setQueryParams({ source: "app" }); + + const { fullUrl, payload } = getRequestValues(request); + + expect(fullUrl).toBe(`${url}?source=app`); + expect(payload).toBe(JSON.stringify({ query: printedQuery })); + }); + + it("should merge query params into the GET query string", () => { + const request = client + .createRequest<{ queryParams: { source: string } }>()({ endpoint: getUserQuery, method: GraphqlMethod.GET }) + .setQueryParams({ source: "app" }); + + const { fullUrl, payload } = getRequestValues(request); + + expect(fullUrl.startsWith(`${url}?query=`)).toBeTrue(); + expect(getQueryString(fullUrl).get("query")).toBe(printedQuery); + expect(getQueryString(fullUrl).get("source")).toBe("app"); + expect(payload).toBeNull(); + }); + }); + + describe("When document is set", () => { + it("should use the endpoint as the url path and print the DocumentNode as query", () => { + const request = client.createRequest()({ endpoint: "/User", document: getUserQuery }); + + const { fullUrl, payload } = getRequestValues(request); + + expect(fullUrl).toBe(`${url}/User`); + expect(payload).toBe(JSON.stringify({ query: printedQuery })); + expect(request.endpoint).toBe("/User"); + expect(getGqlDocument(request)).toBe(getUserQuery); + }); + + it("should send a string document as it is", () => { + const request = client.createRequest()({ endpoint: "/User", document: getUserQueryString }); + + expect(getRequestValues(request).payload).toBe(JSON.stringify({ query: getUserQueryString })); + }); + + it("should build the url from the issue example", () => { + const request = client + .createRequest<{ payload: { id: number } }>()({ endpoint: "/User", document: getUserQuery }) + .setQueryParams({ source: "app", build: "1.2.3" }) + .setPayload({ id: 1 }); + + const { fullUrl, payload } = getRequestValues(request); + + expect(fullUrl).toBe(`${url}/User?source=app&build=1.2.3`); + expect(payload).toBe(JSON.stringify({ query: printedQuery, variables: { id: 1 } })); + }); + + it("should send everything in the query string for GET", () => { + const request = client + .createRequest<{ payload: { id: number } }>()({ + endpoint: "/User", + document: getUserQuery, + method: GraphqlMethod.GET, + }) + .setQueryParams({ source: "app" }) + .setPayload({ id: 1 }); + + const { fullUrl, payload } = getRequestValues(request); + const searchParams = getQueryString(fullUrl); + + expect(fullUrl.startsWith(`${url}/User?query=`)).toBeTrue(); + expect(searchParams.get("query")).toBe(printedQuery); + expect(searchParams.get("variables")).toBe(JSON.stringify({ id: 1 })); + expect(searchParams.get("source")).toBe("app"); + expect(payload).toBeNull(); + }); + + it("should allow for an empty path", () => { + const request = client + .createRequest()({ endpoint: "", document: getUserQuery }) + .setQueryParams({ source: "app" }); + + expect(getRequestValues(request).fullUrl).toBe(`${url}?source=app`); + }); + + it("should replace params in the url path", () => { + const request = client.createRequest()({ endpoint: "/tenant/:tenantId/User", document: getUserQuery }); + + const { fullUrl, payload } = getRequestValues(request.setParams({ tenantId: 7 })); + + expect(fullUrl).toBe(`${url}/tenant/7/User`); + expect(payload).toBe(JSON.stringify({ query: printedQuery })); + }); + + it("should treat nullish document as omitted", () => { + const request = client.createRequest()({ endpoint: getUserQueryString, document: undefined }); + + expect(getRequestValues(request).fullUrl).toBe(url); + expect(getGqlDocument(request)).toBe(getUserQueryString); + }); + + it("should keep the document on clones", () => { + const request = client.createRequest()({ endpoint: "/User", document: getUserQuery }); + + const cloned = request.setHeaders({ a: "b" }).setRetry(1).clone(); + + expect(getRequestValues(cloned).fullUrl).toBe(`${url}/User`); + expect(getGqlDocument(cloned)).toBe(getUserQuery); + }); + + it("should survive JSON serialization", () => { + const request = client.createRequest()({ endpoint: "/User", document: getUserQuery }); + + const restored = JSON.parse(JSON.stringify(request.toJSON())); + const hydrated = client.fromJSON(restored); + + expect(getRequestValues(hydrated as any).fullUrl).toBe(`${url}/User`); + expect(getRequestValues(hydrated as any).payload).toBe(JSON.stringify({ query: printedQuery })); + expect(hydrated.cacheKey).toBe(request.cacheKey); + }); + }); + + describe("When mapping query params", () => { + const getUrl = (queryParams: any, method = GraphqlMethod.POST) => { + const request = client.createRequest<{ queryParams: any }>()({ endpoint: "/User", document: "{ a }", method }); + return getRequestValues(request.setQueryParams(queryParams)).fullUrl; + }; + + it("should not add a question mark without query params", () => { + expect(getUrl(undefined)).toBe(`${url}/User`); + expect(getUrl(null)).toBe(`${url}/User`); + expect(getUrl({})).toBe(`${url}/User`); + expect(getUrl("")).toBe(`${url}/User`); + expect(getUrl("?")).toBe(`${url}/User`); + }); + + it("should accept string query params with and without the question mark", () => { + expect(getUrl("a=1&b=2")).toBe(`${url}/User?a=1&b=2`); + expect(getUrl("?a=1&b=2")).toBe(`${url}/User?a=1&b=2`); + }); + + it("should encode values and skip the empty ones", () => { + expect(getUrl({ a: "x y", b: undefined, c: null, d: [1, 2] })).toBe(`${url}/User?a=x%20y&d[]=1&d[]=2`); + }); + + it("should leave query and variables keys alone for POST", () => { + expect(getUrl({ query: "mine", variables: "mine" })).toBe(`${url}/User?query=mine&variables=mine`); + }); + + it.each([ + ["object", { query: "mine", variables: "mine", source: "app" }], + ["string", "query=mine&variables=mine&source=app"], + ["string with question mark", "?variables=mine&query=mine&source=app"], + ["encoded keys", "%71uery=mine&source=app"], + ["array", { query: ["mine", "mine2"], source: "app" }], + ["bracket array", "query[]=mine&query%5B%5D=mine2&variables[0]=mine&source=app"], + ])("should prefer query and variables on GET collision for %s", (_, queryParams) => { + const request = client + .createRequest<{ queryParams: any; payload: any }>()({ + endpoint: "/User", + document: "{ a }", + method: GraphqlMethod.GET, + }) + .setQueryParams(queryParams) + .setPayload({ id: 1 }); + + const searchParams = getQueryString(getRequestValues(request).fullUrl); + + expect(searchParams.getAll("query")).toStrictEqual(["{ a }"]); + expect(searchParams.getAll("variables")).toStrictEqual([JSON.stringify({ id: 1 })]); + expect(searchParams.get("source")).toBe("app"); + expect([...searchParams.keys()]).toStrictEqual(["query", "variables", "source"]); + }); + + it("should not fail on malformed keys", () => { + expect(getUrl("%ZZ=1&a=2", GraphqlMethod.GET)).toEndWith("&%ZZ=1&a=2"); + }); + + it("should keep similarly named keys on GET", () => { + const searchParams = getQueryString(getUrl({ queryId: 1, "variables.a": 2 }, GraphqlMethod.GET)); + + expect(searchParams.get("queryId")).toBe("1"); + expect(searchParams.get("variables.a")).toBe("2"); + }); + + it("should use the custom query params mapper", () => { + const mapper = vi.fn((queryParams: any) => `custom=${queryParams.a}`); + const customClient = new Client({ url }).setAdapter(GraphqlAdapter().setQueryParamsMapper(mapper)); + const request = customClient + .createRequest<{ queryParams: { a: number } }>()({ endpoint: "/User", document: "{ a }" }) + .setQueryParams({ a: 1 }); + + expect(getRequestValues(request).fullUrl).toBe(`${url}/User?custom=1`); + expect(mapper).toHaveBeenCalledWith({ a: 1 }, undefined); + }); + + it("should accept the object returned from the custom mapper", () => { + const customClient = new Client({ url }).setAdapter( + GraphqlAdapter().setQueryParamsMapper((queryParams) => ({ ...(queryParams as any), mapped: true })), + ); + const request = customClient + .createRequest<{ queryParams: { a: number } }>()({ endpoint: "/User", document: "{ a }" }) + .setQueryParams({ a: 1 }); + + expect(getRequestValues(request).fullUrl).toBe(`${url}/User?a=1&mapped=true`); + }); + + it("should use the query params mapper config", () => { + const customClient = new Client({ url }).setAdapter( + GraphqlAdapter().setQueryParamsMapperConfig({ arrayFormat: "bracket" }), + ); + const request = customClient + .createRequest<{ queryParams: { a: number[] } }>()({ endpoint: "/User", document: "{ a }" }) + .setQueryParams({ a: [1, 2] }); + + expect(getRequestValues(request).fullUrl).toBe(`${url}/User?a[]=1&a[]=2`); + }); + + it("should keep the encoding with the default options spread into the config", () => { + const customClient = new Client({ url }).setAdapter( + GraphqlAdapter().setQueryParamsMapperConfig({ ...stringifyDefaultOptions, arrayFormat: "comma" }), + ); + const request = customClient + .createRequest<{ queryParams: { a: string[] } }>()({ endpoint: "/User", document: "{ a }" }) + .setQueryParams({ a: ["x y", "z"] }); + + expect(getRequestValues(request).fullUrl).toBe(`${url}/User?a=x%20y,z`); + }); + + it("should prefer already mapped query params passed by the fetcher", () => { + const mapper = vi.fn(() => "from=mapper"); + const customClient = new Client({ url }).setAdapter(GraphqlAdapter().setQueryParamsMapper(mapper)); + const request = customClient.createRequest()({ endpoint: "/User", document: "{ a }" }); + + expect(getRequestValues(request, "from=bindings").fullUrl).toBe(`${url}/User?from=bindings`); + expect(mapper).not.toHaveBeenCalled(); + }); + }); + + describe("When generating keys", () => { + it("should read the operation name from the document", () => { + const fromNode = client.createRequest()({ endpoint: "/User", document: getUserQuery }); + const fromString = client.createRequest()({ endpoint: "/User", document: getUserQueryString }); + + [fromNode, fromString].forEach((request) => { + expect(request.cacheKey).toBe("POST_GetUser/User--"); + expect(request.queryKey).toBe("POST_GetUser/User--"); + expect(request.abortKey).toBe("POST_GetUser/User-false"); + }); + }); + + it("should not add the path for the empty endpoint", () => { + const request = client.createRequest()({ endpoint: "", document: getUserQuery }); + + expect(request.cacheKey).toBe("POST_GetUser--"); + }); + + it("should distinguish the same operation sent to different paths", () => { + const v1 = client.createRequest()({ endpoint: "/v1/User", document: getUserQuery }); + const v2 = client.createRequest()({ endpoint: "/v2/User", document: getUserQuery }); + + expect(v1.cacheKey).not.toBe(v2.cacheKey); + expect(v1.queryKey).not.toBe(v2.queryKey); + expect(v1.abortKey).not.toBe(v2.abortKey); + }); + + it("should distinguish different operations sent to the same path", () => { + const first = client.createRequest()({ endpoint: "/graphql", document: "query First { a }" }); + const second = client.createRequest()({ endpoint: "/graphql", document: "query Second { a }" }); + + expect(first.cacheKey).not.toBe(second.cacheKey); + }); + + it("should include query params and params in the cache and query keys", () => { + const request = client.createRequest()({ endpoint: "/tenant/:id/User", document: getUserQuery }); + + const withQuery = request.setQueryParams({ a: 1 }); + const withParams = request.setParams({ id: 2 }); + + expect(withQuery.cacheKey).not.toBe(request.cacheKey); + expect(withQuery.queryKey).not.toBe(request.queryKey); + expect(withParams.cacheKey).not.toBe(request.cacheKey); + expect(withParams.queryKey).not.toBe(request.queryKey); + // Every value of the params shares one abort group, same as in the HttpAdapter + expect(withParams.abortKey).toBe(request.abortKey); + }); + + it("should keep the explicit keys", () => { + const request = client.createRequest()({ endpoint: "/User", document: getUserQuery, cacheKey: "mine" }); + + expect(request.cacheKey).toBe("mine"); + }); + }); + + describe("When using gqlEndpointNameMapper", () => { + it("should read the name from a DocumentNode", () => { + expect(gqlEndpointNameMapper(getUserQuery)).toBe("GetUser"); + }); + + it("should read the first field from an anonymous DocumentNode", () => { + expect( + gqlEndpointNameMapper( + gql` + { + username { + id + } + } + `, + ), + ).toBe("username"); + }); + + it("should use the fallback for a DocumentNode without operation", () => { + expect( + gqlEndpointNameMapper( + gql` + fragment A on User { + id + } + `, + ), + ).toBe("graphql-request"); + }); + + it("should use the name of the mutation", () => { + expect(gqlEndpointNameMapper("mutation Login($a: String!) { login(a: $a) { id } }")).toBe("Login"); + }); + }); + + describe("When using the devtools endpoint getter", () => { + const getter = () => client.adapter.unstable_devtoolsEndpointGetter; + + it("should read the operation name from the endpoint", () => { + expect(getter()(getUserQueryString)).toBe("GetUser"); + expect(getter()(getUserQueryString, { endpoint: getUserQueryString })).toBe("GetUser"); + }); + + it("should read the operation name from the document", () => { + const request = client.createRequest()({ endpoint: "/User", document: getUserQuery }); + + expect(getter()("/User", request.requestOptions)).toBe("GetUser"); + expect(getter()("/User", { document: getUserQueryString })).toBe("GetUser"); + }); + }); +}); diff --git a/packages/adapter-graphql/__tests__/features/adapter/adapter.types.spec.ts b/packages/adapter-graphql/__tests__/features/adapter/adapter.types.spec.ts new file mode 100644 index 00000000..60d0eb07 --- /dev/null +++ b/packages/adapter-graphql/__tests__/features/adapter/adapter.types.spec.ts @@ -0,0 +1,115 @@ +import type { ClientInstance, ExtractAdapterRequestOptionsType, RequestInstance } from "@hyper-fetch/core"; +import { Client, HttpAdapter } from "@hyper-fetch/core"; +import type { ExtractGqlRouteParams, GraphqlAdapterType, GraphqlRequestOptionsType } from "adapter"; +import { GraphqlAdapter } from "adapter"; +import type { DocumentNode } from "graphql"; +import { expectTypeOf } from "vitest"; + +import { getUserQuery } from "../../constants/queries.constants"; + +type UserResponse = { username: string }; + +describe("Graphql Adapter [ Types ]", () => { + const client = new Client({ url: "https://shared-base-url/graphql" }).setAdapter(GraphqlAdapter()); + + it("should expose document in the adapter request options", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("should accept string and DocumentNode documents", () => { + client.createRequest()({ endpoint: "/User", document: getUserQuery }); + client.createRequest()({ endpoint: "/User", document: "query User { id }" }); + client.createRequest()({ endpoint: "query User { id }" }); + client.createRequest()({ endpoint: getUserQuery }); + + expectTypeOf(client.createRequest()).parameter(0).toHaveProperty("document"); + // @ts-expect-error document has to be a string or a DocumentNode + expectTypeOf(client.createRequest()).parameter(0).toEqualTypeOf<{ endpoint: string; document: number }>(); + }); + + it("should compile the example from the proposal", () => { + const getUser = client.createRequest<{ response: UserResponse; payload: { id: number } }>()({ + endpoint: "/User", + document: getUserQuery, + }); + + const send = () => getUser.setQueryParams({ source: "app", build: "1.2.3" }).send({ payload: { id: 1 } }); + + expectTypeOf>["data"]>().toEqualTypeOf(); + }); + + it("should not require query params when they are not declared", () => { + const request = client.createRequest<{ response: UserResponse }>()({ endpoint: "/User", document: getUserQuery }); + + expectTypeOf(request.send).toBeCallableWith(); + expectTypeOf(request.send).toBeCallableWith({}); + request.setQueryParams("source=app"); + request.setQueryParams({ source: "app" }); + }); + + it("should use the declared query params", () => { + const request = client.createRequest<{ queryParams: { source: string } }>()({ + endpoint: "/User", + document: getUserQuery, + }); + + request.setQueryParams({ source: "app" }); + // @ts-expect-error wrong query params + request.setQueryParams({ source: 1 }); + }); + + it("should take the params from the url path when document is set", () => { + const request = client.createRequest()({ endpoint: "/tenant/:tenantId/User", document: getUserQuery }); + + request.setParams({ tenantId: 1 }); + // @ts-expect-error unknown param + request.setParams({ other: 1 }); + }); + + it("should keep the document after using adapter mapper setters", () => { + const adapter = GraphqlAdapter() + .setQueryParamsMapper(() => "a=1") + .setHeaderMapper(() => ({})) + .setPayloadMapper(() => "") + .setEndpointMapper(() => "/User"); + const custom = new Client({ url: "" }).setAdapter(adapter); + + custom.createRequest()({ endpoint: "/User", document: getUserQuery }); + + expectTypeOf>().toEqualTypeOf(); + }); + + it("should not accept document for other adapters", () => { + const http = new Client({ url: "" }).setAdapter(HttpAdapter()); + + // @ts-expect-error document is declared only by the GraphqlAdapter + http.createRequest()({ endpoint: "/User", document: "query User { id }" }); + + expectTypeOf>>().toEqualTypeOf<{}>(); + }); + + it("should keep the params strict for not specified adapters", () => { + const create = (instance: ClientInstance) => { + // @ts-expect-error endpoint is required, the unknown adapter must not turn params into any + return instance.createRequest()({ document: "query User { id }" }); + }; + + expectTypeOf(create).returns.toExtend(); + }); + + it("should extract the variables from the document literal", () => { + const USER_QUERY = "query User($id: ID!, $name: String) { user(id: $id, name: $name) { id } }"; + + expectTypeOf>().toMatchTypeOf<{ id: unknown; name?: unknown }>(); + + const request = client.createRequest<{ payload: ExtractGqlRouteParams }>()({ + endpoint: "/User", + document: USER_QUERY, + }); + + request.setPayload({ id: 1 }); + // @ts-expect-error id is required by the document + request.setPayload({ name: "a" }); + }); +}); diff --git a/packages/adapter-graphql/src/adapter/adapter.types.ts b/packages/adapter-graphql/src/adapter/adapter.types.ts index ec2baedb..ba81ab1a 100644 --- a/packages/adapter-graphql/src/adapter/adapter.types.ts +++ b/packages/adapter-graphql/src/adapter/adapter.types.ts @@ -1,4 +1,4 @@ -import type { Adapter, EmptyTypes, QueryParamsType } from "@hyper-fetch/core"; +import type { Adapter, DefaultMapperType, EmptyTypes, QueryParamsType, stringifyQueryParams } from "@hyper-fetch/core"; import type { DocumentNode } from "graphql/language/ast"; export enum GraphqlMethod { @@ -14,15 +14,27 @@ export type FetchGraphqlAdapterOptionsType = Omit string + (endpoint: GraphQlEndpointType) => string, + typeof stringifyQueryParams, + DefaultMapperType, + DefaultMapperType, + GraphqlRequestOptionsType >; // Extract diff --git a/packages/adapter-graphql/src/adapter/adapter.utils.ts b/packages/adapter-graphql/src/adapter/adapter.utils.ts index 6faa9d36..eba23a16 100644 --- a/packages/adapter-graphql/src/adapter/adapter.utils.ts +++ b/packages/adapter-graphql/src/adapter/adapter.utils.ts @@ -1,17 +1,67 @@ -import type { RequestInstance } from "@hyper-fetch/core"; +import type { EmptyTypes, QueryParamsType, RequestInstance } from "@hyper-fetch/core"; import { HttpMethods, stringifyQueryParams } from "@hyper-fetch/core"; -import type { OperationDefinitionNode } from "graphql"; +import type { DocumentNode, OperationDefinitionNode } from "graphql"; import { print, parse } from "graphql"; -import type { GraphQlEndpointType } from "./adapter.types"; +import type { GraphQlEndpointType, GraphqlRequestOptionsType } from "./adapter.types"; -export const getRequestValues = (request: R) => { +const getDocumentOption = (request: RequestInstance) => { + return (request.requestOptions as unknown as GraphqlRequestOptionsType).document; +}; + +const hasDocumentOption = (request: RequestInstance) => getDocumentOption(request) != null; + +/** The document of the request - from the `document` option or, when it is omitted, from the `endpoint` */ +export const getGqlDocument = (request: RequestInstance): GraphQlEndpointType => { + return getDocumentOption(request) ?? request.endpoint; +}; + +/** Query string keys owned by the GraphQL over HTTP GET format - they win over the user query params */ +const reservedQueryParams = ["query", "variables"]; + +const isReservedQueryParam = (param: string) => { + const [rawKey] = param.split("="); + try { + // Array formats encode the key as `key[]` or `key[0]` + return reservedQueryParams.includes(decodeURIComponent(rawKey).split("[")[0]); + } catch { + return false; + } +}; + +const omitReservedQueryParams = (queryString: string) => { + return queryString + .replace(/^\?/, "") + .split("&") + .filter((param) => !isReservedQueryParam(param)) + .join("&"); +}; + +const joinQueryStrings = (...queryStrings: string[]) => { + const joined = queryStrings + .map((queryString) => queryString.replace(/^\?/, "")) + .filter(Boolean) + .join("&"); + + return joined ? `?${joined}` : ""; +}; + +export const getRequestValues = ( + request: R, + queryParams: unknown = request.client.adapter.unstable_queryParamsMapper(request.queryParams), +) => { const isPostRequest = request.method === HttpMethods.POST; - const query = request.endpoint; + const query = gqlEndpointMapper(getGqlDocument(request)); const variables = request.payload; - const fullUrl = isPostRequest ? request.client.url : request.client.url + stringifyQueryParams({ query, variables }); + const url = hasDocumentOption(request) ? `${request.client.url}${request.endpoint}` : request.client.url; + // Mappers can return anything, so we normalize their result to the query string + const queryString = stringifyQueryParams(queryParams as QueryParamsType | string | EmptyTypes); + + const fullUrl = isPostRequest + ? url + joinQueryStrings(queryString) + : url + joinQueryStrings(stringifyQueryParams({ query, variables }), omitReservedQueryParams(queryString)); const payload = isPostRequest ? JSON.stringify({ @@ -30,25 +80,38 @@ export const gqlEndpointMapper = (endpoint: E): s return print(endpoint); }; -export const gqlEndpointNameMapper = (endpoint: string): string => { +const getOperationName = (ast: DocumentNode): string | undefined => { + const operationDefinition = ast.definitions.find( + (def): def is OperationDefinitionNode => def.kind === "OperationDefinition", + ); + + if (operationDefinition) { + if (operationDefinition.name?.value) { + return operationDefinition.name.value; + } + + const selection = operationDefinition.selectionSet.selections[0]; + if (selection?.kind === "Field") { + return selection.name.value; + } + if (selection?.kind === "InlineFragment" && selection.typeCondition) { + return selection.typeCondition.name.value; + } + } + + return undefined; +}; + +export const gqlEndpointNameMapper = (endpoint: GraphQlEndpointType): string => { + if (typeof endpoint !== "string") { + return getOperationName(endpoint) || "graphql-request"; + } + try { - const ast = parse(endpoint); - const operationDefinition = ast.definitions.find( - (def): def is OperationDefinitionNode => def.kind === "OperationDefinition", - ); - - if (operationDefinition) { - if (operationDefinition.name?.value) { - return operationDefinition.name.value; - } - - const selection = operationDefinition.selectionSet.selections[0]; - if (selection?.kind === "Field") { - return selection.name.value; - } - if (selection?.kind === "InlineFragment" && selection.typeCondition) { - return selection.typeCondition.name.value; - } + const name = getOperationName(parse(endpoint)); + + if (name) { + return name; } } catch { // We can ignore this error and just return some fallback @@ -63,3 +126,13 @@ export const gqlEndpointNameMapper = (endpoint: string): string => { return name || "graphql-request"; }; + +/** + * Name identifying the request in the cache, query and abort keys. + * With the `document` option the same operation can be sent to many paths, so the path is a part of the name. + */ +export const gqlRequestNameMapper = (request: RequestInstance): string => { + const name = gqlEndpointNameMapper(getGqlDocument(request)); + + return hasDocumentOption(request) ? `${name}${request.requestOptions.endpoint}` : name; +}; diff --git a/packages/adapter-graphql/src/adapter/http-adapter.fetch.ts b/packages/adapter-graphql/src/adapter/http-adapter.fetch.ts index 4eb84562..2becb760 100644 --- a/packages/adapter-graphql/src/adapter/http-adapter.fetch.ts +++ b/packages/adapter-graphql/src/adapter/http-adapter.fetch.ts @@ -1,5 +1,5 @@ -import type { QueryParamsType } from "@hyper-fetch/core"; -import { parseResponse, getErrorMessage, Adapter, stringifyKey } from "@hyper-fetch/core"; +import type { DefaultMapperType, QueryParamsType } from "@hyper-fetch/core"; +import { parseResponse, getErrorMessage, Adapter, stringifyKey, stringifyQueryParams } from "@hyper-fetch/core"; import { gqlExtra, defaultTimeout } from "./adapter.constants"; import type { @@ -7,9 +7,10 @@ import type { GraphQlExtraType, GraphQlEndpointType, FetchGraphqlAdapterOptionsType, + GraphqlRequestOptionsType, } from "./adapter.types"; import { GraphqlMethod } from "./adapter.types"; -import { getRequestValues, gqlEndpointMapper, gqlEndpointNameMapper } from "./adapter.utils"; +import { getRequestValues, gqlEndpointMapper, gqlEndpointNameMapper, gqlRequestNameMapper } from "./adapter.utils"; export const getGqlAdapter = (): GraphqlAdapterType => new Adapter< @@ -18,8 +19,13 @@ export const getGqlAdapter = (): GraphqlAdapterType => number, GraphQlExtraType, QueryParamsType | string, - undefined, - GraphQlEndpointType + QueryParamsType | string | undefined, + GraphQlEndpointType, + DefaultMapperType, + DefaultMapperType, + DefaultMapperType, + DefaultMapperType, + GraphqlRequestOptionsType >({ name: "graphql", defaultMethod: GraphqlMethod.POST, @@ -29,21 +35,25 @@ export const getGqlAdapter = (): GraphqlAdapterType => }) .onInitialize(({ client }) => { client.setCacheKeyMapper((request) => { - return `${request.method}_${gqlEndpointNameMapper(request.endpoint)}-${stringifyKey(request.params)}-${stringifyKey(request.queryParams)}`; + return `${request.method}_${gqlRequestNameMapper(request)}-${stringifyKey(request.params)}-${stringifyKey(request.queryParams)}`; }); client.setQueryKeyMapper((request) => { - return `${request.method}_${gqlEndpointNameMapper(request.endpoint)}-${stringifyKey(request.params)}-${stringifyKey(request.queryParams)}`; + return `${request.method}_${gqlRequestNameMapper(request)}-${stringifyKey(request.params)}-${stringifyKey(request.queryParams)}`; }); client.setAbortKeyMapper((request) => { - return `${request.method}_${gqlEndpointNameMapper(request.endpoint)}-${request.cancelable}`; + return `${request.method}_${gqlRequestNameMapper(request)}-${request.cancelable}`; }); }) .setInternalErrorMapping((error) => [error]) - .setDevtoolsEndpointGetter(gqlEndpointNameMapper) + .setDevtoolsEndpointGetter((endpoint, requestOptions) => + gqlEndpointNameMapper(requestOptions?.document ?? endpoint), + ) .setEndpointMapper(gqlEndpointMapper) + .setQueryParamsMapper(stringifyQueryParams) .setFetcher( async ({ request, + queryParams, adapterOptions, headers, onError, @@ -59,7 +69,7 @@ export const getGqlAdapter = (): GraphqlAdapterType => onRequestProgress, onSuccess, }) => { - const { fullUrl, payload } = getRequestValues(request); + const { fullUrl, payload } = getRequestValues(request, queryParams); const controller = getAbortController(); const { timeout: timeoutMs = defaultTimeout, ...restOptions } = adapterOptions || {}; From 0ddb554c1ba47079d88bfa2db7ae472c4cb377ff Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Tue, 3 Nov 2026 12:57:05 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat(testing):=20=F0=9F=8E=B8=20mock=20grap?= =?UTF-8?q?hql=20requests=20using=20the=20document=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #153 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/features/graphql.spec.ts | 57 +++++++++++++++++++ packages/testing/src/graphql/graphql.mock.ts | 35 +++++++++--- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/testing/__tests__/features/graphql.spec.ts b/packages/testing/__tests__/features/graphql.spec.ts index 3ac8400b..b9b08794 100644 --- a/packages/testing/__tests__/features/graphql.spec.ts +++ b/packages/testing/__tests__/features/graphql.spec.ts @@ -78,6 +78,63 @@ describe("Graphql Mocking [ Base ]", () => { }); }); + it("should mock a request using the document option", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/User", + document: getUserQuery, + }); + const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } }); + + const { data, error, status } = await operation.setQueryParams({ source: "app" }).send(); + + expect(expected.data).toStrictEqual(data); + expect(status).toBe(200); + expect(error).toBe(null); + }); + + it("should mock a request using a string document", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/User", + document: getUserQueryString, + }); + const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } }); + + const { data, error } = await operation.send(); + + expect(expected.data).toStrictEqual(data); + expect(error).toBe(null); + }); + + it("should mock a mutation using the document option", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse; payload: LoginMutationVariables }>()({ + endpoint: "/Login", + document: loginMutation, + }); + const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } }); + + const { data, error } = await operation.send({ payload: { username: "Kacper", password: "Kacper1234" } }); + + expect(expected.data).toStrictEqual(data); + expect(error).toBe(null); + }); + + it("should match only the path of the document request", async () => { + const operation = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/User", + document: getUserQuery, + }); + const other = client.createRequest<{ response: GetUserQueryResponse }>()({ + endpoint: "/Other", + document: getUserQuery, + }); + mockRequest(operation, { data: { username: "user", firstName: "User" } }); + const expected = mockRequest(other, { data: { username: "other", firstName: "Other" } }); + + const { data } = await other.send(); + + expect(expected.data).toStrictEqual(data); + }); + it("should mock the error status", async () => { const expected = mockRequest(request, { status: 400, error: [{ message: "Some Error" }] }); diff --git a/packages/testing/src/graphql/graphql.mock.ts b/packages/testing/src/graphql/graphql.mock.ts index a23e38e5..99c42d50 100644 --- a/packages/testing/src/graphql/graphql.mock.ts +++ b/packages/testing/src/graphql/graphql.mock.ts @@ -8,20 +8,39 @@ import { HttpResponse, delay, graphql } from "msw"; import type { MockRequestOptions } from "../http"; import { getMockSetup } from "../http/http.mock"; -const getName = (endpoint: string) => { - if (endpoint.includes("mutation")) { - return endpoint.split("mutation ")[1].split("(")[0]; +// Structural type of the DocumentNode - graphql is not a dependency of this package +type GraphqlDocument = + | string + | { definitions: readonly { kind: string; operation?: string; name?: { value: string } }[] }; + +const isMutation = (document: GraphqlDocument) => { + if (typeof document === "string") { + return document.includes("mutation"); + } + return document.definitions.some((def) => def.kind === "OperationDefinition" && def.operation === "mutation"); +}; + +const getName = (document: GraphqlDocument) => { + if (typeof document !== "string") { + const operation = document.definitions.find((def) => def.kind === "OperationDefinition"); + return operation?.name?.value as string; + } + if (document.includes("mutation")) { + return document.split("mutation ")[1].split("(")[0]; } - return endpoint.split("query ")[1].split("(")[0].split(" {")[0]; + return document.split("query ")[1].split("(")[0].split(" {")[0]; }; export const createMock = ( request: Request, options: MockRequestOptions, ) => { - const { endpoint } = request; + // With the `document` option, the endpoint is a URL path and the document holds the operation + const { document } = request.requestOptions as { document?: GraphqlDocument }; + const operation = document ?? request.endpoint; + const url = document == null ? request.client.url : `${request.client.url}${request.endpoint}`; - const name = getName(endpoint); + const name = getName(operation); const { status, delayTime, data } = getMockSetup(options, { gql: true }); @@ -41,9 +60,9 @@ export const createMock = Date: Tue, 3 Nov 2026 12:57:06 +0100 Subject: [PATCH 4/6] =?UTF-8?q?feat(hyper-flow):=20=F0=9F=8E=B8=20pass=20r?= =?UTF-8?q?equest=20options=20to=20the=20devtools=20endpoint=20getter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #153 Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/store/applications/cache-stats.store.ts | 1 + .../src/frontend/store/applications/error-stats.store.ts | 1 + .../frontend/store/applications/network-stats.store.ts | 1 + .../hyper-flow/src/frontend/store/applications/utils.ts | 9 +++++++-- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts b/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts index f916df05..ed940b38 100644 --- a/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts +++ b/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts @@ -152,6 +152,7 @@ export const useCacheStatsStore = create((set) => ({ data.request.requestOptions.endpoint, data.request.method, data.client, + data.request.requestOptions, ); if (!draft.applications[application]) { draft.applications[application] = { diff --git a/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts b/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts index 2926018e..d3f8ac52 100644 --- a/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts +++ b/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts @@ -77,6 +77,7 @@ export const useErrorStatsStore = create((set) => ({ data.request.requestOptions.endpoint, data.request.method, data.client, + data.request.requestOptions, ); const element = draft.applications[application].endpointErrorStats.get(globalEndpointAndMethod); diff --git a/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts b/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts index b7715da5..5390209d 100644 --- a/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts +++ b/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts @@ -211,6 +211,7 @@ export const useNetworkStatsStore = create<{ data.request.requestOptions.endpoint, data.request.method, data.client, + data.request.requestOptions, ); draft.applications[application].networkStats = getNetworkStats( diff --git a/packages/hyper-flow/src/frontend/store/applications/utils.ts b/packages/hyper-flow/src/frontend/store/applications/utils.ts index c713fe5e..3e4869b0 100644 --- a/packages/hyper-flow/src/frontend/store/applications/utils.ts +++ b/packages/hyper-flow/src/frontend/store/applications/utils.ts @@ -1,8 +1,13 @@ import type { ClientInstance } from "@hyper-fetch/core"; import { stringifyValue } from "@hyper-fetch/core"; -export const getEndpointAndMethod = (endpointString: string, method: string, client: ClientInstance) => { - const endpoint = client.adapter.unstable_devtoolsEndpointGetter(endpointString); +export const getEndpointAndMethod = ( + endpointString: string, + method: string, + client: ClientInstance, + requestOptions?: Record, +) => { + const endpoint = client.adapter.unstable_devtoolsEndpointGetter(endpointString, requestOptions); return `${method}-${endpoint}`; }; From 2bdcfe13bfcedbf7188a198b42baad2b62467293 Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Tue, 3 Nov 2026 12:57:08 +0100 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20document=20gra?= =?UTF-8?q?phql=20url=20paths=20and=20query=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #153 Co-Authored-By: Claude Opus 5.5 --- .../integrations/adapter-graphql/overview.mdx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/documentation/docs/integrations/adapter-graphql/overview.mdx b/documentation/docs/integrations/adapter-graphql/overview.mdx index 11b505ca..accc912c 100644 --- a/documentation/docs/integrations/adapter-graphql/overview.mdx +++ b/documentation/docs/integrations/adapter-graphql/overview.mdx @@ -136,6 +136,63 @@ This will pass the `name` variable to the `AddUser` mutation. --- +## URL path per operation + +By default, `endpoint` holds the GraphQL document and every operation goes to `client.url`. When gateways, WAF/CDN +rules, APM or access logs group the traffic by URL, pass the document separately with the `document` option. The +`endpoint` then becomes a URL path appended to `client.url`, the same way as in the `HttpAdapter`. + +```ts +const getUser = client.createRequest<{ response: User; payload: { id: number } }>()({ + endpoint: "/User", // URL path, supports params like "/tenant/:tenantId/User" + document: gql` + query User($id: ID!) { + user(id: $id) { + username + } + } + `, // string | DocumentNode, sent as `query` +}); + +await getUser.send({ payload: { id: 1 } }); +// POST {client.url}/User +// body: { query, variables: { id: 1 } } +``` + +- Without `document` nothing changes: `endpoint` is the document and the URL is `client.url`. +- The cache, query and abort keys contain the operation name from `document` and the path from `endpoint`, so the same + operation sent to two paths is not shared in the cache. +- Devtools display the operation name from `document`. +- Variables are typed with the `payload` generic, the same as without the `document` option. + +--- + +## Query params + +Query params are appended to the URL, with or without the `document` option. This lets access logs and APM filter +requests without parsing the body. + +```ts +await getUser.setQueryParams({ source: "app", build: "1.2.3" }).send({ payload: { id: 1 } }); +// POST {client.url}/User?source=app&build=1.2.3 +// body: { query, variables: { id: 1 } } +``` + +For the `GET` method they are merged with `query` and `variables` in the query string. If a key collides, `query` and +`variables` take precedence. + +They go through the query params mapper of the adapter, so you can change the format with the mapper config or replace +the mapper. It can return a query string or an object. + +```ts +import { stringifyDefaultOptions } from "@hyper-fetch/core"; + +// The config replaces the defaults, so spread them to keep the encoding +const adapter = GraphqlAdapter().setQueryParamsMapperConfig({ ...stringifyDefaultOptions, arrayFormat: "comma" }); +``` + +--- + ## Next Steps Now that you have a basic understanding of the GraphQL adapter, you can explore more advanced topics: From c2b544b357d2e290df2eaf1b7d25a4084111cd70 Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Tue, 3 Nov 2026 13:07:40 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fix(hyper-flow):=20=F0=9F=90=9B=20keep=20th?= =?UTF-8?q?e=20endpoint=20helper=20within=20the=20parameter=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getEndpointAndMethod now takes the request and the client. The extra parameter for the request options broke the max-params lint rule in CI. Refs #153 Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/store/applications/cache-stats.store.ts | 8 +------- .../src/frontend/store/applications/error-stats.store.ts | 8 +------- .../frontend/store/applications/network-stats.store.ts | 8 +------- .../hyper-flow/src/frontend/store/applications/utils.ts | 8 ++++---- 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts b/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts index ed940b38..81b56eda 100644 --- a/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts +++ b/packages/hyper-flow/src/frontend/store/applications/cache-stats.store.ts @@ -147,13 +147,7 @@ export const useCacheStatsStore = create((set) => ({ set((state) => produce(state, (draft) => { - const globalEndpointAndMethod = getEndpointAndMethod( - // api/users/:id - data.request.requestOptions.endpoint, - data.request.method, - data.client, - data.request.requestOptions, - ); + const globalEndpointAndMethod = getEndpointAndMethod(data.request, data.client); if (!draft.applications[application]) { draft.applications[application] = { generalStats: { diff --git a/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts b/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts index d3f8ac52..764b16db 100644 --- a/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts +++ b/packages/hyper-flow/src/frontend/store/applications/error-stats.store.ts @@ -72,13 +72,7 @@ export const useErrorStatsStore = create((set) => ({ } const { status } = data.response; - const globalEndpointAndMethod = getEndpointAndMethod( - // api/users/:id - data.request.requestOptions.endpoint, - data.request.method, - data.client, - data.request.requestOptions, - ); + const globalEndpointAndMethod = getEndpointAndMethod(data.request, data.client); const element = draft.applications[application].endpointErrorStats.get(globalEndpointAndMethod); if (!element) { diff --git a/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts b/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts index 5390209d..c37b5ad3 100644 --- a/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts +++ b/packages/hyper-flow/src/frontend/store/applications/network-stats.store.ts @@ -206,13 +206,7 @@ export const useNetworkStatsStore = create<{ draft.applications[application] = getNetworkInitialState(); } - const globalEndpointAndMethod = getEndpointAndMethod( - // api/users/:id - data.request.requestOptions.endpoint, - data.request.method, - data.client, - data.request.requestOptions, - ); + const globalEndpointAndMethod = getEndpointAndMethod(data.request, data.client); draft.applications[application].networkStats = getNetworkStats( draft.applications[application].networkStats, diff --git a/packages/hyper-flow/src/frontend/store/applications/utils.ts b/packages/hyper-flow/src/frontend/store/applications/utils.ts index 3e4869b0..1352123d 100644 --- a/packages/hyper-flow/src/frontend/store/applications/utils.ts +++ b/packages/hyper-flow/src/frontend/store/applications/utils.ts @@ -2,12 +2,12 @@ import type { ClientInstance } from "@hyper-fetch/core"; import { stringifyValue } from "@hyper-fetch/core"; export const getEndpointAndMethod = ( - endpointString: string, - method: string, + // api/users/:id + request: { method: string; requestOptions: { endpoint: string } & Record }, client: ClientInstance, - requestOptions?: Record, ) => { - const endpoint = client.adapter.unstable_devtoolsEndpointGetter(endpointString, requestOptions); + const { requestOptions, method } = request; + const endpoint = client.adapter.unstable_devtoolsEndpointGetter(requestOptions.endpoint, requestOptions); return `${method}-${endpoint}`; };