Repository: FormidableLabs/urql
Pull request #200 (2019-02-22 16:30:42)

Rehydrate GraphQLErrors properly


Patch:
diff --git a/src/client.ts b/src/client.ts
index 15a85ce6..533889eb 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;
+  fetchOptions?: RequestInit | (() => RequestInit);
   exchange: Exchange;
 
   // These are internals to be used to keep track of operations
@@ -57,11 +57,7 @@ export class Client {
 
   constructor(opts: ClientOptions) {
     this.url = opts.url;
-
-    this.fetchOptions =
-      typeof opts.fetchOptions === 'function'
-        ? opts.fetchOptions()
-        : opts.fetchOptions || {};
+    this.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/exchanges/cache.ts b/src/exchanges/cache.ts
index 95565946..31fc0996 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 { formatDocument, gankTypeNamesFromResponse } from '../utils/typenames';
+import { collectTypesFromResponse, formatDocument } from '../utils/typenames';
 
 type ResultCache = Map<number, OperationResult>;
 
@@ -116,7 +116,7 @@ export const afterMutation = (
 ) => (response: OperationResult) => {
   const pendingOperations = new Set<number>();
 
-  gankTypeNamesFromResponse(response.data).forEach(typeName => {
+  collectTypesFromResponse(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);
 
-  gankTypeNamesFromResponse(response.data).forEach(typeName => {
+  collectTypesFromResponse(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 411c5ffa..35437aab 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -52,16 +52,26 @@ 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/types.ts b/src/types.ts
index 52561503..696ac2dd 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;
+  fetchOptions?: RequestInit | (() => RequestInit);
   requestPolicy: RequestPolicy;
   url: string;
 }
diff --git a/src/utils/error.ts b/src/utils/error.ts
index 2384aecf..f9470c05 100644
--- a/src/utils/error.ts
+++ b/src/utils/error.ts
@@ -1,6 +1,4 @@
-export interface GraphQLError {
-  message?: string;
-}
+import { GraphQLError } from 'graphql';
 
 const generateErrorMessage = (
   networkErr?: Error,
@@ -21,21 +19,29 @@ const generateErrorMessage = (
   return error.trim();
 };
 
-const rehydrateGraphQlError = (error: string | GraphQLError): Error => {
+const rehydrateGraphQlError = (error: any): GraphQLError => {
   if (typeof error === 'string') {
-    return new Error(error);
-  } else if (error.message) {
-    return new Error(error.message);
+    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 || {}
+    );
   } else {
     return error as any;
   }
 };
 
 /** An error which can consist of GraphQL errors and Network errors. */
-export class CombinedError implements Error {
+export class CombinedError extends Error {
   public name: string;
   public message: string;
-  public graphQLErrors: Error[];
+  public graphQLErrors: GraphQLError[];
   public networkError?: Error;
   public response?: any;
 
@@ -45,13 +51,24 @@ export class CombinedError implements Error {
     response,
   }: {
     networkError?: Error;
-    graphQLErrors?: Array<string | GraphQLError>;
+    graphQLErrors?: Array<string | GraphQLError | Error>;
     response?: any;
   }) {
+    const normalisedGraphQLErrors = (graphQLErrors || []).map(
+      rehydrateGraphQlError
+    );
+    const message = generateErrorMessage(networkError, normalisedGraphQLErrors);
+
+    super(message);
+
     this.name = 'CombinedError';
-    this.graphQLErrors = (graphQLErrors || []).map(rehydrateGraphQlError);
-    this.message = generateErrorMessage(networkError, this.graphQLErrors);
+    this.message = message;
+    this.graphQLErrors = normalisedGraphQLErrors;
     this.networkError = networkError;
     this.response = response;
   }
+
+  toString() {
+    return this.message;
+  }
 }
diff --git a/src/utils/index.ts b/src/utils/index.ts
index ef4f0725..a70396e7 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, gankTypeNamesFromResponse } from './typenames';
+export { formatDocument, collectTypesFromResponse } from './typenames';
 
 export const noop = () => {
   /* noop */
diff --git a/src/utils/typenames.ts b/src/utils/typenames.ts
index d837f78e..2fa9cc2b 100755
--- a/src/utils/typenames.ts
+++ b/src/utils/typenames.ts
@@ -6,22 +6,33 @@ import {
   visit,
 } from 'graphql';
 
-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) },
-    {}
-  );
+interface EntityLike {
+  [key: string]: EntityLike | EntityLike[] | any;
+  __typename: string | null | void;
+}
 
-export const gankTypeNamesFromResponse = (response: object) => {
-  const typeNames = Object.keys(getTypeNameFromField(response));
-  return [...typeNames].filter((v, i, a) => a.indexOf(v) === i);
+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 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 f15a65f4..30f757e4 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": Object {},
+  "fetchOptions": undefined,
   "operations$": [Function],
   "reexecuteOperation": [Function],
   "results$": [Function],
diff --git a/src/__snapshots__/context.test.ts.snap b/src/__snapshots__/context.test.ts.snap
index ac40de0b..277618e7 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": Object {},
+      "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
@@ -38,7 +38,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": Object {},
+      "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
@@ -72,7 +72,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": Object {},
+      "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
@@ -87,7 +87,7 @@ Object {
       "executeMutation": [Function],
       "executeQuery": [Function],
       "executeSubscription": [Function],
-      "fetchOptions": Object {},
+      "fetchOptions": undefined,
       "operations$": [Function],
       "reexecuteOperation": [Function],
       "results$": [Function],
diff --git a/src/client.test.ts b/src/client.test.ts
index 5133ac51..8f4c6727 100755
--- a/src/client.test.ts
+++ b/src/client.test.ts
@@ -20,21 +20,6 @@ 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 25c266d8..e7b8979e 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -3,16 +3,7 @@
 exports[`returns error data from fetch 1`] = `
 Object {
   "data": undefined,
-  "error": CombinedError {
-    "graphQLErrors": Array [],
-    "message": "[Network] ",
-    "name": "CombinedError",
-    "networkError": [Error],
-    "response": Object {
-      "json": [MockFunction],
-      "status": 400,
-    },
-  },
+  "error": [CombinedError: [Network] ],
   "operation": Object {
     "context": Object {
       "fetchOptions": Object {
@@ -231,8 +222,16 @@ Object {
   "error": undefined,
   "operation": Object {
     "context": Object {
-      "fetchOptions": Object {
-        "method": "POST",
+      "fetchOptions": [MockFunction] {
+        "calls": Array [
+          Array [],
+        ],
+        "results": Array [
+          Object {
+            "isThrow": false,
+            "value": Object {},
+          },
+        ],
       },
       "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
diff --git a/src/exchanges/fetch.test.ts b/src/exchanges/fetch.test.ts
index 8df7f860..da12529c 100755
--- a/src/exchanges/fetch.test.ts
+++ b/src/exchanges/fetch.test.ts
@@ -46,13 +46,22 @@ it('returns response data from fetch', async () => {
     json: jest.fn().mockResolvedValue(response),
   });
 
+  const fetchOptions = jest.fn().mockReturnValue({});
+
   const data = await pipe(
-    fromValue(queryOperation),
+    fromValue({
+      ...queryOperation,
+      context: {
+        ...queryOperation.context,
+        fetchOptions,
+      },
+    }),
     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
new file mode 100644
index 00000000..2caff081
--- /dev/null
+++ b/src/utils/__snapshots__/error.test.ts.snap
@@ -0,0 +1,3 @@
+// 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 1af18b72..4593d827 100644
--- a/src/utils/error.test.ts
+++ b/src/utils/error.test.ts
@@ -9,6 +9,17 @@ 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 02d2b4a9..e8b95a83 100755
--- a/src/utils/typenames.test.ts
+++ b/src/utils/typenames.test.ts
@@ -1,5 +1,5 @@
 import { parse, print } from 'graphql';
-import { formatDocument, gankTypeNamesFromResponse } from './typenames';
+import { collectTypesFromResponse, formatDocument } from './typenames';
 
 const formatTypeNames = (query: string) => {
   const typedNode = formatDocument(parse(query));
@@ -19,9 +19,9 @@ describe('formatTypeNames', () => {
   });
 });
 
-describe('gankTypeNamesFromResponse', () => {
+describe('collectTypesFromResponse', () => {
   it('returns all typenames included in a response as an array', () => {
-    const typeNames = gankTypeNamesFromResponse({
+    const typeNames = collectTypesFromResponse({
       todos: [
         {
           id: 1,
@@ -33,7 +33,7 @@ describe('gankTypeNamesFromResponse', () => {
   });
 
   it('does not duplicate typenames', () => {
-    const typeNames = gankTypeNamesFromResponse({
+    const typeNames = collectTypesFromResponse({
       todos: [
         {
           id: 1,
@@ -49,7 +49,7 @@ describe('gankTypeNamesFromResponse', () => {
   });
 
   it('returns multiple different typenames', () => {
-    const typeNames = gankTypeNamesFromResponse({
+    const typeNames = collectTypesFromResponse({
       todos: [
         {
           id: 1,
@@ -65,7 +65,7 @@ describe('gankTypeNamesFromResponse', () => {
   });
 
   it('works on nested objects', () => {
-    const typeNames = gankTypeNamesFromResponse({
+    const typeNames = collectTypesFromResponse({
       todos: [
         {
           id: 1,
@@ -82,4 +82,25 @@ describe('gankTypeNamesFromResponse', () => {
     });
     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']);
+  });
 });