Repository: FormidableLabs/urql
Pull request #198 (2019-02-22 16:04:45)

Add data generic to OperationResult and mutation result


Patch:
diff --git a/src/client.ts b/src/client.ts
index 533889eb..15a85ce6 100755
--- a/src/client.ts
+++ b/src/client.ts
@@ -46,7 +46,7 @@ export const createClient = (opts: ClientOptions) => new Client(opts);
 export class Client {
   // These are variables derived from ClientOptions
   url: string;
-  fetchOptions?: RequestInit | (() => RequestInit);
+  fetchOptions: RequestInit;
   exchange: Exchange;
 
   // These are internals to be used to keep track of operations
@@ -57,7 +57,11 @@ export class Client {
 
   constructor(opts: ClientOptions) {
     this.url = opts.url;
-    this.fetchOptions = opts.fetchOptions;
+
+    this.fetchOptions =
+      typeof opts.fetchOptions === 'function'
+        ? opts.fetchOptions()
+        : opts.fetchOptions || {};
 
     // This subject forms the input of operations; executeOperation may be
     // called to dispatch a new operation on the subject
diff --git a/src/components/Mutation.tsx b/src/components/Mutation.tsx
index eb2fd50c..ed05490e 100644
--- a/src/components/Mutation.tsx
+++ b/src/components/Mutation.tsx
@@ -18,7 +18,9 @@ interface MutationHandlerState {
 }
 
 interface MutationChildProps extends MutationHandlerState {
-  executeMutation: (variables?: object) => Promise<OperationResult>;
+  executeMutation: <T = any, V = object>(
+    variables?: V
+  ) => Promise<OperationResult<T>>;
 }
 
 class MutationHandler extends Component<
@@ -38,14 +40,14 @@ class MutationHandler extends Component<
     });
   }
 
-  executeMutation = (variables?: object): Promise<OperationResult> => {
+  executeMutation: MutationChildProps['executeMutation'] = variables => {
     this.setState({
       fetching: true,
       error: undefined,
       data: undefined,
     });
 
-    const request = createRequest(this.props.query, variables);
+    const request = createRequest(this.props.query, variables as any);
 
     return pipe(
       this.props.client.executeMutation(request),
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index 31fc0996..95565946 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -2,7 +2,7 @@ import { filter, map, merge, pipe, share, tap } from 'wonka';
 
 import { Client } from '../client';
 import { Exchange, Operation, OperationResult } from '../types';
-import { collectTypesFromResponse, formatDocument } from '../utils/typenames';
+import { formatDocument, gankTypeNamesFromResponse } from '../utils/typenames';
 
 type ResultCache = Map<number, OperationResult>;
 
@@ -116,7 +116,7 @@ export const afterMutation = (
 ) => (response: OperationResult) => {
   const pendingOperations = new Set<number>();
 
-  collectTypesFromResponse(response.data).forEach(typeName => {
+  gankTypeNamesFromResponse(response.data).forEach(typeName => {
     const operations =
       operationCache[typeName] || (operationCache[typeName] = new Set());
     operations.forEach(key => pendingOperations.add(key));
@@ -145,7 +145,7 @@ const afterQuery = (
 
   resultCache.set(key, response);
 
-  collectTypesFromResponse(response.data).forEach(typeName => {
+  gankTypeNamesFromResponse(response.data).forEach(typeName => {
     const operations =
       operationCache[typeName] || (operationCache[typeName] = new Set());
     operations.add(key);
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index 35437aab..411c5ffa 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -52,26 +52,16 @@ const createFetchSource = (operation: Operation) => {
         ? new AbortController()
         : undefined;
 
-    const { context } = operation;
-
-    const extraOptions =
-      typeof context.fetchOptions === 'function'
-        ? context.fetchOptions()
-        : context.fetchOptions || {};
-
     const fetchOptions = {
       body: JSON.stringify({
         query: print(operation.query),
         variables: operation.variables,
       }),
+      headers: { 'Content-Type': 'application/json' },
       method: 'POST',
-      ...extraOptions,
-      headers: {
-        'content-type': 'application/json',
-        ...extraOptions.headers,
-      },
       signal:
         abortController !== undefined ? abortController.signal : undefined,
+      ...operation.context.fetchOptions,
     };
 
     executeFetch(operation, fetchOptions).then(result => {
diff --git a/src/hooks/useMutation.ts b/src/hooks/useMutation.ts
index b686e17b..3c7e7078 100644
--- a/src/hooks/useMutation.ts
+++ b/src/hooks/useMutation.ts
@@ -13,7 +13,7 @@ interface UseMutationState<T> {
 
 type UseMutationResponse<T, V> = [
   UseMutationState<T>,
-  (variables?: V) => Promise<OperationResult>
+  (variables?: V) => Promise<OperationResult<T>>
 ];
 
 export const useMutation = <T = any, V = object>(
diff --git a/src/types.ts b/src/types.ts
index 696ac2dd..5d3e7e40 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -26,7 +26,7 @@ export interface GraphQLRequest {
 /** Additional metadata passed to [exchange]{@link Exchange} functions. */
 export interface OperationContext {
   [key: string]: any;
-  fetchOptions?: RequestInit | (() => RequestInit);
+  fetchOptions?: RequestInit;
   requestPolicy: RequestPolicy;
   url: string;
 }
@@ -41,11 +41,11 @@ export interface Operation {
 }
 
 /** Resulting data from an [operation]{@link Operation}. */
-export interface OperationResult {
+export interface OperationResult<Data = any> {
   /** The [operation]{@link Operation} which has been executed. */
   operation: Operation;
   /** The data returned from the Graphql server. */
-  data?: any;
+  data?: Data;
   /** Any errors resulting from the operation. */
   error?: CombinedError;
 }
diff --git a/src/utils/error.ts b/src/utils/error.ts
index f9470c05..2384aecf 100644
--- a/src/utils/error.ts
+++ b/src/utils/error.ts
@@ -1,4 +1,6 @@
-import { GraphQLError } from 'graphql';
+export interface GraphQLError {
+  message?: string;
+}
 
 const generateErrorMessage = (
   networkErr?: Error,
@@ -19,29 +21,21 @@ const generateErrorMessage = (
   return error.trim();
 };
 
-const rehydrateGraphQlError = (error: any): GraphQLError => {
+const rehydrateGraphQlError = (error: string | GraphQLError): Error => {
   if (typeof error === 'string') {
-    return new GraphQLError(error);
-  } else if (typeof error === 'object' && error.message) {
-    return new GraphQLError(
-      error.message,
-      error.nodes,
-      error.source,
-      error.positions,
-      error.path,
-      error.originalError,
-      error.extensions || {}
-    );
+    return new Error(error);
+  } else if (error.message) {
+    return new Error(error.message);
   } else {
     return error as any;
   }
 };
 
 /** An error which can consist of GraphQL errors and Network errors. */
-export class CombinedError extends Error {
+export class CombinedError implements Error {
   public name: string;
   public message: string;
-  public graphQLErrors: GraphQLError[];
+  public graphQLErrors: Error[];
   public networkError?: Error;
   public response?: any;
 
@@ -51,24 +45,13 @@ export class CombinedError extends Error {
     response,
   }: {
     networkError?: Error;
-    graphQLErrors?: Array<string | GraphQLError | Error>;
+    graphQLErrors?: Array<string | GraphQLError>;
     response?: any;
   }) {
-    const normalisedGraphQLErrors = (graphQLErrors || []).map(
-      rehydrateGraphQlError
-    );
-    const message = generateErrorMessage(networkError, normalisedGraphQLErrors);
-
-    super(message);
-
     this.name = 'CombinedError';
-    this.message = message;
-    this.graphQLErrors = normalisedGraphQLErrors;
+    this.graphQLErrors = (graphQLErrors || []).map(rehydrateGraphQlError);
+    this.message = generateErrorMessage(networkError, this.graphQLErrors);
     this.networkError = networkError;
     this.response = response;
   }
-
-  toString() {
-    return this.message;
-  }
 }
diff --git a/src/utils/index.ts b/src/utils/index.ts
index a70396e7..ef4f0725 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,7 +1,7 @@
 export { CombinedError } from './error';
 export { getKeyForRequest } from './keyForQuery';
 export { createRequest } from './request';
-export { formatDocument, collectTypesFromResponse } from './typenames';
+export { formatDocument, gankTypeNamesFromResponse } from './typenames';
 
 export const noop = () => {
   /* noop */
diff --git a/src/utils/typenames.ts b/src/utils/typenames.ts
index 2fa9cc2b..d837f78e 100755
--- a/src/utils/typenames.ts
+++ b/src/utils/typenames.ts
@@ -6,33 +6,22 @@ import {
   visit,
 } from 'graphql';
 
-interface EntityLike {
-  [key: string]: EntityLike | EntityLike[] | any;
-  __typename: string | null | void;
-}
+const getTypeNameFromField = (obj: object) =>
+  Object.values(obj).reduce(
+    (all, val) =>
+      typeof val !== 'object' || val === null
+        ? all
+        : val.__typename !== undefined
+        ? { ...all, [val.__typename]: true }
+        : { ...all, ...getTypeNameFromField(val) },
+    {}
+  );
 
-const collectTypes = (obj: EntityLike | EntityLike[], types: string[] = []) => {
-  if (Array.isArray(obj)) {
-    obj.forEach(inner => collectTypes(inner, types));
-  } else if (typeof obj === 'object' && obj !== null) {
-    for (const key in obj) {
-      if (obj.hasOwnProperty(key)) {
-        const val = obj[key];
-        if (key === '__typename' && typeof val === 'string') {
-          types.push(val);
-        } else if (typeof val === 'object' && val !== null) {
-          collectTypes(val, types);
-        }
-      }
-    }
-  }
-
-  return types;
+export const gankTypeNamesFromResponse = (response: object) => {
+  const typeNames = Object.keys(getTypeNameFromField(response));
+  return [...typeNames].filter((v, i, a) => a.indexOf(v) === i);
 };
 
-export const collectTypesFromResponse = (response: object) =>
-  collectTypes(response as EntityLike).filter((v, i, a) => a.indexOf(v) === i);
-
 const formatNode = (
   n: FieldNode | InlineFragmentNode | OperationDefinitionNode
 ) =>


Test patch:
diff --git a/src/__snapshots__/client.test.ts.snap b/src/__snapshots__/client.test.ts.snap
index 30f757e4..f15a65f4 100644
--- a/src/__snapshots__/client.test.ts.snap
+++ b/src/__snapshots__/client.test.ts.snap
@@ -10,7 +10,7 @@ Client {
   "executeMutation": [Function],
   "executeQuery": [Function],
   "executeSubscription": [Function],
-  "fetchOptions": undefined,
+  "fetchOptions": Object {},
   "operations$": [Function],
   "reexecuteOperation": [Function],
   "results$": [Function],
diff --git a/src/__snapshots__/context.test.ts.snap b/src/__snapshots__/context.test.ts.snap
index 277618e7..ac40de0b 100644
--- a/src/__snapshots__/context.test.ts.snap
+++ b/src/__snapshots__/context.test.ts.snap
@@ -23,7 +23,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": undefined,
+      "fetchOptions": Object {},
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
@@ -38,7 +38,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": undefined,
+      "fetchOptions": Object {},
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
@@ -72,7 +72,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": undefined,
+      "fetchOptions": Object {},
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
@@ -87,7 +87,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": undefined,
+      "fetchOptions": Object {},
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
diff --git a/src/client.test.ts b/src/client.test.ts
index 8f4c6727..5133ac51 100755
--- a/src/client.test.ts
+++ b/src/client.test.ts
@@ -20,6 +20,21 @@ describe('createClient', () => {
 
     expect(c).toMatchSnapshot();
   });
+
+  describe('args', () => {
+    describe('fetchOptions', () => {
+      const fetchOptions = jest.fn(() => ({}));
+
+      it('function is executed', () => {
+        createClient({
+          url,
+          fetchOptions: fetchOptions as any,
+        });
+
+        expect(fetchOptions).toBeCalled();
+      });
+    });
+  });
 });
 
 const query = {
diff --git a/src/exchanges/__snapshots__/fetch.test.ts.snap b/src/exchanges/__snapshots__/fetch.test.ts.snap
index e7b8979e..25c266d8 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -3,7 +3,16 @@
 exports[`returns error data from fetch 1`] = `
 Object {
   "data": undefined,
-  "error": [CombinedError: [Network] ],
+  "error": CombinedError {
+    "graphQLErrors": Array [],
+    "message": "[Network] ",
+    "name": "CombinedError",
+    "networkError": [Error],
+    "response": Object {
+      "json": [MockFunction],
+      "status": 400,
+    },
+  },
   "operation": Object {
     "context": Object {
       "fetchOptions": Object {
@@ -222,16 +231,8 @@ Object {
   "error": undefined,
   "operation": Object {
     "context": Object {
-      "fetchOptions": [MockFunction] {
-        "calls": Array [
-          Array [],
-        ],
-        "results": Array [
-          Object {
-            "isThrow": false,
-            "value": Object {},
-          },
-        ],
+      "fetchOptions": Object {
+        "method": "POST",
       },
       "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
diff --git a/src/exchanges/fetch.test.ts b/src/exchanges/fetch.test.ts
index da12529c..8df7f860 100755
--- a/src/exchanges/fetch.test.ts
+++ b/src/exchanges/fetch.test.ts
@@ -46,22 +46,13 @@ it('returns response data from fetch', async () => {
     json: jest.fn().mockResolvedValue(response),
   });
 
-  const fetchOptions = jest.fn().mockReturnValue({});
-
   const data = await pipe(
-    fromValue({
-      ...queryOperation,
-      context: {
-        ...queryOperation.context,
-        fetchOptions,
-      },
-    }),
+    fromValue(queryOperation),
     fetchExchange(exchangeArgs),
     toPromise
   );
 
   expect(data).toMatchSnapshot();
-  expect(fetchOptions).toHaveBeenCalled();
 });
 
 it('returns error data from fetch', async () => {
diff --git a/src/utils/__snapshots__/error.test.ts.snap b/src/utils/__snapshots__/error.test.ts.snap
deleted file mode 100644
index 2caff081..00000000
--- a/src/utils/__snapshots__/error.test.ts.snap
+++ /dev/null
@@ -1,3 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`CombinedError behaves like a normal Error 1`] = `"[Network] test"`;
diff --git a/src/utils/error.test.ts b/src/utils/error.test.ts
index 4593d827..1af18b72 100644
--- a/src/utils/error.test.ts
+++ b/src/utils/error.test.ts
@@ -9,17 +9,6 @@ describe('CombinedError', () => {
     expect(err.name).toBe('CombinedError');
   });
 
-  it('behaves like a normal Error', () => {
-    const err = new CombinedError({
-      networkError: new Error('test'),
-    });
-
-    expect(err instanceof CombinedError).toBe(true);
-    expect(err instanceof Error).toBe(true);
-    expect(Error.prototype.isPrototypeOf(err)).toBe(true);
-    expect('' + err).toMatchSnapshot();
-  });
-
   it('accepts graphQLError messages and generates a single message from them', () => {
     const graphQLErrors = ['Error Message A', 'Error Message B'];
 
diff --git a/src/utils/typenames.test.ts b/src/utils/typenames.test.ts
index e8b95a83..02d2b4a9 100755
--- a/src/utils/typenames.test.ts
+++ b/src/utils/typenames.test.ts
@@ -1,5 +1,5 @@
 import { parse, print } from 'graphql';
-import { collectTypesFromResponse, formatDocument } from './typenames';
+import { formatDocument, gankTypeNamesFromResponse } from './typenames';
 
 const formatTypeNames = (query: string) => {
   const typedNode = formatDocument(parse(query));
@@ -19,9 +19,9 @@ describe('formatTypeNames', () => {
   });
 });
 
-describe('collectTypesFromResponse', () => {
+describe('gankTypeNamesFromResponse', () => {
   it('returns all typenames included in a response as an array', () => {
-    const typeNames = collectTypesFromResponse({
+    const typeNames = gankTypeNamesFromResponse({
       todos: [
         {
           id: 1,
@@ -33,7 +33,7 @@ describe('collectTypesFromResponse', () => {
   });
 
   it('does not duplicate typenames', () => {
-    const typeNames = collectTypesFromResponse({
+    const typeNames = gankTypeNamesFromResponse({
       todos: [
         {
           id: 1,
@@ -49,7 +49,7 @@ describe('collectTypesFromResponse', () => {
   });
 
   it('returns multiple different typenames', () => {
-    const typeNames = collectTypesFromResponse({
+    const typeNames = gankTypeNamesFromResponse({
       todos: [
         {
           id: 1,
@@ -65,7 +65,7 @@ describe('collectTypesFromResponse', () => {
   });
 
   it('works on nested objects', () => {
-    const typeNames = collectTypesFromResponse({
+    const typeNames = gankTypeNamesFromResponse({
       todos: [
         {
           id: 1,
@@ -82,25 +82,4 @@ describe('collectTypesFromResponse', () => {
     });
     expect(typeNames).toEqual(['Todo', 'SubTask']);
   });
-
-  it('traverses nested arrays of objects', () => {
-    const typenames = collectTypesFromResponse({
-      todos: [
-        {
-          id: 1,
-          authors: [
-            [
-              {
-                name: 'Phil',
-                __typename: 'Author',
-              },
-            ],
-          ],
-          __typename: 'Todo',
-        },
-      ],
-    });
-
-    expect(typenames).toEqual(['Author', 'Todo']);
-  });
 });