Repository: FormidableLabs/urql (ts)

Issue:
Top level extensions are not passed through
GraphQL supports an `extensions` key at the top level, as siblings of the `data` and `error` keys. This is often used for cross cutting concerns or performance metrics etc. Note this is not the error extensions.

> The response map may also contain an entry with key extensions. This entry, if set, must have a map as its value. This entry is reserved for implementors to extend the protocol however they see fit, and hence there are no additional restrictions on its contents. 
https://graphql.github.io/graphql-spec/June2018/#sec-Response-Format

Currently, this is not parsed by the fetch exchange and I think is not considered at multiple levels below that too meaning it's ultimately not accessible.

Would a PR be accepted?

Pull request:
(feat)  Add support for the top-level extensions key in the GraphQL specification
Resolves #353.

Patch:
diff --git a/docs/api.md b/docs/api.md
index fbbe6e72..ca102454 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -25,6 +25,7 @@ interface UseQueryState<T> {
   fetching: boolean;
   data?: T;
   error?: CombinedError;
+  extensions?: Record<string, any>;
 }
 ```
 
@@ -49,6 +50,7 @@ interface UseMutationState<T> {
   fetching: boolean;
   data?: T;
   error?: CombinedError;
+  extensions?: Record<string, any>;
 }
 ```
 
@@ -85,6 +87,7 @@ The hook returns a tuple of only its state:
 interface UseSubscriptionState<T> {
   data?: T;
   error?: CombinedError;
+  extensions?: Record<string, any>;
 }
 ```
 
@@ -111,6 +114,7 @@ interface UseSubscriptionState<T> {
 | fetching     | `boolean`                           | Whether the `Query` is currently waiting for a GraphQL result                                           |
 | data         | `?any`                              | The GraphQL request's result                                                                            |
 | error        | `?CombinedError`                    | The `CombinedError` containing any errors that might've occured                                         |
+| extensions   | `?Record<string, any>`              | Optional extensions that the GraphQL server may have returned.                                          |
 | executeQuery | `Partial<OperationContext> => void` | A function that can force the operation to be sent again with the given context (Useful for refetching) |
 
 ### `Mutation` (component)
@@ -131,6 +135,7 @@ interface UseSubscriptionState<T> {
 | fetching        | `boolean`                     | Whether the `Mutation` is currently waiting for a GraphQL result |
 | data            | `?any`                        | The GraphQL request's result                                     |
 | error           | `?CombinedError`              | The `CombinedError` containing any errors that might've occured  |
+| extensions      | `?Record<string, any>`        | Optional extensions that the GraphQL server may have returned.   |
 | executeMutation | `(variables: object) => void` | A function that accepts variables and starts the mutation        |
 
 ### `Subscription` (component)
@@ -148,11 +153,12 @@ interface UseSubscriptionState<T> {
 
 #### Render Props
 
-| Prop     | Type             | Description                                                     |
-| -------- | ---------------- | --------------------------------------------------------------- |
-| fetching | `boolean`        | Whether the `Subscription` is currently ongoing                 |
-| data     | `?any`           | The GraphQL subscription's data                                 |
-| error    | `?CombinedError` | The `CombinedError` containing any errors that might've occured |
+| Prop       | Type                   | Description                                                     |
+| ---------- | ---------------------- | --------------------------------------------------------------- |
+| fetching   | `boolean`              | Whether the `Subscription` is currently ongoing                 |
+| data       | `?any`                 | The GraphQL subscription's data                                 |
+| error      | `?CombinedError`       | The `CombinedError` containing any errors that might've occured |
+| extensions | `?Record<string, any>` | Optional extensions that the GraphQL server may have returned.  |
 
 ### Context components
 
@@ -332,6 +338,7 @@ type OperationResult = {
   operation: Operation, // The operation that this result is a response for
   data?: any,
   error?: CombinedError,
+  extensions?: Record<string, any>,
 };
 ```
 
diff --git a/docs/architecture.md b/docs/architecture.md
index 407cdec9..ce4e9bfd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -174,8 +174,8 @@ Every operation that enters the exchange pipeline will receive a result, either
 immediately (read: synchronously) or eventually as the result of a network
 request comes in.
 
-The raw GraphQL result from an API is typically: `{ data?: T, errors?: GraphQLError[] }`.
-And `urql`'s operation results are very similar: `{ data?: T, error?: CombinedError }`.
+The raw GraphQL result from an API is: `{ data?: T, errors?: GraphQLError[], extensions?: Record<string, any> }`.
+And `urql`'s operation results are very similar: `{ data?: T, error?: CombinedError, extensions?: Record<string, any>}`.
 
 The [`CombinedError` is a very simple wrapper](https://github.com/FormidableLabs/urql/blob/master/src/utils/error.ts)
 that has either a `networkError` property with any unexpected errors that might occur,
diff --git a/docs/basics.md b/docs/basics.md
index 2fbf8c85..aeca0d6a 100644
--- a/docs/basics.md
+++ b/docs/basics.md
@@ -364,7 +364,7 @@ the `<Subscription>` component and/or the `useSubscription()` hook.
 
 The `<Subscription>` component is extremely similar to the `<Query>`
 component. You can pass it a query and variables, and it will serve
-you render props with `data`, `error`, and `fetching`.
+you render props with `data`, `error`, `extensions`, and `fetching`.
 
 ```js
 import { Subscription } from 'urql';
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 75114ab1..479aa2f7 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -75,7 +75,7 @@ const getTodos = `
 
 const TodoList = ({ limit = 10 }) => {
   <Query query={getTodos} variables={{ limit }}>
-    {({ fetching, data, error }) => {
+    {({ fetching, data, error, extensions }) => {
       if (fetching) {
         return 'Loading...';
       } else if (error) {
@@ -97,7 +97,8 @@ const TodoList = ({ limit = 10 }) => {
 When this component is mounted it will send the `query` and `variables`
 to your GraphQL API. Here we're using `fetching` to see whether the
 request is still being sent and is loading, `error` to see whether any
-errors have come back, and finally `data` to get the result.
+errors have come back, `data` to get the result, and finally `extensions`
+to get any arbitrary extensions data the server may have optionally returned.
 
 Whenever the query or variables props change, the `<Query>` component will
 send a new request and go back into the `fetching` state.
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index 8c66e402..ce37aa94 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -74,6 +74,7 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
         return {
           data: undefined,
           error: undefined,
+          extensions: undefined,
           operation: addMetadata(operation, { cacheOutcome: 'miss' }),
         };
       })
@@ -152,13 +153,13 @@ const afterQuery = (
   resultCache: ResultCache,
   operationCache: OperationCache
 ) => (response: OperationResult) => {
-  const { operation, data } = response;
+  const { operation, data, error } = response;
 
   if (data === undefined) {
     return;
   }
 
-  resultCache.set(operation.key, response);
+  resultCache.set(operation.key, { operation, data, error });
 
   collectTypesFromResponse(response.data).forEach(typeName => {
     const operations =
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index 6b13058a..f8ff9c1f 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -116,6 +116,10 @@ const executeFetch = (operation: Operation, opts: RequestInit) => {
             response,
           })
         : undefined,
+      extensions:
+        typeof result.extensions === 'object' && result.extensions !== null
+          ? result.extensions
+          : undefined,
     }))
     .catch(err => {
       if (err.name === 'AbortError') {
@@ -129,6 +133,7 @@ const executeFetch = (operation: Operation, opts: RequestInit) => {
           networkError: err,
           response,
         }),
+        extensions: undefined,
       };
     });
 };
diff --git a/src/exchanges/ssr.ts b/src/exchanges/ssr.ts
index 278ef4c9..a5afa1a3 100644
--- a/src/exchanges/ssr.ts
+++ b/src/exchanges/ssr.ts
@@ -50,7 +50,12 @@ const deserializeResult = (
   result: SerializedResult
 ): OperationResult => {
   const { error, data } = result;
-  const deserialized: OperationResult = { operation, data, error: undefined };
+  const deserialized: OperationResult = {
+    operation,
+    data,
+    extensions: undefined,
+    error: undefined,
+  };
   if (error !== undefined) {
     deserialized.error = new CombinedError({
       networkError: new Error(error.networkError),
diff --git a/src/exchanges/subscription.ts b/src/exchanges/subscription.ts
index 11b5cd04..86846484 100644
--- a/src/exchanges/subscription.ts
+++ b/src/exchanges/subscription.ts
@@ -45,7 +45,7 @@ export interface SubscriptionOperation {
 
 export type SubscriptionForwarder = (
   operation: SubscriptionOperation
-) => ObservableLike<ExecutionResult>;
+) => ObservableLike<ExecutionResult & { extensions?: Record<string, any> }>;
 
 /** This is called to create a subscription and needs to be hooked up to a transport client. */
 export interface SubscriptionExchangeOpts {
@@ -85,11 +85,17 @@ export const subscriptionExchange = ({
                   response: undefined,
                 })
               : undefined,
+            extensions:
+              typeof result.extensions === 'object' &&
+              result.extensions !== null
+                ? result.extensions
+                : undefined,
           }),
         error: err =>
           next({
             operation,
             data: undefined,
+            extensions: undefined,
             error: new CombinedError({
               networkError: err,
               response: undefined,
diff --git a/src/hooks/useMutation.ts b/src/hooks/useMutation.ts
index 5cdc484e..054f2a69 100644
--- a/src/hooks/useMutation.ts
+++ b/src/hooks/useMutation.ts
@@ -11,6 +11,7 @@ export interface UseMutationState<T> {
   fetching: boolean;
   data?: T;
   error?: CombinedError;
+  extensions?: Record<string, any>;
 }
 
 export type UseMutationResponse<T, V> = [
@@ -27,11 +28,17 @@ export const useMutation = <T = any, V = object>(
     fetching: false,
     error: undefined,
     data: undefined,
+    extensions: undefined,
   });
 
   const executeMutation = useCallback(
     (variables?: V) => {
-      setState({ fetching: true, error: undefined, data: undefined });
+      setState({
+        fetching: true,
+        error: undefined,
+        data: undefined,
+        extensions: undefined,
+      });
 
       const request = createRequest(query, variables as any);
 
@@ -39,8 +46,8 @@ export const useMutation = <T = any, V = object>(
         client.executeMutation(request, devtoolsContext),
         toPromise
       ).then(result => {
-        const { data, error } = result;
-        setState({ fetching: false, data, error });
+        const { data, error, extensions } = result;
+        setState({ fetching: false, data, error, extensions });
         return result;
       });
     },
diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts
index 213920b0..82b7eba2 100644
--- a/src/hooks/useQuery.ts
+++ b/src/hooks/useQuery.ts
@@ -20,6 +20,7 @@ export interface UseQueryState<T> {
   fetching: boolean;
   data?: T;
   error?: CombinedError;
+  extensions?: Record<string, any>;
 }
 
 export type UseQueryResponse<T> = [
@@ -40,6 +41,7 @@ export const useQuery = <T = any, V = object>(
     fetching: false,
     data: undefined,
     error: undefined,
+    extensions: undefined,
   });
 
   // This creates a request which will keep a stable reference
@@ -58,8 +60,8 @@ export const useQuery = <T = any, V = object>(
           ...opts,
           ...devtoolsContext,
         }),
-        subscribe(({ data, error }) => {
-          setState({ fetching: false, data, error });
+        subscribe(({ data, error, extensions }) => {
+          setState({ fetching: false, data, error, extensions });
         })
       );
     },
diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts
index 87f528be..8ccb6c15 100644
--- a/src/hooks/useSubscription.ts
+++ b/src/hooks/useSubscription.ts
@@ -18,6 +18,7 @@ export interface UseSubscriptionState<T> {
   fetching: boolean;
   data?: T;
   error?: CombinedError;
+  extensions?: Record<string, any>;
 }
 
 export type UseSubscriptionResponse<T> = [UseSubscriptionState<T>];
@@ -34,6 +35,7 @@ export const useSubscription = <T = any, R = T, V = object>(
     fetching: true,
     error: undefined,
     data: undefined,
+    extensions: undefined,
   });
 
   // This creates a request which will keep a stable reference
@@ -45,11 +47,12 @@ export const useSubscription = <T = any, R = T, V = object>(
 
     [unsubscribe.current] = pipe(
       client.executeSubscription(request, devtoolsContext),
-      subscribe(({ data, error }) => {
+      subscribe(({ data, error, extensions }) => {
         setState(s => ({
           fetching: true,
           data: handler !== undefined ? handler(s.data, data) : data,
           error,
+          extensions,
         }));
       })
     );
diff --git a/src/types.ts b/src/types.ts
index 1c95278b..2a8da684 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -59,6 +59,8 @@ export interface OperationResult<Data = any> {
   data?: Data;
   /** Any errors resulting from the operation. */
   error?: CombinedError;
+  /** Optional extensions return by the Graphql server. */
+  extensions?: Record<string, any>;
 }
 
 /** Input parameters for to an Exchange factory function. */


Test patch:
diff --git a/src/exchanges/__snapshots__/fetch.test.ts.snap b/src/exchanges/__snapshots__/fetch.test.ts.snap
index fa96ef54..f855e6f7 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -4,6 +4,7 @@ exports[`on error returns error data 1`] = `
 Object {
   "data": undefined,
   "error": [CombinedError: [Network] ],
+  "extensions": undefined,
   "operation": Object {
     "context": Object {
       "fetchOptions": Object {
@@ -135,6 +136,7 @@ exports[`on error returns error data with status 400 and manual redirect mode 1`
 Object {
   "data": undefined,
   "error": [CombinedError: [Network] ],
+  "extensions": undefined,
   "operation": Object {
     "context": Object {
       "fetchOptions": [MockFunction] {
@@ -280,6 +282,7 @@ Object {
     },
   },
   "error": undefined,
+  "extensions": undefined,
   "operation": Object {
     "context": Object {
       "fetchOptions": [MockFunction] {
diff --git a/src/exchanges/__snapshots__/subscription.test.ts.snap b/src/exchanges/__snapshots__/subscription.test.ts.snap
index 8c31b9c3..546cc7ca 100644
--- a/src/exchanges/__snapshots__/subscription.test.ts.snap
+++ b/src/exchanges/__snapshots__/subscription.test.ts.snap
@@ -4,6 +4,7 @@ exports[`should return response data from forwardSubscription observable 1`] = `
 Object {
   "data": Object {},
   "error": undefined,
+  "extensions": undefined,
   "operation": Object {
     "context": Object {
       "fetchOptions": Object {
diff --git a/src/hooks/__snapshots__/useMutation.test.tsx.snap b/src/hooks/__snapshots__/useMutation.test.tsx.snap
index ab88df2e..bc1b8d00 100644
--- a/src/hooks/__snapshots__/useMutation.test.tsx.snap
+++ b/src/hooks/__snapshots__/useMutation.test.tsx.snap
@@ -4,6 +4,7 @@ exports[`on initial useEffect initialises default state 1`] = `
 Object {
   "data": undefined,
   "error": undefined,
+  "extensions": undefined,
   "fetching": false,
 }
 `;
diff --git a/src/hooks/__snapshots__/useQuery.test.tsx.snap b/src/hooks/__snapshots__/useQuery.test.tsx.snap
index 58854c49..b4db0876 100644
--- a/src/hooks/__snapshots__/useQuery.test.tsx.snap
+++ b/src/hooks/__snapshots__/useQuery.test.tsx.snap
@@ -4,6 +4,7 @@ exports[`on initial useEffect initialises default state 1`] = `
 Object {
   "data": undefined,
   "error": undefined,
+  "extensions": undefined,
   "fetching": true,
 }
 `;
diff --git a/src/hooks/__snapshots__/useSubscription.test.tsx.snap b/src/hooks/__snapshots__/useSubscription.test.tsx.snap
index 58854c49..b4db0876 100644
--- a/src/hooks/__snapshots__/useSubscription.test.tsx.snap
+++ b/src/hooks/__snapshots__/useSubscription.test.tsx.snap
@@ -4,6 +4,7 @@ exports[`on initial useEffect initialises default state 1`] = `
 Object {
   "data": undefined,
   "error": undefined,
+  "extensions": undefined,
   "fetching": true,
 }
 `;
diff --git a/src/hooks/useMutation.test.tsx b/src/hooks/useMutation.test.tsx
index 321adf5a..4e9fbc31 100644
--- a/src/hooks/useMutation.test.tsx
+++ b/src/hooks/useMutation.test.tsx
@@ -6,7 +6,7 @@ jest.mock('../client', () => {
   const mock = {
     executeMutation: jest.fn(() =>
       pipe(
-        fromValue({ data: 1, error: 2 }),
+        fromValue({ data: 1, error: 2, extensions: { i: 1 } }),
         delay(200)
       )
     ),
@@ -135,6 +135,14 @@ describe('on subscription update', () => {
     expect(state).toHaveProperty('error', 2);
   });
 
+  it('forwards extensions response', async () => {
+    const wrapper = renderer.create(<MutationUser {...props} />);
+    await execute();
+    wrapper.update(<MutationUser {...props} />);
+
+    expect(state).toHaveProperty('extensions', { i: 1 });
+  });
+
   it('sets fetching to false', async () => {
     const wrapper = renderer.create(<MutationUser {...props} />);
     wrapper.update(<MutationUser {...props} />);
diff --git a/src/hooks/useQuery.test.tsx b/src/hooks/useQuery.test.tsx
index 9fa87b27..cd03726b 100644
--- a/src/hooks/useQuery.test.tsx
+++ b/src/hooks/useQuery.test.tsx
@@ -7,7 +7,7 @@ jest.mock('../client', () => {
     executeQuery: jest.fn(() =>
       pipe(
         interval(400),
-        map((i: number) => ({ data: i, error: i + 1 }))
+        map((i: number) => ({ data: i, error: i + 1, extensions: { i: 1 } }))
       )
     ),
   };
@@ -142,6 +142,21 @@ describe('on subscription update', () => {
     }, 400);
   });
 
+  it('forwards extensions response', done => {
+    const wrapper = renderer.create(<QueryUser {...props} />);
+    /**
+     * Have to call update (without changes) in order to see the
+     * result of the state change.
+     */
+    wrapper.update(<QueryUser {...props} />);
+
+    setTimeout(() => {
+      wrapper.update(<QueryUser {...props} />);
+      expect(state).toHaveProperty('extensions', { i: 1 });
+      done();
+    }, 400);
+  });
+
   it('sets fetching to false', done => {
     const wrapper = renderer.create(<QueryUser {...props} />);
     /**