Skip to content

Commit f816df6

Browse files
committed
feat: added strict server fetcher
1 parent dc3f769 commit f816df6

File tree

4 files changed

+139
-98
lines changed

4 files changed

+139
-98
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/server.ts

Lines changed: 125 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core";
2-
import { SpanStatusCode, trace } from "@opentelemetry/api";
1+
import type {DocumentTypeDecoration} from "@graphql-typed-document-node/core";
2+
import {SpanStatusCode, trace} from "@opentelemetry/api";
33
import invariant from "tiny-invariant";
44
import {
55
getDocumentId,
@@ -10,15 +10,16 @@ import {
1010
mergeHeaders,
1111
hasPersistedQueryError,
1212
} from "./helpers";
13-
import { print } from "graphql";
14-
import { isNode } from "graphql/language/ast.js";
13+
import {print, type GraphQLError} from "graphql";
14+
import {isNode} from "graphql/language/ast.js";
1515
import {
1616
createRequest,
1717
createRequestBody,
1818
createRequestURL,
1919
type GraphQLRequest,
2020
isPersistedQuery,
2121
} from "./request";
22+
import {toe} from "graphql-toe";
2223

2324
type RequestOptions = {
2425
/**
@@ -73,6 +74,32 @@ const tracer = trace.getTracer(
7374
process.env.PACKAGE_VERSION,
7475
);
7576

77+
// Wraps the initServerFetcher function, which returns the result wrapped in the graphql-toe library. This will throw
78+
// an error if a field is used that had an entry in the error response array
79+
export const initStrictServerFetcher = (url: string, options: Options = {}) => {
80+
const fetcher = initServerFetcher(url, options);
81+
return async <TResponse extends Record<string, any>, TVariables>(
82+
astNode: DocumentTypeDecoration<TResponse, TVariables>,
83+
variables: TVariables,
84+
cacheOptions: CacheOptions,
85+
requestOptions: RequestOptions = {},
86+
): Promise<TResponse> => {
87+
const response = await fetcher(
88+
astNode,
89+
variables,
90+
cacheOptions,
91+
requestOptions,
92+
);
93+
94+
return toe<TResponse>(
95+
response as unknown as {
96+
data?: TResponse | null | undefined;
97+
errors?: readonly GraphQLError[] | undefined;
98+
},
99+
);
100+
};
101+
};
102+
76103
export const initServerFetcher =
77104
(
78105
url: string,
@@ -84,122 +111,122 @@ export const initServerFetcher =
84111
createDocumentId = getDocumentId,
85112
}: Options = {},
86113
) =>
87-
async <TResponse, TVariables>(
88-
astNode: DocumentTypeDecoration<TResponse, TVariables>,
89-
variables: TVariables,
90-
{ cache, next = {} }: CacheOptions,
91-
options: RequestOptions = {},
92-
): Promise<GqlResponse<TResponse>> => {
93-
const query = isNode(astNode) ? print(astNode) : astNode.toString();
94-
95-
const documentId = createDocumentId(astNode);
96-
const request = await createRequest(
97-
query,
98-
variables,
99-
documentId,
100-
includeQuery,
101-
);
102-
const requestOptions: RequestOptions = {
103-
...options,
104-
signal:
105-
defaultTimeout !== undefined && !options.signal
106-
? AbortSignal.timeout(defaultTimeout)
107-
: options.signal,
108-
headers: mergeHeaders({ ...defaultHeaders, ...options.headers }),
109-
};
114+
async <TResponse, TVariables>(
115+
astNode: DocumentTypeDecoration<TResponse, TVariables>,
116+
variables: TVariables,
117+
{cache, next = {}}: CacheOptions,
118+
options: RequestOptions = {},
119+
): Promise<GqlResponse<TResponse>> => {
120+
const query = isNode(astNode) ? print(astNode) : astNode.toString();
110121

111-
// When cache is disabled we always make a POST request and set the
112-
// cache to no-store to prevent any caching
113-
if (dangerouslyDisableCache) {
114-
// If we force the cache field we shouldn't set revalidate at all, it will
115-
// throw a warning otherwise
116-
delete next.revalidate;
117-
delete request.extensions?.persistedQuery;
122+
const documentId = createDocumentId(astNode);
123+
const request = await createRequest(
124+
query,
125+
variables,
126+
documentId,
127+
includeQuery,
128+
);
129+
const requestOptions: RequestOptions = {
130+
...options,
131+
signal:
132+
defaultTimeout !== undefined && !options.signal
133+
? AbortSignal.timeout(defaultTimeout)
134+
: options.signal,
135+
headers: mergeHeaders({...defaultHeaders, ...options.headers}),
136+
};
118137

119-
return tracer.startActiveSpan(request.operationName, async (span) => {
120-
try {
121-
const response = await gqlPost(
122-
url,
123-
request,
124-
{ ...next, cache: "no-store" },
125-
requestOptions,
126-
);
138+
// When cache is disabled we always make a POST request and set the
139+
// cache to no-store to prevent any caching
140+
if (dangerouslyDisableCache) {
141+
// If we force the cache field we shouldn't set revalidate at all, it will
142+
// throw a warning otherwise
143+
delete next.revalidate;
144+
delete request.extensions?.persistedQuery;
127145

128-
span.end();
129-
return response as GqlResponse<TResponse>;
130-
} catch (err: any) {
131-
span.setStatus({
132-
code: SpanStatusCode.ERROR,
133-
message: err?.message ?? String(err),
134-
});
135-
throw err;
136-
}
137-
});
138-
}
146+
return tracer.startActiveSpan(request.operationName, async (span) => {
147+
try {
148+
const response = await gqlPost(
149+
url,
150+
request,
151+
{...next, cache: "no-store"},
152+
requestOptions,
153+
);
139154

140-
// Skip automatic persisted queries if operation is a mutation
141-
const queryType = getQueryType(query);
142-
if (queryType === "mutation") {
155+
span.end();
156+
return response as GqlResponse<TResponse>;
157+
} catch (err: any) {
158+
span.setStatus({
159+
code: SpanStatusCode.ERROR,
160+
message: err?.message ?? String(err),
161+
});
162+
throw err;
163+
}
164+
});
165+
}
166+
167+
// Skip automatic persisted queries if operation is a mutation
168+
const queryType = getQueryType(query);
169+
if (queryType === "mutation") {
170+
return tracer.startActiveSpan(request.operationName, async (span) => {
171+
try {
172+
const response = await gqlPost(
173+
url,
174+
request,
175+
{cache, next},
176+
requestOptions,
177+
);
178+
179+
span.end();
180+
return response as GqlResponse<TResponse>;
181+
} catch (err: unknown) {
182+
span.setStatus({
183+
code: SpanStatusCode.ERROR,
184+
message: err instanceof Error ? err.message : String(err),
185+
});
186+
throw err;
187+
}
188+
});
189+
}
190+
191+
// Otherwise, try to get the cached query
143192
return tracer.startActiveSpan(request.operationName, async (span) => {
144193
try {
145-
const response = await gqlPost(
194+
let response = await gqlPersistedQuery(
146195
url,
147196
request,
148-
{ cache, next },
197+
{cache, next},
149198
requestOptions,
150199
);
151200

201+
// If this is not a persisted query, but we tried to use automatic
202+
// persisted queries (APQ) then we retry with a POST
203+
if (!isPersistedQuery(request) && hasPersistedQueryError(response)) {
204+
// If the cached query doesn't exist, fall back to POST request and
205+
// let the server cache it.
206+
response = await gqlPost(
207+
url,
208+
request,
209+
{cache, next},
210+
requestOptions,
211+
);
212+
}
213+
152214
span.end();
153215
return response as GqlResponse<TResponse>;
154-
} catch (err: unknown) {
216+
} catch (err: any) {
155217
span.setStatus({
156218
code: SpanStatusCode.ERROR,
157-
message: err instanceof Error ? err.message : String(err),
219+
message: err?.message ?? String(err),
158220
});
159221
throw err;
160222
}
161223
});
162-
}
163-
164-
// Otherwise, try to get the cached query
165-
return tracer.startActiveSpan(request.operationName, async (span) => {
166-
try {
167-
let response = await gqlPersistedQuery(
168-
url,
169-
request,
170-
{ cache, next },
171-
requestOptions,
172-
);
173-
174-
// If this is not a persisted query, but we tried to use automatic
175-
// persisted queries (APQ) then we retry with a POST
176-
if (!isPersistedQuery(request) && hasPersistedQueryError(response)) {
177-
// If the cached query doesn't exist, fall back to POST request and
178-
// let the server cache it.
179-
response = await gqlPost(
180-
url,
181-
request,
182-
{ cache, next },
183-
requestOptions,
184-
);
185-
}
186-
187-
span.end();
188-
return response as GqlResponse<TResponse>;
189-
} catch (err: any) {
190-
span.setStatus({
191-
code: SpanStatusCode.ERROR,
192-
message: err?.message ?? String(err),
193-
});
194-
throw err;
195-
}
196-
});
197-
};
224+
};
198225

199226
const gqlPost = async <TVariables>(
200227
url: string,
201228
request: GraphQLRequest<TVariables>,
202-
{ cache, next }: CacheOptions,
229+
{cache, next}: CacheOptions,
203230
options: RequestOptions,
204231
) => {
205232
const endpoint = new URL(url);
@@ -220,7 +247,7 @@ const gqlPost = async <TVariables>(
220247
const gqlPersistedQuery = async <TVariables>(
221248
endpoint: string,
222249
request: GraphQLRequest<TVariables>,
223-
{ cache, next }: CacheOptions,
250+
{cache, next}: CacheOptions,
224251
options: RequestOptions,
225252
) => {
226253
const url = createRequestURL(endpoint, request);

0 commit comments

Comments
 (0)