@khanacademy/wonder-blocks-data 2.3.4 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/dist/es/index.js +368 -429
- package/dist/index.js +457 -460
- package/docs.md +19 -13
- package/package.json +3 -3
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +40 -160
- package/src/__tests__/generated-snapshot.test.js +15 -195
- package/src/components/__tests__/data.test.js +159 -965
- package/src/components/__tests__/gql-router.test.js +64 -0
- package/src/components/__tests__/intercept-data.test.js +9 -66
- package/src/components/__tests__/track-data.test.js +6 -5
- package/src/components/data.js +9 -117
- package/src/components/data.md +38 -60
- package/src/components/gql-router.js +66 -0
- package/src/components/intercept-data.js +2 -34
- package/src/components/intercept-data.md +7 -105
- package/src/hooks/__tests__/use-data.test.js +826 -0
- package/src/hooks/__tests__/use-gql.test.js +233 -0
- package/src/hooks/use-data.js +143 -0
- package/src/hooks/use-gql.js +77 -0
- package/src/index.js +13 -9
- package/src/util/__tests__/get-gql-data-from-response.test.js +187 -0
- package/src/util/__tests__/memory-cache.test.js +134 -35
- package/src/util/__tests__/request-fulfillment.test.js +21 -36
- package/src/util/__tests__/request-handler.test.js +30 -30
- package/src/util/__tests__/request-tracking.test.js +29 -30
- package/src/util/__tests__/response-cache.test.js +521 -561
- package/src/util/__tests__/result-from-cache-entry.test.js +68 -0
- package/src/util/get-gql-data-from-response.js +69 -0
- package/src/util/gql-error.js +36 -0
- package/src/util/gql-router-context.js +6 -0
- package/src/util/gql-types.js +65 -0
- package/src/util/memory-cache.js +18 -14
- package/src/util/request-fulfillment.js +4 -0
- package/src/util/request-handler.js +2 -27
- package/src/util/request-handler.md +0 -32
- package/src/util/response-cache.js +50 -110
- package/src/util/result-from-cache-entry.js +38 -0
- package/src/util/types.js +14 -35
- package/LICENSE +0 -21
- package/src/components/__tests__/intercept-cache.test.js +0 -124
- package/src/components/__tests__/internal-data.test.js +0 -1030
- package/src/components/intercept-cache.js +0 -79
- package/src/components/intercept-cache.md +0 -103
- package/src/components/internal-data.js +0 -219
- package/src/util/__tests__/no-cache.test.js +0 -112
- package/src/util/no-cache.js +0 -67
- package/src/util/no-cache.md +0 -66
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {renderHook} from "@testing-library/react-hooks";
|
|
4
|
+
|
|
5
|
+
import * as GetGqlDataFromResponse from "../../util/get-gql-data-from-response.js";
|
|
6
|
+
import {GqlRouterContext} from "../../util/gql-router-context.js";
|
|
7
|
+
import {useGql} from "../use-gql.js";
|
|
8
|
+
|
|
9
|
+
describe("#useGql", () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.resetAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should throw if there is no GqlRouterContext available", () => {
|
|
15
|
+
// Arrange
|
|
16
|
+
|
|
17
|
+
// Act
|
|
18
|
+
const {
|
|
19
|
+
result: {error: result},
|
|
20
|
+
} = renderHook(() => useGql());
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect(result).toMatchInlineSnapshot(
|
|
24
|
+
`[GqlInternalError: No GqlRouter]`,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should return a function", () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
const gqlRouterContext = {
|
|
31
|
+
fetch: jest.fn(),
|
|
32
|
+
defaultContext: {},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
const {
|
|
37
|
+
result: {current: result},
|
|
38
|
+
} = renderHook(() => useGql(), {
|
|
39
|
+
wrapper: ({children}) => (
|
|
40
|
+
<GqlRouterContext.Provider value={gqlRouterContext}>
|
|
41
|
+
{children}
|
|
42
|
+
</GqlRouterContext.Provider>
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(result).toBeInstanceOf(Function);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("returned gqlFetch", () => {
|
|
51
|
+
it("should fetch the operation with combined context", async () => {
|
|
52
|
+
// Arrange
|
|
53
|
+
jest.spyOn(
|
|
54
|
+
GetGqlDataFromResponse,
|
|
55
|
+
"getGqlDataFromResponse",
|
|
56
|
+
).mockResolvedValue({
|
|
57
|
+
some: "data",
|
|
58
|
+
});
|
|
59
|
+
const fetchFake = jest
|
|
60
|
+
.fn()
|
|
61
|
+
.mockResolvedValue(("FAKE_RESPONSE": any));
|
|
62
|
+
const gqlRouterContext = {
|
|
63
|
+
fetch: fetchFake,
|
|
64
|
+
defaultContext: {
|
|
65
|
+
a: "defaultA",
|
|
66
|
+
b: "defaultB",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const {
|
|
70
|
+
result: {current: gqlFetch},
|
|
71
|
+
} = renderHook(() => useGql(), {
|
|
72
|
+
wrapper: ({children}) => (
|
|
73
|
+
<GqlRouterContext.Provider value={gqlRouterContext}>
|
|
74
|
+
{children}
|
|
75
|
+
</GqlRouterContext.Provider>
|
|
76
|
+
),
|
|
77
|
+
});
|
|
78
|
+
const gqlOp = {
|
|
79
|
+
type: "query",
|
|
80
|
+
id: "MyQuery",
|
|
81
|
+
};
|
|
82
|
+
const gqlOpContext = {
|
|
83
|
+
b: "overrideB",
|
|
84
|
+
};
|
|
85
|
+
const gqlOpVariables = {
|
|
86
|
+
var1: "val1",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Act
|
|
90
|
+
await gqlFetch(gqlOp, {
|
|
91
|
+
context: gqlOpContext,
|
|
92
|
+
variables: gqlOpVariables,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(fetchFake).toHaveBeenCalledWith(gqlOp, gqlOpVariables, {
|
|
97
|
+
a: "defaultA",
|
|
98
|
+
b: "overrideB",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should parse the response", async () => {
|
|
103
|
+
// Arrange
|
|
104
|
+
const getGqlDataFromResponseSpy = jest
|
|
105
|
+
.spyOn(GetGqlDataFromResponse, "getGqlDataFromResponse")
|
|
106
|
+
.mockResolvedValue({
|
|
107
|
+
some: "data",
|
|
108
|
+
});
|
|
109
|
+
const gqlRouterContext = {
|
|
110
|
+
fetch: jest.fn().mockResolvedValue(("FAKE_RESPONSE": any)),
|
|
111
|
+
defaultContext: {},
|
|
112
|
+
};
|
|
113
|
+
const {
|
|
114
|
+
result: {current: gqlFetch},
|
|
115
|
+
} = renderHook(() => useGql(), {
|
|
116
|
+
wrapper: ({children}) => (
|
|
117
|
+
<GqlRouterContext.Provider value={gqlRouterContext}>
|
|
118
|
+
{children}
|
|
119
|
+
</GqlRouterContext.Provider>
|
|
120
|
+
),
|
|
121
|
+
});
|
|
122
|
+
const gqlOp = {
|
|
123
|
+
type: "query",
|
|
124
|
+
id: "MyQuery",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
await gqlFetch(gqlOp);
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(getGqlDataFromResponseSpy).toHaveBeenCalledWith(
|
|
132
|
+
"FAKE_RESPONSE",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should reject if the response parse rejects", async () => {
|
|
137
|
+
// Arrange
|
|
138
|
+
jest.spyOn(
|
|
139
|
+
GetGqlDataFromResponse,
|
|
140
|
+
"getGqlDataFromResponse",
|
|
141
|
+
).mockRejectedValue(new Error("FAKE_ERROR"));
|
|
142
|
+
const gqlRouterContext = {
|
|
143
|
+
fetch: jest.fn().mockResolvedValue(("FAKE_RESPONSE": any)),
|
|
144
|
+
defaultContext: {},
|
|
145
|
+
};
|
|
146
|
+
const {
|
|
147
|
+
result: {current: gqlFetch},
|
|
148
|
+
} = renderHook(() => useGql(), {
|
|
149
|
+
wrapper: ({children}) => (
|
|
150
|
+
<GqlRouterContext.Provider value={gqlRouterContext}>
|
|
151
|
+
{children}
|
|
152
|
+
</GqlRouterContext.Provider>
|
|
153
|
+
),
|
|
154
|
+
});
|
|
155
|
+
const gqlOp = {
|
|
156
|
+
type: "query",
|
|
157
|
+
id: "MyQuery",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Act
|
|
161
|
+
const act = gqlFetch(gqlOp);
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
await expect(act).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
165
|
+
`"FAKE_ERROR"`,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should resolve to null if the fetch was aborted", async () => {
|
|
170
|
+
// Arrange
|
|
171
|
+
const abortError = new Error("Aborted");
|
|
172
|
+
abortError.name = "AbortError";
|
|
173
|
+
const gqlRouterContext = {
|
|
174
|
+
fetch: jest.fn().mockRejectedValue(abortError),
|
|
175
|
+
defaultContext: {},
|
|
176
|
+
};
|
|
177
|
+
const {
|
|
178
|
+
result: {current: gqlFetch},
|
|
179
|
+
} = renderHook(() => useGql(), {
|
|
180
|
+
wrapper: ({children}) => (
|
|
181
|
+
<GqlRouterContext.Provider value={gqlRouterContext}>
|
|
182
|
+
{children}
|
|
183
|
+
</GqlRouterContext.Provider>
|
|
184
|
+
),
|
|
185
|
+
});
|
|
186
|
+
const gqlOp = {
|
|
187
|
+
type: "query",
|
|
188
|
+
id: "MyQuery",
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Act
|
|
192
|
+
const result = await gqlFetch(gqlOp);
|
|
193
|
+
|
|
194
|
+
// Assert
|
|
195
|
+
expect(result).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should resolve to the response data", async () => {
|
|
199
|
+
// Arrange
|
|
200
|
+
jest.spyOn(
|
|
201
|
+
GetGqlDataFromResponse,
|
|
202
|
+
"getGqlDataFromResponse",
|
|
203
|
+
).mockResolvedValue({
|
|
204
|
+
some: "data",
|
|
205
|
+
});
|
|
206
|
+
const gqlRouterContext = {
|
|
207
|
+
fetch: jest.fn().mockResolvedValue(("FAKE_RESPONSE": any)),
|
|
208
|
+
defaultContext: {},
|
|
209
|
+
};
|
|
210
|
+
const {
|
|
211
|
+
result: {current: gqlFetch},
|
|
212
|
+
} = renderHook(() => useGql(), {
|
|
213
|
+
wrapper: ({children}) => (
|
|
214
|
+
<GqlRouterContext.Provider value={gqlRouterContext}>
|
|
215
|
+
{children}
|
|
216
|
+
</GqlRouterContext.Provider>
|
|
217
|
+
),
|
|
218
|
+
});
|
|
219
|
+
const gqlOp = {
|
|
220
|
+
type: "mutation",
|
|
221
|
+
id: "MyMutation",
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Act
|
|
225
|
+
const result = await gqlFetch(gqlOp);
|
|
226
|
+
|
|
227
|
+
// Assert
|
|
228
|
+
expect(result).toEqual({
|
|
229
|
+
some: "data",
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {Server} from "@khanacademy/wonder-blocks-core";
|
|
3
|
+
import {useState, useEffect, useContext, useRef} from "react";
|
|
4
|
+
import {RequestFulfillment} from "../util/request-fulfillment.js";
|
|
5
|
+
import InterceptContext from "../components/intercept-context.js";
|
|
6
|
+
import {TrackerContext} from "../util/request-tracking.js";
|
|
7
|
+
import {resultFromCacheEntry} from "../util/result-from-cache-entry.js";
|
|
8
|
+
import {ResponseCache} from "../util/response-cache.js";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Result,
|
|
12
|
+
IRequestHandler,
|
|
13
|
+
ValidData,
|
|
14
|
+
CacheEntry,
|
|
15
|
+
} from "../util/types.js";
|
|
16
|
+
|
|
17
|
+
export const useData = <TOptions, TData: ValidData>(
|
|
18
|
+
handler: IRequestHandler<TOptions, TData>,
|
|
19
|
+
options: TOptions,
|
|
20
|
+
): Result<TData> => {
|
|
21
|
+
// If we're server-side or hydrating, we'll have a cached entry to use.
|
|
22
|
+
// So we get that and use it to initialize our state.
|
|
23
|
+
// This works in both hydration and SSR because the very first call to
|
|
24
|
+
// this will have cached data in those cases as it will be present on the
|
|
25
|
+
// initial render - and subsequent renders on the client it will be null.
|
|
26
|
+
const cachedResult = ResponseCache.Default.getEntry<TOptions, TData>(
|
|
27
|
+
handler,
|
|
28
|
+
options,
|
|
29
|
+
);
|
|
30
|
+
const [result, setResult] = useState<?CacheEntry<TData>>(cachedResult);
|
|
31
|
+
|
|
32
|
+
// Lookup to see if there's an interceptor for the handler.
|
|
33
|
+
// If we have one, we need to replace the handler with one that
|
|
34
|
+
// uses the interceptor.
|
|
35
|
+
const interceptorMap = useContext(InterceptContext);
|
|
36
|
+
const interceptor = interceptorMap[handler.type];
|
|
37
|
+
|
|
38
|
+
// If we have an interceptor, we need to replace the handler with one that
|
|
39
|
+
// uses the interceptor. This helper function generates a new handler.
|
|
40
|
+
// We need this before we track the request as we want the interceptor
|
|
41
|
+
// to also work for tracked requests to simplify testing the server-side
|
|
42
|
+
// request fulfillment.
|
|
43
|
+
const getMaybeInterceptedHandler = () => {
|
|
44
|
+
if (interceptor == null) {
|
|
45
|
+
return handler;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fulfillRequestFn = (options) =>
|
|
49
|
+
interceptor.fulfillRequest(options) ??
|
|
50
|
+
handler.fulfillRequest(options);
|
|
51
|
+
return {
|
|
52
|
+
fulfillRequest: fulfillRequestFn,
|
|
53
|
+
getKey: (options) => handler.getKey(options),
|
|
54
|
+
type: handler.type,
|
|
55
|
+
hydrate: handler.hydrate,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// We only track data requests when we are server-side and we don't
|
|
60
|
+
// already have a result, as given by the cachedData (which is also the
|
|
61
|
+
// initial value for the result state).
|
|
62
|
+
const maybeTrack = useContext(TrackerContext);
|
|
63
|
+
if (result == null && Server.isServerSide()) {
|
|
64
|
+
maybeTrack?.(getMaybeInterceptedHandler(), options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// We need to update our request when the handler changes or the key
|
|
68
|
+
// to the options change, so we keep track of those.
|
|
69
|
+
// However, even if we are hydrating from cache, we still need to make the
|
|
70
|
+
// request at least once, so we do not initialize these references.
|
|
71
|
+
const handlerRef = useRef();
|
|
72
|
+
const keyRef = useRef();
|
|
73
|
+
const interceptorRef = useRef();
|
|
74
|
+
|
|
75
|
+
// This effect will ensure that we fulfill the request as desired.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
// If we are server-side, then just skip the effect. We track requests
|
|
78
|
+
// during SSR and fulfill them outside of the React render cycle.
|
|
79
|
+
// NOTE: This shouldn't happen since effects would not run on the server
|
|
80
|
+
// but let's be defensive - I think it makes the code clearer.
|
|
81
|
+
/* istanbul ignore next */
|
|
82
|
+
if (Server.isServerSide()) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Update our refs to the current handler and key.
|
|
87
|
+
handlerRef.current = handler;
|
|
88
|
+
keyRef.current = handler.getKey(options);
|
|
89
|
+
interceptorRef.current = interceptor;
|
|
90
|
+
|
|
91
|
+
// If we're not hydrating a result, we want to make sure we set our
|
|
92
|
+
// result to null so that we're in the loading state.
|
|
93
|
+
if (cachedResult == null) {
|
|
94
|
+
// Mark ourselves as loading.
|
|
95
|
+
setResult(null);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// We aren't server-side, so let's make the request.
|
|
99
|
+
// The request handler is in control of whether that request actually
|
|
100
|
+
// happens or not.
|
|
101
|
+
let cancel = false;
|
|
102
|
+
RequestFulfillment.Default.fulfill(
|
|
103
|
+
getMaybeInterceptedHandler(),
|
|
104
|
+
options,
|
|
105
|
+
)
|
|
106
|
+
.then((updateEntry) => {
|
|
107
|
+
if (cancel) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setResult(updateEntry);
|
|
111
|
+
return;
|
|
112
|
+
})
|
|
113
|
+
.catch((e) => {
|
|
114
|
+
if (cancel) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* We should never get here as errors in fulfillment are part
|
|
119
|
+
* of the `then`, but if we do.
|
|
120
|
+
*/
|
|
121
|
+
// eslint-disable-next-line no-console
|
|
122
|
+
console.error(
|
|
123
|
+
`Unexpected error occurred during data fulfillment: ${e}`,
|
|
124
|
+
);
|
|
125
|
+
setResult({
|
|
126
|
+
data: null,
|
|
127
|
+
error: typeof e === "string" ? e : e.message,
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return () => {
|
|
133
|
+
cancel = true;
|
|
134
|
+
};
|
|
135
|
+
// - handler.getKey is a proxy for options
|
|
136
|
+
// - We don't want to trigger on cachedResult changing, we're
|
|
137
|
+
// just using that as a flag for render state if the other things
|
|
138
|
+
// trigger this effect.
|
|
139
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
|
+
}, [handler, handler.getKey(options), interceptor]);
|
|
141
|
+
|
|
142
|
+
return resultFromCacheEntry(result);
|
|
143
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {useContext, useMemo} from "react";
|
|
3
|
+
|
|
4
|
+
import {GqlRouterContext} from "../util/gql-router-context.js";
|
|
5
|
+
import {getGqlDataFromResponse} from "../util/get-gql-data-from-response.js";
|
|
6
|
+
import {GqlError, GqlErrors} from "../util/gql-error.js";
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
GqlContext,
|
|
10
|
+
GqlOperation,
|
|
11
|
+
GqlFetchOptions,
|
|
12
|
+
GqlOperationType,
|
|
13
|
+
} from "../util/gql-types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hook to obtain a gqlFetch function for performing GraphQL requests.
|
|
17
|
+
*
|
|
18
|
+
* The fetch function will resolve null if the request was aborted, otherwise
|
|
19
|
+
* it will resolve the data returned by the GraphQL server.
|
|
20
|
+
*/
|
|
21
|
+
export const useGql = (): (<
|
|
22
|
+
TType: GqlOperationType,
|
|
23
|
+
TData,
|
|
24
|
+
TVariables: {...},
|
|
25
|
+
TContext: GqlContext,
|
|
26
|
+
>(
|
|
27
|
+
operation: GqlOperation<TType, TData, TVariables>,
|
|
28
|
+
options?: GqlFetchOptions<TVariables, TContext>,
|
|
29
|
+
) => Promise<?TData>) => {
|
|
30
|
+
// This hook only works if the `GqlRouter` has been used to setup context.
|
|
31
|
+
const gqlRouterContext = useContext(GqlRouterContext);
|
|
32
|
+
if (gqlRouterContext == null) {
|
|
33
|
+
throw new GqlError("No GqlRouter", GqlErrors.Internal);
|
|
34
|
+
}
|
|
35
|
+
const {fetch, defaultContext} = gqlRouterContext;
|
|
36
|
+
|
|
37
|
+
// Let's memoize the gqlFetch function we create based off our context.
|
|
38
|
+
// That way, even if the context happens to change, if its values don't
|
|
39
|
+
// we give the same function instance back to our callers instead of
|
|
40
|
+
// making a new one. That then means they can safely use the return value
|
|
41
|
+
// in hooks deps without fear of it triggering extra renders.
|
|
42
|
+
const gqlFetch = useMemo(
|
|
43
|
+
() =>
|
|
44
|
+
<
|
|
45
|
+
TType: GqlOperationType,
|
|
46
|
+
TData,
|
|
47
|
+
TVariables: {...},
|
|
48
|
+
TContext: GqlContext,
|
|
49
|
+
>(
|
|
50
|
+
operation: GqlOperation<TType, TData, TVariables>,
|
|
51
|
+
options: GqlFetchOptions<TVariables, TContext> = Object.freeze(
|
|
52
|
+
{},
|
|
53
|
+
),
|
|
54
|
+
) => {
|
|
55
|
+
const {variables, context} = options;
|
|
56
|
+
|
|
57
|
+
// Invoke the fetch and extract the data.
|
|
58
|
+
return fetch(operation, variables, {
|
|
59
|
+
...defaultContext,
|
|
60
|
+
...context,
|
|
61
|
+
}).then(getGqlDataFromResponse, (error) => {
|
|
62
|
+
// Return null if the request was aborted.
|
|
63
|
+
// The only way to detect this reliably, it seems, is to
|
|
64
|
+
// check the error name and see if it's "AbortError" (this
|
|
65
|
+
// is also what Apollo does).
|
|
66
|
+
// Even then, it's reliant on the fetch supporting aborts.
|
|
67
|
+
if (error.name === "AbortError") {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// Need to make sure we pass other errors along.
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
[fetch, defaultContext],
|
|
75
|
+
);
|
|
76
|
+
return gqlFetch;
|
|
77
|
+
};
|
package/src/index.js
CHANGED
|
@@ -15,7 +15,6 @@ export type {
|
|
|
15
15
|
CacheEntry,
|
|
16
16
|
Result,
|
|
17
17
|
IRequestHandler,
|
|
18
|
-
ICache,
|
|
19
18
|
ResponseCache,
|
|
20
19
|
} from "./util/types.js";
|
|
21
20
|
|
|
@@ -51,15 +50,20 @@ export const removeAllFromCache = <TOptions, TData: ValidData>(
|
|
|
51
50
|
) => boolean,
|
|
52
51
|
): number => ResCache.Default.removeAll<TOptions, TData>(handler, predicate);
|
|
53
52
|
|
|
54
|
-
/**
|
|
55
|
-
* TODO(somewhatabstract): Export each cache type we implement.
|
|
56
|
-
*
|
|
57
|
-
* Is there a base type we export, like we do for RequestHandler?
|
|
58
|
-
*/
|
|
59
|
-
|
|
60
53
|
export {default as RequestHandler} from "./util/request-handler.js";
|
|
61
54
|
export {default as TrackData} from "./components/track-data.js";
|
|
62
55
|
export {default as Data} from "./components/data.js";
|
|
63
56
|
export {default as InterceptData} from "./components/intercept-data.js";
|
|
64
|
-
export {
|
|
65
|
-
|
|
57
|
+
export {useData} from "./hooks/use-data.js";
|
|
58
|
+
|
|
59
|
+
// GraphQL
|
|
60
|
+
export {GqlRouter} from "./components/gql-router.js";
|
|
61
|
+
export {useGql} from "./hooks/use-gql.js";
|
|
62
|
+
export * from "./util/gql-error.js";
|
|
63
|
+
export type {
|
|
64
|
+
GqlContext,
|
|
65
|
+
GqlOperation,
|
|
66
|
+
GqlOperationType,
|
|
67
|
+
GqlFetchOptions,
|
|
68
|
+
GqlFetchFn,
|
|
69
|
+
} from "./util/gql-types.js";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {getGqlDataFromResponse} from "../get-gql-data-from-response.js";
|
|
3
|
+
|
|
4
|
+
describe("#getGqlDataFromReponse", () => {
|
|
5
|
+
it("should throw if the response cannot be parsed", async () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const response: any = {
|
|
8
|
+
status: 200,
|
|
9
|
+
text: jest.fn(() => Promise.resolve("BAD JSON")),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Act
|
|
13
|
+
const result = getGqlDataFromResponse(response);
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(`
|
|
17
|
+
"Failed to parse response
|
|
18
|
+
caused by
|
|
19
|
+
SyntaxError: Unexpected token B in JSON at position 0"
|
|
20
|
+
`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should include status code and body text in parse error metadata", async () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const response: any = {
|
|
26
|
+
status: 200,
|
|
27
|
+
text: jest.fn(() => Promise.resolve("BAD JSON")),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Act
|
|
31
|
+
const result = getGqlDataFromResponse(response);
|
|
32
|
+
|
|
33
|
+
// Assert
|
|
34
|
+
await expect(result).rejects.toHaveProperty("metadata", {
|
|
35
|
+
statusCode: 200,
|
|
36
|
+
bodyText: "BAD JSON",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should throw if the status code is not <300", async () => {
|
|
41
|
+
// Arrange
|
|
42
|
+
const response: any = {
|
|
43
|
+
status: 400,
|
|
44
|
+
text: jest.fn(() => Promise.resolve("{}")),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Act
|
|
48
|
+
const result = getGqlDataFromResponse(response);
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
52
|
+
`"Response unsuccessful"`,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should include status code and result in response error metadata", async () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
const response: any = {
|
|
59
|
+
status: 400,
|
|
60
|
+
text: jest.fn(() =>
|
|
61
|
+
Promise.resolve(JSON.stringify({data: "DATA"})),
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Act
|
|
66
|
+
const result = getGqlDataFromResponse(response);
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
await expect(result).rejects.toHaveProperty("metadata", {
|
|
70
|
+
statusCode: 400,
|
|
71
|
+
result: {
|
|
72
|
+
data: "DATA",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should throw if the response is malformed", async () => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const response: any = {
|
|
80
|
+
status: 200,
|
|
81
|
+
text: jest.fn(() => Promise.resolve("{}")),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
const result = getGqlDataFromResponse(response);
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
88
|
+
await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
89
|
+
`"Server response missing"`,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should include the status code and the result in the malformed response error", async () => {
|
|
94
|
+
// Arrange
|
|
95
|
+
const response: any = {
|
|
96
|
+
status: 200,
|
|
97
|
+
text: jest.fn(() =>
|
|
98
|
+
Promise.resolve(JSON.stringify({malformed: "response"})),
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Act
|
|
103
|
+
const result = getGqlDataFromResponse(response);
|
|
104
|
+
|
|
105
|
+
// Assert
|
|
106
|
+
await expect(result).rejects.toHaveProperty("metadata", {
|
|
107
|
+
statusCode: 200,
|
|
108
|
+
result: {
|
|
109
|
+
malformed: "response",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should throw if the response has GraphQL errors", async () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const response: any = {
|
|
117
|
+
status: 200,
|
|
118
|
+
text: jest.fn(() =>
|
|
119
|
+
Promise.resolve(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
data: {},
|
|
122
|
+
errors: [{message: "GraphQL error"}],
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Act
|
|
129
|
+
const result = getGqlDataFromResponse(response);
|
|
130
|
+
|
|
131
|
+
// Assert
|
|
132
|
+
await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
133
|
+
`"GraphQL errors"`,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should include the status code and result in the metadata", async () => {
|
|
138
|
+
// Arrange
|
|
139
|
+
const response: any = {
|
|
140
|
+
status: 200,
|
|
141
|
+
text: jest.fn(() =>
|
|
142
|
+
Promise.resolve(
|
|
143
|
+
JSON.stringify({
|
|
144
|
+
data: {},
|
|
145
|
+
errors: [{message: "GraphQL error"}],
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
const result = getGqlDataFromResponse(response);
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
await expect(result).rejects.toHaveProperty("metadata", {
|
|
156
|
+
statusCode: 200,
|
|
157
|
+
result: {
|
|
158
|
+
data: {},
|
|
159
|
+
errors: [{message: "GraphQL error"}],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should resolve to the response data", async () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
const response: any = {
|
|
167
|
+
status: 200,
|
|
168
|
+
text: jest.fn(() =>
|
|
169
|
+
Promise.resolve(
|
|
170
|
+
JSON.stringify({
|
|
171
|
+
data: {
|
|
172
|
+
test: "test",
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
const result = getGqlDataFromResponse(response);
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
await expect(result).resolves.toEqual({
|
|
184
|
+
test: "test",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|