Repository: FormidableLabs/urql
Pull request #474 (2019-11-21 12:35:07)

Add executeExchange for using an executable schema
## About
Exchange for executing queries locally using an executable schema.

This may be particularly useful for us when making example projects as we could mock responses here rather than implementing a server.

kitten: I think @mxstbr is already maintaining a package for that. We may just want to send a PR to update it a little https://github.com/mxstbr/urql-exchange-schema
mxstbr: Happy to merge that into core!
andyrichardson: Let me know what you want to do :+1:
changeset-bot[bot]: ###  💥  No Changeset

Latest commit: e660a8231a2b05442a5a8a3b3ae0b31b6e132b2c

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/execute-exchange?filename=.changeset/witty-countries-burn.md&value=---%0A%22%40fake-scope%2Ffake-pkg%22%3A%20patch%0A---%0A%0AUpdate%20the%20package%20info%20above%20to%20the%20changes%20you%20want%2C%20and%20write%20your%20description%20here.%20You%20can%20add%20more%20packages%20on%20new%20lines.%0A)


kitten: @JoviDeCroock @andyrichardson assuming we'll want to get this merged soon, but we'll only need some minor documentation for this after a rebase, and let's stick it into a new `exchanges/execute` package?
andyrichardson: @kitten Yeah I'm happy to sort that out but you're going to have to explain the monorepo to me.

The exchange is 61 lines, no new dependencies, and will be tree shaken if not in use. Do we want this exchange to be a standalone package in `/exchanges`?

Also, where do the docs for standalone packages go?
kitten: I think bundling an exchange would only make sense if a majority of users use it? Although, I can totally see an exchange being "moved" into core, when it crosses that threshold. For now, I'd play it safe. It looks like when `execute` is bundled (be that because it's bundled accidentally, or tree-shaking fails, or not an option, etc) it adds a lot of heft: https://github.com/apollographql/apollo-link/pull/968

I've got some docs in the `CONTRIBUTING.md` guide that should help :+1: but happy to also pair on that or sth :+1: https://github.com/FormidableLabs/urql/blob/master/CONTRIBUTING.md#how-do-i-add-a-new-package
andyrichardson: @kitten thanks for the explanation - I've moved it to _/exchanges_
leebenson: FWIW, this exchange fixed an issue I was experiencing with https://github.com/mxstbr/urql-exchange-schema, in that queries via SSR were being executed _once for each exchange_.

So a typical set-up of:

```ts
exchanges: [
  dedupExchange,
  cacheExchange,
  ssr,
  schemaExchange(schema, { context }),
]
```

... would actually run the query **four** times.

This is a subtle bug that's easy to overlook, since the first query that's run is the one that winds up being rendered back to the client.

Just a heads-up @mxstbr in case it made sense to update your own package while waiting for this to merge.
kitten: To get this up-to-date we just need to make sure that the config files match (`package.json` fields, some are linted by the `prepare` script), `tsconfig.json` changes, creating an empty `CHANGELOG`, etc
amyboyd: > To get this up-to-date we just need to make sure that the config files match (`package.json` fields, some are linted by the `prepare` script), `tsconfig.json` changes, creating an empty `CHANGELOG`, etc

The branch has been rebased on master and these files are matching the other extensions.

A few other misc changes -- see commit log.

Patch:
diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json
index a99f2eb4..27063483 100644
--- a/.codesandbox/ci.json
+++ b/.codesandbox/ci.json
@@ -8,11 +8,10 @@
     "exchanges/multipart-fetch",
     "exchanges/persisted-fetch",
     "exchanges/retry",
-    "exchanges/suspense"
-  ],
-  "sandboxes": [
-    "urql-issue-template-client-iui0o"
+    "exchanges/suspense",
+    "exchanges/execute"
   ],
+  "sandboxes": ["urql-issue-template-client-iui0o"],
   "buildCommand": "build",
   "silent": true
 }
diff --git a/exchanges/execute/CHANGELOG.md b/exchanges/execute/CHANGELOG.md
new file mode 100644
index 00000000..e69de29b
diff --git a/exchanges/execute/README.md b/exchanges/execute/README.md
new file mode 100644
index 00000000..a37c64c2
--- /dev/null
+++ b/exchanges/execute/README.md
@@ -0,0 +1,88 @@
+<h2 align="center">@urql/exchange-execute</h2>
+
+<p align="center"><strong>An exchange for executing queries against a local schema in <code>urql</code></strong></p>
+
+`@urql/exchange-execute` is an exchange for the [`urql`](https://github.com/FormidableLabs/urql) GraphQL client which executes queries against a local schema.
+This is a replacement for the default _fetchExchange_ which sends queries over HTTP/S to be executed remotely.
+
+## Quick Start Guide
+
+First install `@urql/exchange-execute` alongside `urql`:
+
+```sh
+yarn add @urql/exchange-execute
+# or
+npm install --save @urql/exchange-execute
+```
+
+You'll then need to add the `executeExchange`, that this package exposes, to your `urql` Client,
+by replacing the default fetch exchange with it:
+
+```js
+import { createClient, dedupExchange, cacheExchange } from 'urql';
+import { executeExchange } from '@urql/exchange-execute';
+
+const client = createClient({
+  url: 'http://localhost:1234/graphql',
+  exchanges: [
+    dedupExchange,
+    cacheExchange,
+    // Replace the default fetchExchange with the new one.
+    executeExchange({
+      /* config */
+    }),
+  ],
+});
+```
+
+## Usage
+
+The exchange takes the same arguments as the [_execute_ function](https://graphql.org/graphql-js/execution/#execute) provided by graphql-js.
+
+Here's a brief example of how it might be used:
+
+```js
+import { buildSchema } from 'graphql';
+
+// Create local schema
+const schema = buildSchema(`
+  type Todo {
+    id: ID!
+    text: String!
+  }
+
+  type Query {
+    todos: [Todo]!
+  }
+
+  type Mutation {
+    addTodo(text: String!): Todo!
+  }
+`);
+
+// Create local state
+let todos = [];
+
+// Create root value with resolvers
+const rootValue = {
+  todos: () => todos,
+  addTodo: (_, args) => {
+    const todo = { id: todos.length.toString(), ...args };
+    todos = [...todos, todo];
+    return todo;
+  }
+}
+
+// ...
+
+// Pass schema and root value to executeExchange
+executeExchange({
+  schema,
+  rootValue,
+}),
+// ...
+```
+
+## Maintenance Status
+
+**Active:** Formidable is actively working on this project, and we expect to continue for work for the foreseeable future. Bug reports, feature requests and pull requests are welcome.
diff --git a/exchanges/execute/package.json b/exchanges/execute/package.json
new file mode 100644
index 00000000..2158d777
--- /dev/null
+++ b/exchanges/execute/package.json
@@ -0,0 +1,67 @@
+{
+  "name": "@urql/exchange-execute",
+  "version": "1.0.0",
+  "description": "An exchange for executing queries against a local schema in urql",
+  "sideEffects": false,
+  "homepage": "https://formidable.com/open-source/urql/docs/",
+  "bugs": "https://github.com/FormidableLabs/urql/issues",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/FormidableLabs/urql.git",
+    "directory": "exchanges/execute"
+  },
+  "keywords": [
+    "urql",
+    "exchange",
+    "execute",
+    "executable schema",
+    "formidablelabs",
+    "exchanges"
+  ],
+  "main": "dist/urql-exchange-execute",
+  "module": "dist/urql-exchange-execute.mjs",
+  "types": "dist/types/index.d.ts",
+  "source": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/urql-exchange-execute.mjs",
+      "require": "./dist/urql-exchange-execute.js",
+      "types": "./dist/types/index.d.ts",
+      "source": "./src/index.ts"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "LICENSE",
+    "CHANGELOG.md",
+    "README.md",
+    "dist/"
+  ],
+  "scripts": {
+    "test": "jest",
+    "clean": "rimraf dist extras",
+    "check": "tsc --noEmit",
+    "lint": "eslint --ext=js,jsx,ts,tsx .",
+    "build": "rollup -c ../../scripts/rollup/config.js",
+    "prepare": "node ../../scripts/prepare/index.js",
+    "prepublishOnly": "run-s clean build"
+  },
+  "jest": {
+    "preset": "../../scripts/jest/preset"
+  },
+  "dependencies": {
+    "@urql/core": ">=1.11.7",
+    "wonka": "^4.0.10"
+  },
+  "peerDependencies": {
+    "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0"
+  },
+  "devDependencies": {
+    "graphql": "^15.0.0",
+    "graphql-tag": "^2.10.1"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/exchanges/execute/src/execute.ts b/exchanges/execute/src/execute.ts
new file mode 100644
index 00000000..f146b543
--- /dev/null
+++ b/exchanges/execute/src/execute.ts
@@ -0,0 +1,112 @@
+import {
+  pipe,
+  share,
+  filter,
+  fromPromise,
+  takeUntil,
+  onEnd,
+  mergeMap,
+  merge,
+} from 'wonka';
+
+import {
+  DocumentNode,
+  Kind,
+  GraphQLSchema,
+  GraphQLFieldResolver,
+  GraphQLTypeResolver,
+  execute,
+} from 'graphql';
+
+import { Exchange, makeResult, makeErrorResult, Operation } from '@urql/core';
+
+export const getOperationName = (query: DocumentNode): string | undefined => {
+  for (let i = 0, l = query.definitions.length; i < l; i++) {
+    const node = query.definitions[i];
+    if (node.kind === Kind.OPERATION_DEFINITION && node.name) {
+      return node.name.value;
+    }
+  }
+};
+
+interface ExecuteExchangeArgs {
+  schema: GraphQLSchema;
+  rootValue?: any;
+  context?: ((op: Operation) => void) | any;
+  fieldResolver?: GraphQLFieldResolver<any, any>;
+  typeResolver?: GraphQLTypeResolver<any, any>;
+}
+
+/** Exchange for executing queries locally on a schema using graphql-js. */
+export const executeExchange = ({
+  schema,
+  rootValue,
+  context,
+  fieldResolver,
+  typeResolver,
+}: ExecuteExchangeArgs): Exchange => ({ forward }) => {
+  return ops$ => {
+    const sharedOps$ = share(ops$);
+
+    const executedOps$ = pipe(
+      sharedOps$,
+      filter((operation: Operation) => {
+        return (
+          operation.operationName === 'query' ||
+          operation.operationName === 'mutation'
+        );
+      }),
+      mergeMap((operation: Operation) => {
+        const { key } = operation;
+        const teardown$ = pipe(
+          sharedOps$,
+          filter(op => op.operationName === 'teardown' && op.key === key)
+        );
+
+        const calculatedContext =
+          typeof context === 'function' ? context(operation) : context;
+
+        let ended = false;
+
+        const result = Promise.resolve()
+          .then(() => {
+            if (ended) return;
+
+            return execute(
+              schema,
+              operation.query,
+              rootValue,
+              calculatedContext,
+              operation.variables,
+              getOperationName(operation.query),
+              fieldResolver,
+              typeResolver
+            );
+          })
+          .then(result => makeResult(operation, result))
+          .catch(err => makeErrorResult(operation, err));
+
+        return pipe(
+          fromPromise(result),
+          onEnd(() => {
+            ended = true;
+          }),
+          takeUntil(teardown$)
+        );
+      })
+    );
+
+    const forwardedOps$ = pipe(
+      sharedOps$,
+      filter((operation: Operation) => {
+        return (
+          operation.operationName !== 'query' &&
+          operation.operationName !== 'mutation'
+        );
+      }),
+      forward
+    );
+
+    return merge([executedOps$, forwardedOps$]);
+  };
+};
diff --git a/exchanges/execute/src/index.ts b/exchanges/execute/src/index.ts
new file mode 100644
index 00000000..cf7bbd94
--- /dev/null
+++ b/exchanges/execute/src/index.ts
@@ -0,0 +1,1 @@
+export { executeExchange } from './execute';
diff --git a/exchanges/execute/tsconfig.json b/exchanges/execute/tsconfig.json
new file mode 100644
index 00000000..5797ce61
--- /dev/null
+++ b/exchanges/execute/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/exchanges/graphcache/README.md b/exchanges/graphcache/README.md
index cbf0fcde..79549ce5 100644
--- a/exchanges/graphcache/README.md
+++ b/exchanges/graphcache/README.md
@@ -1,20 +1,6 @@
 <h2 align="center">@urql/exchange-graphcache</h2>
-<p align="center">
-<strong>An exchange for normalized caching support in <code>urql</code></strong>
-<br /><br />
-<a href="https://npmjs.com/package/@urql/exchange-graphcache">
-  <img alt="NPM Version" src="https://img.shields.io/npm/v/@urql/exchange-graphcache.svg" />
-</a>
-<a href="https://bundlephobia.com/result?p=@urql/exchange-graphcache">
-  <img alt="Minified gzip size" src="https://img.shields.io/bundlephobia/minzip/@urql/exchange-graphcache.svg?label=gzip%20size" />
-</a>
-<a href="https://github.com/FormidableLabs/urql-exchange-graphcache#maintenance-status">
-  <img alt="Maintenance Status" src="https://img.shields.io/badge/maintenance-active-green.svg" />
-</a>
-<a href="https://spectrum.chat/urql">
-  <img alt="Spectrum badge" src="https://withspectrum.github.io/badge/badge.svg" />
-</a>
-</p>
+
+<p align="center"><strong>An exchange for normalized caching support in <code>urql</code></strong></p>
 
 `@urql/exchange-graphcache` is a normalized cache exchange for the [`urql`](https://github.com/FormidableLabs/urql) GraphQL client.
 This is a drop-in replacement for the default `cacheExchange` that, instead of document


Test patch:
diff --git a/exchanges/execute/src/execute.test.ts b/exchanges/execute/src/execute.test.ts
new file mode 100644
index 00000000..f4f46396
--- /dev/null
+++ b/exchanges/execute/src/execute.test.ts
@@ -0,0 +1,200 @@
+jest.mock('graphql');
+
+import { fetchExchange } from 'urql';
+import { executeExchange, getOperationName } from './execute';
+import { execute, print } from 'graphql';
+import {
+  pipe,
+  fromValue,
+  toPromise,
+  take,
+  makeSubject,
+  empty,
+  Source,
+} from 'wonka';
+import { mocked } from 'ts-jest/utils';
+import { queryOperation } from '@urql/core/test-utils';
+import { makeErrorResult } from '@urql/core';
+import { Client } from '@urql/core/client';
+import { OperationResult } from '@urql/core/types';
+
+const schema = 'STUB_SCHEMA' as any;
+const exchangeArgs = {
+  forward: a => a,
+  client: {},
+} as any;
+
+const expectedOperationName = getOperationName(queryOperation.query);
+
+const fetchMock = (global as any).fetch as jest.Mock;
+afterEach(() => {
+  fetchMock.mockClear();
+});
+
+const mockHttpResponseData = { key: 'value' };
+
+beforeEach(jest.clearAllMocks);
+
+beforeEach(() => {
+  mocked(print).mockImplementation(a => a as any);
+  mocked(execute).mockResolvedValue({ data: mockHttpResponseData });
+});
+
+describe('on operation', () => {
+  it('calls execute with args', async () => {
+    const context = 'USER_ID=123';
+
+    await pipe(
+      fromValue(queryOperation),
+      executeExchange({ schema, context })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    expect(mocked(execute)).toBeCalledTimes(1);
+    expect(mocked(execute)).toBeCalledWith(
+      schema,
+      queryOperation.query,
+      undefined,
+      context,
+      queryOperation.variables,
+      expectedOperationName,
+      undefined,
+      undefined
+    );
+  });
+
+  it('calls execute after executing context as a function', async () => {
+    const context = operation => {
+      expect(operation).toBe(queryOperation);
+      return 'CALCULATED_USER_ID=' + 8 * 10;
+    };
+
+    await pipe(
+      fromValue(queryOperation),
+      executeExchange({ schema, context })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    expect(mocked(execute)).toBeCalledTimes(1);
+    expect(mocked(execute)).toBeCalledWith(
+      schema,
+      queryOperation.query,
+      undefined,
+      'CALCULATED_USER_ID=80',
+      queryOperation.variables,
+      expectedOperationName,
+      undefined,
+      undefined
+    );
+  });
+
+  it('should return the same data as the fetch exchange', async () => {
+    const context = 'USER_ID=123';
+
+    const responseFromExecuteExchange = await pipe(
+      fromValue(queryOperation),
+      executeExchange({ schema, context })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    fetchMock.mockResolvedValue({
+      status: 200,
+      json: jest.fn().mockResolvedValue({ data: mockHttpResponseData }),
+    });
+
+    const responseFromFetchExchange = await pipe(
+      fromValue(queryOperation),
+      fetchExchange({
+        dispatchDebug: jest.fn(),
+        forward: () => empty as Source<OperationResult>,
+        client: {} as Client,
+      }),
+      toPromise
+    );
+
+    expect(responseFromExecuteExchange.data).toEqual(
+      responseFromFetchExchange.data
+    );
+    expect(mocked(execute)).toBeCalledTimes(1);
+    expect(fetchMock).toBeCalledTimes(1);
+  });
+});
+
+describe('on success response', () => {
+  it('returns operation result', async () => {
+    const response = await pipe(
+      fromValue(queryOperation),
+      executeExchange({ schema })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    expect(response).toEqual({
+      operation: queryOperation,
+      data: mockHttpResponseData,
+    });
+  });
+});
+
+describe('on error response', () => {
+  const errors = ['error'] as any;
+
+  beforeEach(() => {
+    mocked(execute).mockResolvedValue({ errors });
+  });
+
+  it('returns operation result', async () => {
+    const response = await pipe(
+      fromValue(queryOperation),
+      executeExchange({ schema })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    expect(response).toHaveProperty('operation', queryOperation);
+    expect(response).toHaveProperty('error');
+  });
+});
+
+describe('on thrown error', () => {
+  const errors = ['error'] as any;
+
+  beforeEach(() => {
+    mocked(execute).mockRejectedValue({ errors });
+  });
+
+  it('returns operation result', async () => {
+    const response = await pipe(
+      fromValue(queryOperation),
+      executeExchange({ schema })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    expect(response).toMatchObject(makeErrorResult(queryOperation, errors));
+  });
+});
+
+describe('on unsupported operation', () => {
+  const operation = {
+    ...queryOperation,
+    operationName: 'teardown',
+  } as const;
+
+  it('returns operation result', async () => {
+    const { source, next } = makeSubject<any>();
+
+    const response = pipe(
+      source,
+      executeExchange({ schema })(exchangeArgs),
+      take(1),
+      toPromise
+    );
+
+    next(operation);
+    expect(await response).toEqual(operation);
+  });
+});