@khanacademy/wonder-blocks-data 3.0.0 → 3.1.2
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 +28 -2
- package/dist/es/index.js +204 -31
- package/dist/index.js +315 -70
- package/package.json +4 -3
- package/src/components/__tests__/gql-router.test.js +64 -0
- package/src/components/gql-router.js +66 -0
- package/src/hooks/__tests__/use-data.test.js +142 -106
- package/src/hooks/__tests__/use-gql.test.js +233 -0
- package/src/hooks/use-data.js +28 -23
- package/src/hooks/use-gql.js +77 -0
- package/src/index.js +12 -6
- package/src/util/__tests__/get-gql-data-from-response.test.js +187 -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
|
@@ -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
|
@@ -50,14 +50,20 @@ export const removeAllFromCache = <TOptions, TData: ValidData>(
|
|
|
50
50
|
) => boolean,
|
|
51
51
|
): number => ResCache.Default.removeAll<TOptions, TData>(handler, predicate);
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
* TODO(somewhatabstract): Export each cache type we implement.
|
|
55
|
-
*
|
|
56
|
-
* Is there a base type we export, like we do for RequestHandler?
|
|
57
|
-
*/
|
|
58
|
-
|
|
59
53
|
export {default as RequestHandler} from "./util/request-handler.js";
|
|
60
54
|
export {default as TrackData} from "./components/track-data.js";
|
|
61
55
|
export {default as Data} from "./components/data.js";
|
|
62
56
|
export {default as InterceptData} from "./components/intercept-data.js";
|
|
63
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
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {GqlError, GqlErrors} from "./gql-error.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate a GQL operation response and extract the data.
|
|
6
|
+
*/
|
|
7
|
+
export const getGqlDataFromResponse = async <TData>(
|
|
8
|
+
response: Response,
|
|
9
|
+
): Promise<TData> => {
|
|
10
|
+
// Get the response as text, that way we can use the text in error
|
|
11
|
+
// messaging, should our parsing fail.
|
|
12
|
+
const bodyText = await response.text();
|
|
13
|
+
let result;
|
|
14
|
+
try {
|
|
15
|
+
result = JSON.parse(bodyText);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
throw new GqlError("Failed to parse response", GqlErrors.Parse, {
|
|
18
|
+
metadata: {
|
|
19
|
+
statusCode: response.status,
|
|
20
|
+
bodyText,
|
|
21
|
+
},
|
|
22
|
+
cause: e,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check for a bad status code.
|
|
27
|
+
if (response.status >= 300) {
|
|
28
|
+
throw new GqlError("Response unsuccessful", GqlErrors.Network, {
|
|
29
|
+
metadata: {
|
|
30
|
+
statusCode: response.status,
|
|
31
|
+
result,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check that we have a valid result payload.
|
|
37
|
+
if (
|
|
38
|
+
// Flow shouldn't be warning about this.
|
|
39
|
+
// $FlowIgnore[method-unbinding]
|
|
40
|
+
!Object.prototype.hasOwnProperty.call(result, "data") &&
|
|
41
|
+
// Flow shouldn't be warning about this.
|
|
42
|
+
// $FlowIgnore[method-unbinding]
|
|
43
|
+
!Object.prototype.hasOwnProperty.call(result, "errors")
|
|
44
|
+
) {
|
|
45
|
+
throw new GqlError("Server response missing", GqlErrors.BadResponse, {
|
|
46
|
+
metadata: {
|
|
47
|
+
statusCode: response.status,
|
|
48
|
+
result,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// If the response payload has errors, throw an error.
|
|
54
|
+
if (
|
|
55
|
+
result.errors != null &&
|
|
56
|
+
Array.isArray(result.errors) &&
|
|
57
|
+
result.errors.length > 0
|
|
58
|
+
) {
|
|
59
|
+
throw new GqlError("GraphQL errors", GqlErrors.ErrorResult, {
|
|
60
|
+
metadata: {
|
|
61
|
+
statusCode: response.status,
|
|
62
|
+
result,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// We got here, so return the data.
|
|
68
|
+
return result.data;
|
|
69
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
|
|
3
|
+
import type {Metadata} from "@khanacademy/wonder-stuff-core";
|
|
4
|
+
|
|
5
|
+
type GqlErrorOptions = {|
|
|
6
|
+
metadata?: ?Metadata,
|
|
7
|
+
cause?: ?Error,
|
|
8
|
+
|};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Error kinds for GqlError.
|
|
12
|
+
*/
|
|
13
|
+
export const GqlErrors = Object.freeze({
|
|
14
|
+
...Errors,
|
|
15
|
+
Network: "Network",
|
|
16
|
+
Parse: "Parse",
|
|
17
|
+
BadResponse: "BadResponse",
|
|
18
|
+
ErrorResult: "ErrorResult",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An error from the GQL API.
|
|
23
|
+
*/
|
|
24
|
+
export class GqlError extends KindError {
|
|
25
|
+
constructor(
|
|
26
|
+
message: string,
|
|
27
|
+
kind: $Values<typeof GqlErrors>,
|
|
28
|
+
{metadata, cause}: GqlErrorOptions = ({}: $Shape<GqlErrorOptions>),
|
|
29
|
+
) {
|
|
30
|
+
super(message, kind, {
|
|
31
|
+
metadata,
|
|
32
|
+
cause,
|
|
33
|
+
prefix: "Gql",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
/**
|
|
3
|
+
* Operation types.
|
|
4
|
+
*/
|
|
5
|
+
export type GqlOperationType = "mutation" | "query";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A GraphQL operation.
|
|
9
|
+
*/
|
|
10
|
+
export type GqlOperation<
|
|
11
|
+
TType: GqlOperationType,
|
|
12
|
+
// TData is not used to define a field on this type, but it is used
|
|
13
|
+
// to ensure that calls using this operation will properly return the
|
|
14
|
+
// correct data type.
|
|
15
|
+
// eslint-disable-next-line no-unused-vars
|
|
16
|
+
TData,
|
|
17
|
+
// TVariables is not used to define a field on this type, but it is used
|
|
18
|
+
// to ensure that calls using this operation will properly consume the
|
|
19
|
+
// correct variables type.
|
|
20
|
+
// eslint-disable-next-line no-unused-vars
|
|
21
|
+
TVariables: {...} = Empty,
|
|
22
|
+
> = {
|
|
23
|
+
type: TType,
|
|
24
|
+
id: string,
|
|
25
|
+
// We allow other things here to be passed along to the fetch function.
|
|
26
|
+
// For example, we might want to pass the full query/mutation definition
|
|
27
|
+
// as a string here to allow that to be sent to an Apollo server that
|
|
28
|
+
// expects it. This is a courtesy to calling code; these additional
|
|
29
|
+
// values are ignored by WB Data, and passed through as-is.
|
|
30
|
+
...
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type GqlContext = {|
|
|
34
|
+
[key: string]: string,
|
|
35
|
+
|};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Functions that make fetches of GQL operations.
|
|
39
|
+
*/
|
|
40
|
+
export type GqlFetchFn<
|
|
41
|
+
TType,
|
|
42
|
+
TData,
|
|
43
|
+
TVariables: {...},
|
|
44
|
+
TContext: GqlContext,
|
|
45
|
+
> = (
|
|
46
|
+
operation: GqlOperation<TType, TData, TVariables>,
|
|
47
|
+
variables: ?TVariables,
|
|
48
|
+
context: TContext,
|
|
49
|
+
) => Promise<Response>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The configuration stored in the GqlRouterContext context.
|
|
53
|
+
*/
|
|
54
|
+
export type GqlRouterConfiguration<TContext: GqlContext> = {|
|
|
55
|
+
fetch: GqlFetchFn<any, any, any, any>,
|
|
56
|
+
defaultContext: TContext,
|
|
57
|
+
|};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Options for configuring a GQL fetch.
|
|
61
|
+
*/
|
|
62
|
+
export type GqlFetchOptions<TVariables: {...}, TContext: GqlContext> = {|
|
|
63
|
+
variables?: TVariables,
|
|
64
|
+
context?: Partial<TContext>,
|
|
65
|
+
|};
|