@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.
@@ -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,6 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import type {GqlRouterConfiguration} from "./gql-types.js";
4
+
5
+ export const GqlRouterContext: React.Context<?GqlRouterConfiguration<any>> =
6
+ React.createContext<?GqlRouterConfiguration<any>>(null);
@@ -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
+ |};