diff --git a/.changeset/cuddly-beers-cry.md b/.changeset/cuddly-beers-cry.md new file mode 100644 index 0000000..5fd42de --- /dev/null +++ b/.changeset/cuddly-beers-cry.md @@ -0,0 +1,5 @@ +--- +"@labdigital/graphql-fetcher": minor +--- + +Added strict server and client fetchers that use the graphql errors array and magic getters to determine if fields failed to fetch diff --git a/package.json b/package.json index 9a1d227..367bbd5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ ], "dependencies": { "@apollo/utils.createhash": "3.0.1", + "graphql-toe": "^1.0.0", "tiny-invariant": "1.3.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a20a6bd..920305a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: graphql: specifier: '>= 16.6.0' version: 16.9.0 + graphql-toe: + specifier: ^1.0.0 + version: 1.0.0 react: specifier: '>= 18.0.0' version: 18.3.1 @@ -844,6 +847,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql-toe@1.0.0: + resolution: {integrity: sha512-gu2GOt9oHaj3EAWfwYVBOeYMcvLLzeYxzCL7IBcjCwE0LRTAP4FpFnjLHMzoIKyZaRFqgqBAulKRvEfhQ7dHag==} + graphql@16.9.0: resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -2243,6 +2249,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql-toe@1.0.0: {} + graphql@16.9.0: {} has-flag@4.0.0: {} diff --git a/src/client.test.ts b/src/client.test.ts index a1983d4..b1d3b00 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -8,7 +8,7 @@ import { vi, } from "vitest"; import createFetchMock from "vitest-fetch-mock"; -import { initClientFetcher } from "./client"; +import { initClientFetcher, initStrictClientFetcher } from "./client"; import { TypedDocumentString } from "./testing"; import { createSha256 } from "helpers"; @@ -26,8 +26,34 @@ const mutation = new TypedDocumentString(/* GraphQL */ ` } `); -const response = { foo: "foo", bar: "bar" }; -const responseString = JSON.stringify(response); +const data = { foo: "foo", bar: "bar" }; +const response = { data: data, errors: undefined }; +const successResponse = JSON.stringify(response); + +const errorResponse = JSON.stringify({ + data: undefined, + errors: [{ message: "PersistedQueryNotFound" }], +}); + +const nestedErrorResponse = JSON.stringify({ + errors: [ + { + message: "Starship not found", + locations: [ + { + line: 3, + column: 3, + }, + ], + path: ["secondShip"], + }, + ], + data: { + firstShip: "3001", + secondShip: null, + }, +}); + const fetchMock = createFetchMock(vi); describe("gqlClientFetch", () => { @@ -41,7 +67,7 @@ describe("gqlClientFetch", () => { }); it("should perform a query", async () => { - const mockedFetch = fetchMock.mockResponse(responseString); + const mockedFetch = fetchMock.mockResponse(successResponse); const gqlResponse = await fetcher(query, { myVar: "baz", }); @@ -76,7 +102,7 @@ describe("gqlClientFetch", () => { }); it("should perform a persisted query when enabled", async () => { - const mockedFetch = fetchMock.mockResponse(responseString); + const mockedFetch = fetchMock.mockResponse(successResponse); const gqlResponse = await persistedFetcher(query, { myVar: "baz", @@ -99,7 +125,7 @@ describe("gqlClientFetch", () => { ); }); it("should perform a mutation", async () => { - const mockedFetch = fetchMock.mockResponse(responseString); + const mockedFetch = fetchMock.mockResponse(successResponse); const gqlResponse = await fetcher(mutation, { myVar: "baz", }); @@ -121,12 +147,7 @@ describe("gqlClientFetch", () => { }); it("should fallback to POST when persisted query is not found on the server", async () => { - const mockedFetch = fetchMock.mockResponses( - JSON.stringify({ - errors: [{ message: "PersistedQueryNotFound" }], - }), - responseString, - ); + const mockedFetch = fetchMock.mockResponses(errorResponse, successResponse); const gqlResponse = await persistedFetcher(query, { myVar: "baz", @@ -164,7 +185,7 @@ describe("gqlClientFetch", () => { it("should use time out after 30 seconds by default", async () => { const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); - fetchMock.mockResponse(responseString); + fetchMock.mockResponse(successResponse); await fetcher(query, { myVar: "baz", @@ -182,7 +203,7 @@ describe("gqlClientFetch", () => { defaultTimeout: 1, }); const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); - fetchMock.mockResponse(responseString); + fetchMock.mockResponse(successResponse); await fetcher(query, { myVar: "baz", @@ -198,7 +219,7 @@ describe("gqlClientFetch", () => { it("should use the provided signal", async () => { const fetcher = initClientFetcher("https://localhost/graphql"); - fetchMock.mockResponse(responseString); + fetchMock.mockResponse(successResponse); const controller = new AbortController(); await fetcher( @@ -221,7 +242,7 @@ describe("gqlClientFetch", () => { }); it("should allow passing extra HTTP headers", async () => { - const mockedFetch = fetchMock.mockResponse(responseString); + const mockedFetch = fetchMock.mockResponse(successResponse); const gqlResponse = await fetcher( query, { @@ -264,3 +285,33 @@ describe("gqlClientFetch", () => { ); }); }); + +describe("initStrictClientFetcher", () => { + beforeAll(() => fetchMock.enableMocks()); + afterAll(() => fetchMock.disableMocks()); + beforeEach(() => fetchMock.resetMocks()); + + it("should return the data directory if no error occurred", async () => { + const gqlClientFetch = initStrictClientFetcher("https://localhost/graphql"); + fetchMock.mockResponse(successResponse); + const gqlResponse = await gqlClientFetch(query as any, { myVar: "baz" }); + + expect(gqlResponse).toEqual(data); + }); + it("should throw an aggregate error if a generic one occurred", async () => { + const gqlClientFetch = initStrictClientFetcher("https://localhost/graphql"); + fetchMock.mockResponse(errorResponse); + const promise = gqlClientFetch(query as any, { myVar: "baz" }); + + await expect(promise).rejects.toThrow(); + }); + it("should return a response with a nested error thrown", async () => { + const gqlClientFetch = initStrictClientFetcher("https://localhost/graphql"); + fetchMock.mockResponse(nestedErrorResponse); + const result = await gqlClientFetch(query as any, { myVar: "baz" }); + + expect(result).toBeTruthy(); + expect(result.firstShip).toBe("3001"); + expect(() => result.secondShip).toThrowError("Starship not found"); + }); +}); diff --git a/src/client.ts b/src/client.ts index a8b0a11..a350616 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core"; -import { print } from "graphql"; +import { type GraphQLError, print } from "graphql"; import { isNode } from "graphql/language/ast.js"; import { createRequest, @@ -16,6 +16,7 @@ import { mergeHeaders, type GqlResponse, } from "./helpers"; +import { toe } from "graphql-toe"; type Options = { /** @@ -60,6 +61,38 @@ type RequestOptions = { headers?: Headers | Record; }; +export type StrictClientFetcher = < + TResponse extends Record, + TVariables, +>( + astNode: DocumentTypeDecoration, + variables?: TVariables, + options?: RequestOptions, +) => Promise; + +// Wraps the initServerFetcher function, which returns the result wrapped in the graphql-toe library. This will throw +// an error if a field is used that had an entry in the error response array +export const initStrictClientFetcher = ( + url: string, + options: Options = {}, +): StrictClientFetcher => { + const fetcher = initClientFetcher(url, options); + return async , TVariables>( + astNode: DocumentTypeDecoration, + variables?: TVariables, + options?: RequestOptions, + ): Promise => { + const response = await fetcher(astNode, variables, options); + + return toe( + response as unknown as { + data?: TResponse | null | undefined; + errors?: readonly GraphQLError[] | undefined; + }, + ); + }; +}; + export type ClientFetcher = ( astNode: DocumentTypeDecoration, variables?: TVariables, diff --git a/src/index.ts b/src/index.ts index b1decea..7fb2960 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ -export { initClientFetcher } from "./client"; -export type { ClientFetcher } from "./client"; +export { initClientFetcher, initStrictClientFetcher } from "./client"; +export type { ClientFetcher, StrictClientFetcher } from "./client"; export { ClientGqlFetcherProvider, useClientGqlFetcher } from "./provider"; +export { + StrictClientGqlFetcherProvider, + useStrictClientGqlFetcher, +} from "./strict-provider"; export type { GraphQLError, GqlResponse } from "./helpers"; diff --git a/src/request.ts b/src/request.ts index 6a6e42b..3322ab7 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,10 +1,4 @@ -import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core"; import { createSha256, extractOperationName, pruneObject } from "helpers"; - -export type DocumentIdFn = ( - query: DocumentTypeDecoration, -) => string | undefined; - export type GraphQLRequest = { operationName: string; query: string | undefined; diff --git a/src/server.test.ts b/src/server.test.ts index a739085..7358cad 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createSha256, pruneObject } from "./helpers"; -import { initServerFetcher } from "./server"; +import { initServerFetcher, initStrictServerFetcher } from "./server"; import { TypedDocumentString } from "./testing"; const query = new TypedDocumentString(` @@ -17,12 +17,36 @@ const queryMutation = new TypedDocumentString(` `); const hash = "e5276e0694f661ef818210402d06d249625ef169a1c2b60383acb2c42d45f7ae"; -const response = { foo: "foo", bar: "bar" }; + +const data = { foo: "foo", bar: "bar" }; +const response = { data: data, errors: undefined }; + const successResponse = JSON.stringify(response); + const errorResponse = JSON.stringify({ + data: undefined, errors: [{ message: "PersistedQueryNotFound" }], }); +const nestedErrorResponse = JSON.stringify({ + errors: [ + { + message: "Starship not found", + locations: [ + { + line: 3, + column: 3, + }, + ], + path: ["secondShip"], + }, + ], + data: { + firstShip: "3001", + secondShip: null, + }, +}); + describe("gqlServerFetch", () => { it("should fetch a persisted query", async () => { const gqlServerFetch = initServerFetcher("https://localhost/graphql"); @@ -338,3 +362,41 @@ describe("gqlServerFetch", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +describe("initStrictServerFetcher", () => { + it("should return the data directory if no error occurred", async () => { + const gqlServerFetch = initStrictServerFetcher("https://localhost/graphql"); + fetchMock.mockResponse(successResponse); + const gqlResponse = await gqlServerFetch( + query as any, + { myVar: "baz" }, + { cache: "force-cache", next: { revalidate: 900 } }, + ); + + expect(gqlResponse).toEqual(data); + }); + it("should throw an aggregate error if a generic one occurred", async () => { + const gqlServerFetch = initStrictServerFetcher("https://localhost/graphql"); + fetchMock.mockResponse(errorResponse); + const promise = gqlServerFetch( + query as any, + { myVar: "baz" }, + { cache: "force-cache", next: { revalidate: 900 } }, + ); + + await expect(promise).rejects.toThrow(); + }); + it("should return a response with a nested error thrown", async () => { + const gqlServerFetch = initStrictServerFetcher("https://localhost/graphql"); + fetchMock.mockResponse(nestedErrorResponse); + const result = await gqlServerFetch( + query as any, + { myVar: "baz" }, + { cache: "force-cache", next: { revalidate: 900 } }, + ); + + expect(result).toBeTruthy(); + expect(result.firstShip).toBe("3001"); + expect(() => result.secondShip).toThrowError("Starship not found"); + }); +}); diff --git a/src/server.ts b/src/server.ts index b5ce68e..9d8048d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { mergeHeaders, hasPersistedQueryError, } from "./helpers"; -import { print } from "graphql"; +import { print, type GraphQLError } from "graphql"; import { isNode } from "graphql/language/ast.js"; import { createRequest, @@ -19,6 +19,7 @@ import { type GraphQLRequest, isPersistedQuery, } from "./request"; +import { toe } from "graphql-toe"; type RequestOptions = { /** @@ -73,6 +74,32 @@ const tracer = trace.getTracer( process.env.PACKAGE_VERSION, ); +// Wraps the initServerFetcher function, which returns the result wrapped in the graphql-toe library. This will throw +// an error if a field is used that had an entry in the error response array +export const initStrictServerFetcher = (url: string, options: Options = {}) => { + const fetcher = initServerFetcher(url, options); + return async , TVariables>( + astNode: DocumentTypeDecoration, + variables: TVariables, + cacheOptions: CacheOptions, + requestOptions: RequestOptions = {}, + ): Promise => { + const response = await fetcher( + astNode, + variables, + cacheOptions, + requestOptions, + ); + + return toe( + response as unknown as { + data?: TResponse | null | undefined; + errors?: readonly GraphQLError[] | undefined; + }, + ); + }; +}; + export const initServerFetcher = ( url: string, diff --git a/src/strict-provider.tsx b/src/strict-provider.tsx new file mode 100644 index 0000000..a9f8d12 --- /dev/null +++ b/src/strict-provider.tsx @@ -0,0 +1,47 @@ +import type { StrictClientFetcher } from "client"; +import { createContext, useContext } from "react"; +import invariant from "tiny-invariant"; +import type { PropsWithChildren } from "react"; + +/** + * Context to provide the fetcher for the API used during client side calls + */ +export const StrictClientGqlFetcherContext = createContext< + StrictClientFetcher | undefined +>(undefined); + +export type StrictClientGqlFetcherProviderProps = PropsWithChildren<{ + fetcher: StrictClientFetcher; +}>; + +/** + * Provides the fetcher that should be used for client side calls to the React context + */ +export const StrictClientGqlFetcherProvider = ({ + children, + fetcher, +}: StrictClientGqlFetcherProviderProps) => ( + + {children} + +); + +/** + * React hook to get the fetcher that should be used for client side calls + */ +export const useStrictClientGqlFetcher = (): StrictClientFetcher => { + const context = useContext(StrictClientGqlFetcherContext); + + if (context === undefined) { + if ("production" !== process.env.NODE_ENV) { + invariant( + false, + "useStrictClientGqlFetcher must be used within a ClientGqlFetcherProvider", + ); + } else { + invariant(false); + } + } + + return context as StrictClientFetcher; +};