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: 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 || {}; 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< 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..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,12 +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, - ); + 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 2926018e..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,12 +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, - ); + 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 b7715da5..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,12 +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, - ); + 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 c713fe5e..1352123d 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 = ( + // api/users/:id + request: { method: string; requestOptions: { endpoint: string } & Record }, + client: ClientInstance, +) => { + const { requestOptions, method } = request; + const endpoint = client.adapter.unstable_devtoolsEndpointGetter(requestOptions.endpoint, requestOptions); return `${method}-${endpoint}`; }; 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 =