Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cuddly-beers-cry.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
],
"dependencies": {
"@apollo/utils.createhash": "3.0.1",
"graphql-toe": "^1.0.0",
"tiny-invariant": "1.3.1"
},
"devDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 67 additions & 16 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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", () => {
Expand All @@ -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",
});
Expand Down Expand Up @@ -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",
Expand All @@ -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",
});
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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(
Expand All @@ -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,
{
Expand Down Expand Up @@ -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");
});
});
35 changes: 34 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +16,7 @@ import {
mergeHeaders,
type GqlResponse,
} from "./helpers";
import { toe } from "graphql-toe";

type Options = {
/**
Expand Down Expand Up @@ -60,6 +61,38 @@ type RequestOptions = {
headers?: Headers | Record<string, string>;
};

export type StrictClientFetcher = <
TResponse extends Record<string, any>,
TVariables,
>(
astNode: DocumentTypeDecoration<TResponse, TVariables>,
variables?: TVariables,
options?: RequestOptions,
) => Promise<TResponse>;

// 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 <TResponse extends Record<string, any>, TVariables>(
astNode: DocumentTypeDecoration<TResponse, TVariables>,
variables?: TVariables,
options?: RequestOptions,
): Promise<TResponse> => {
const response = await fetcher(astNode, variables, options);

return toe<TResponse>(
response as unknown as {
data?: TResponse | null | undefined;
errors?: readonly GraphQLError[] | undefined;
},
);
};
};

export type ClientFetcher = <TResponse, TVariables>(
astNode: DocumentTypeDecoration<TResponse, TVariables>,
variables?: TVariables,
Expand Down
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
6 changes: 0 additions & 6 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core";
import { createSha256, extractOperationName, pruneObject } from "helpers";

export type DocumentIdFn = <TResult, TVariables>(
query: DocumentTypeDecoration<TResult, TVariables>,
) => string | undefined;

export type GraphQLRequest<TVariables> = {
operationName: string;
query: string | undefined;
Expand Down
66 changes: 64 additions & 2 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -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(`
Expand All @@ -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");
Expand Down Expand Up @@ -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");
});
});
Loading