Skip to content

Commit cc92fdd

Browse files
committed
feat: added strict server and client fetchers
1 parent dc3f769 commit cc92fdd

File tree

10 files changed

+260
-28
lines changed

10 files changed

+260
-28
lines changed

.changeset/cuddly-beers-cry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@labdigital/graphql-fetcher": minor
3+
---
4+
5+
Added strict server and client fetchers that use the graphql errors array and magic getters to determine if fields failed to fetch

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
],
4848
"dependencies": {
4949
"@apollo/utils.createhash": "3.0.1",
50+
"graphql-toe": "^1.0.0",
5051
"tiny-invariant": "1.3.1"
5152
},
5253
"devDependencies": {

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client.test.ts

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
vi,
99
} from "vitest";
1010
import createFetchMock from "vitest-fetch-mock";
11-
import { initClientFetcher } from "./client";
11+
import { initClientFetcher, initStrictClientFetcher } from "./client";
1212
import { TypedDocumentString } from "./testing";
1313
import { createSha256 } from "helpers";
1414

@@ -26,8 +26,34 @@ const mutation = new TypedDocumentString(/* GraphQL */ `
2626
}
2727
`);
2828

29-
const response = { foo: "foo", bar: "bar" };
30-
const responseString = JSON.stringify(response);
29+
const data = { foo: "foo", bar: "bar" };
30+
const response = { data: data, errors: undefined };
31+
const successResponse = JSON.stringify(response);
32+
33+
const errorResponse = JSON.stringify({
34+
data: undefined,
35+
errors: [{ message: "PersistedQueryNotFound" }],
36+
});
37+
38+
const nestedErrorResponse = JSON.stringify({
39+
errors: [
40+
{
41+
message: "Starship not found",
42+
locations: [
43+
{
44+
line: 3,
45+
column: 3,
46+
},
47+
],
48+
path: ["secondShip"],
49+
},
50+
],
51+
data: {
52+
firstShip: "3001",
53+
secondShip: null,
54+
},
55+
});
56+
3157
const fetchMock = createFetchMock(vi);
3258

3359
describe("gqlClientFetch", () => {
@@ -41,7 +67,7 @@ describe("gqlClientFetch", () => {
4167
});
4268

4369
it("should perform a query", async () => {
44-
const mockedFetch = fetchMock.mockResponse(responseString);
70+
const mockedFetch = fetchMock.mockResponse(successResponse);
4571
const gqlResponse = await fetcher(query, {
4672
myVar: "baz",
4773
});
@@ -76,7 +102,7 @@ describe("gqlClientFetch", () => {
76102
});
77103

78104
it("should perform a persisted query when enabled", async () => {
79-
const mockedFetch = fetchMock.mockResponse(responseString);
105+
const mockedFetch = fetchMock.mockResponse(successResponse);
80106

81107
const gqlResponse = await persistedFetcher(query, {
82108
myVar: "baz",
@@ -99,7 +125,7 @@ describe("gqlClientFetch", () => {
99125
);
100126
});
101127
it("should perform a mutation", async () => {
102-
const mockedFetch = fetchMock.mockResponse(responseString);
128+
const mockedFetch = fetchMock.mockResponse(successResponse);
103129
const gqlResponse = await fetcher(mutation, {
104130
myVar: "baz",
105131
});
@@ -121,12 +147,7 @@ describe("gqlClientFetch", () => {
121147
});
122148

123149
it("should fallback to POST when persisted query is not found on the server", async () => {
124-
const mockedFetch = fetchMock.mockResponses(
125-
JSON.stringify({
126-
errors: [{ message: "PersistedQueryNotFound" }],
127-
}),
128-
responseString,
129-
);
150+
const mockedFetch = fetchMock.mockResponses(errorResponse, successResponse);
130151

131152
const gqlResponse = await persistedFetcher(query, {
132153
myVar: "baz",
@@ -164,7 +185,7 @@ describe("gqlClientFetch", () => {
164185

165186
it("should use time out after 30 seconds by default", async () => {
166187
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
167-
fetchMock.mockResponse(responseString);
188+
fetchMock.mockResponse(successResponse);
168189

169190
await fetcher(query, {
170191
myVar: "baz",
@@ -182,7 +203,7 @@ describe("gqlClientFetch", () => {
182203
defaultTimeout: 1,
183204
});
184205
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
185-
fetchMock.mockResponse(responseString);
206+
fetchMock.mockResponse(successResponse);
186207

187208
await fetcher(query, {
188209
myVar: "baz",
@@ -198,7 +219,7 @@ describe("gqlClientFetch", () => {
198219

199220
it("should use the provided signal", async () => {
200221
const fetcher = initClientFetcher("https://localhost/graphql");
201-
fetchMock.mockResponse(responseString);
222+
fetchMock.mockResponse(successResponse);
202223

203224
const controller = new AbortController();
204225
await fetcher(
@@ -221,7 +242,7 @@ describe("gqlClientFetch", () => {
221242
});
222243

223244
it("should allow passing extra HTTP headers", async () => {
224-
const mockedFetch = fetchMock.mockResponse(responseString);
245+
const mockedFetch = fetchMock.mockResponse(successResponse);
225246
const gqlResponse = await fetcher(
226247
query,
227248
{
@@ -264,3 +285,33 @@ describe("gqlClientFetch", () => {
264285
);
265286
});
266287
});
288+
289+
describe("initStrictClientFetcher", () => {
290+
beforeAll(() => fetchMock.enableMocks());
291+
afterAll(() => fetchMock.disableMocks());
292+
beforeEach(() => fetchMock.resetMocks());
293+
294+
it("should return the data directory if no error occurred", async () => {
295+
const gqlClientFetch = initStrictClientFetcher("https://localhost/graphql");
296+
fetchMock.mockResponse(successResponse);
297+
const gqlResponse = await gqlClientFetch(query as any, { myVar: "baz" });
298+
299+
expect(gqlResponse).toEqual(data);
300+
});
301+
it("should throw an aggregate error if a generic one occurred", async () => {
302+
const gqlClientFetch = initStrictClientFetcher("https://localhost/graphql");
303+
fetchMock.mockResponse(errorResponse);
304+
const promise = gqlClientFetch(query as any, { myVar: "baz" });
305+
306+
await expect(promise).rejects.toThrow();
307+
});
308+
it("should return a response with a nested error thrown", async () => {
309+
const gqlClientFetch = initStrictClientFetcher("https://localhost/graphql");
310+
fetchMock.mockResponse(nestedErrorResponse);
311+
const result = await gqlClientFetch(query as any, { myVar: "baz" });
312+
313+
expect(result).toBeTruthy();
314+
expect(result.firstShip).toBe("3001");
315+
expect(() => result.secondShip).toThrowError("Starship not found");
316+
});
317+
});

src/client.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core";
2-
import { print } from "graphql";
2+
import { type GraphQLError, print } from "graphql";
33
import { isNode } from "graphql/language/ast.js";
44
import {
55
createRequest,
@@ -16,6 +16,7 @@ import {
1616
mergeHeaders,
1717
type GqlResponse,
1818
} from "./helpers";
19+
import { toe } from "graphql-toe";
1920

2021
type Options = {
2122
/**
@@ -60,6 +61,38 @@ type RequestOptions = {
6061
headers?: Headers | Record<string, string>;
6162
};
6263

64+
export type StrictClientFetcher = <
65+
TResponse extends Record<string, any>,
66+
TVariables,
67+
>(
68+
astNode: DocumentTypeDecoration<TResponse, TVariables>,
69+
variables?: TVariables,
70+
options?: RequestOptions,
71+
) => Promise<TResponse>;
72+
73+
// Wraps the initServerFetcher function, which returns the result wrapped in the graphql-toe library. This will throw
74+
// an error if a field is used that had an entry in the error response array
75+
export const initStrictClientFetcher = (
76+
url: string,
77+
options: Options = {},
78+
): StrictClientFetcher => {
79+
const fetcher = initClientFetcher(url, options);
80+
return async <TResponse extends Record<string, any>, TVariables>(
81+
astNode: DocumentTypeDecoration<TResponse, TVariables>,
82+
variables?: TVariables,
83+
options?: RequestOptions,
84+
): Promise<TResponse> => {
85+
const response = await fetcher(astNode, variables, options);
86+
87+
return toe<TResponse>(
88+
response as unknown as {
89+
data?: TResponse | null | undefined;
90+
errors?: readonly GraphQLError[] | undefined;
91+
},
92+
);
93+
};
94+
};
95+
6396
export type ClientFetcher = <TResponse, TVariables>(
6497
astNode: DocumentTypeDecoration<TResponse, TVariables>,
6598
variables?: TVariables,

src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
export { initClientFetcher } from "./client";
2-
export type { ClientFetcher } from "./client";
1+
export { initClientFetcher, initStrictClientFetcher } from "./client";
2+
export type { ClientFetcher, StrictClientFetcher } from "./client";
33
export { ClientGqlFetcherProvider, useClientGqlFetcher } from "./provider";
4+
export {
5+
StrictClientGqlFetcherProvider,
6+
useStrictClientGqlFetcher,
7+
} from "./strict-provider";
48
export type { GraphQLError, GqlResponse } from "./helpers";

src/request.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core";
21
import { createSha256, extractOperationName, pruneObject } from "helpers";
3-
4-
export type DocumentIdFn = <TResult, TVariables>(
5-
query: DocumentTypeDecoration<TResult, TVariables>,
6-
) => string | undefined;
7-
82
export type GraphQLRequest<TVariables> = {
93
operationName: string;
104
query: string | undefined;

src/server.test.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { createSha256, pruneObject } from "./helpers";
3-
import { initServerFetcher } from "./server";
3+
import { initServerFetcher, initStrictServerFetcher } from "./server";
44
import { TypedDocumentString } from "./testing";
55

66
const query = new TypedDocumentString(`
@@ -17,12 +17,36 @@ const queryMutation = new TypedDocumentString(`
1717
`);
1818

1919
const hash = "e5276e0694f661ef818210402d06d249625ef169a1c2b60383acb2c42d45f7ae";
20-
const response = { foo: "foo", bar: "bar" };
20+
21+
const data = { foo: "foo", bar: "bar" };
22+
const response = { data: data, errors: undefined };
23+
2124
const successResponse = JSON.stringify(response);
25+
2226
const errorResponse = JSON.stringify({
27+
data: undefined,
2328
errors: [{ message: "PersistedQueryNotFound" }],
2429
});
2530

31+
const nestedErrorResponse = JSON.stringify({
32+
errors: [
33+
{
34+
message: "Starship not found",
35+
locations: [
36+
{
37+
line: 3,
38+
column: 3,
39+
},
40+
],
41+
path: ["secondShip"],
42+
},
43+
],
44+
data: {
45+
firstShip: "3001",
46+
secondShip: null,
47+
},
48+
});
49+
2650
describe("gqlServerFetch", () => {
2751
it("should fetch a persisted query", async () => {
2852
const gqlServerFetch = initServerFetcher("https://localhost/graphql");
@@ -338,3 +362,41 @@ describe("gqlServerFetch", () => {
338362
expect(fetchMock).toHaveBeenCalledTimes(1);
339363
});
340364
});
365+
366+
describe("initStrictServerFetcher", () => {
367+
it("should return the data directory if no error occurred", async () => {
368+
const gqlServerFetch = initStrictServerFetcher("https://localhost/graphql");
369+
fetchMock.mockResponse(successResponse);
370+
const gqlResponse = await gqlServerFetch(
371+
query as any,
372+
{ myVar: "baz" },
373+
{ cache: "force-cache", next: { revalidate: 900 } },
374+
);
375+
376+
expect(gqlResponse).toEqual(data);
377+
});
378+
it("should throw an aggregate error if a generic one occurred", async () => {
379+
const gqlServerFetch = initStrictServerFetcher("https://localhost/graphql");
380+
fetchMock.mockResponse(errorResponse);
381+
const promise = gqlServerFetch(
382+
query as any,
383+
{ myVar: "baz" },
384+
{ cache: "force-cache", next: { revalidate: 900 } },
385+
);
386+
387+
await expect(promise).rejects.toThrow();
388+
});
389+
it("should return a response with a nested error thrown", async () => {
390+
const gqlServerFetch = initStrictServerFetcher("https://localhost/graphql");
391+
fetchMock.mockResponse(nestedErrorResponse);
392+
const result = await gqlServerFetch(
393+
query as any,
394+
{ myVar: "baz" },
395+
{ cache: "force-cache", next: { revalidate: 900 } },
396+
);
397+
398+
expect(result).toBeTruthy();
399+
expect(result.firstShip).toBe("3001");
400+
expect(() => result.secondShip).toThrowError("Starship not found");
401+
});
402+
});

0 commit comments

Comments
 (0)