From be2c8da290d43fcddd19134ed643f27bf45e2a69 Mon Sep 17 00:00:00 2001 From: Andy Richardson Date: Thu, 7 May 2020 13:06:28 +0100 Subject: [PATCH] Add executeExchange for using an executable schema (#474) * initial * optimize andadd tests * Update src/exchanges/execute.ts * Remove unused vars * Move execute exchange to it's own package * Update getting started guide * Remove extras * Remove extras again * Update exchanges/execute/src/execute.test.ts * Update exchanges/execute/src/execute.ts * Execute exchange: update package.json to align with new conventions & dependency versions. * Execute exchange: fix failing test. * Execute exchange: misc code cosmetics from PR review. * Execute exchange: add empty CHANGELOG.md. * Execute exchange: fix typo in README.md. * Execute exchange: allow contextValue to be a function. * Execute exchange: changes based on PR comments. * Execute exchange: instead of exposing core's internal method, copy to exchange's package. * Execute exchange: add test that execute and fetch exchanges both return same data for same inputs. * Execute exchange: Add teardown support * Execute exchange: Add to CodeSandbox CI Co-authored-by: Amy Boyd --- .codesandbox/ci.json | 7 +- exchanges/execute/CHANGELOG.md | 0 exchanges/execute/README.md | 88 ++++++++++++ exchanges/execute/package.json | 67 +++++++++ exchanges/execute/src/execute.test.ts | 200 ++++++++++++++++++++++++++ exchanges/execute/src/execute.ts | 112 +++++++++++++++ exchanges/execute/src/index.ts | 1 + exchanges/execute/tsconfig.json | 13 ++ exchanges/graphcache/README.md | 18 +-- 9 files changed, 486 insertions(+), 20 deletions(-) create mode 100644 exchanges/execute/CHANGELOG.md create mode 100644 exchanges/execute/README.md create mode 100644 exchanges/execute/package.json create mode 100644 exchanges/execute/src/execute.test.ts create mode 100644 exchanges/execute/src/execute.ts create mode 100644 exchanges/execute/src/index.ts create mode 100644 exchanges/execute/tsconfig.json diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index a99f2eb4c7..27063483c6 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 0000000000..e69de29bb2 diff --git a/exchanges/execute/README.md b/exchanges/execute/README.md new file mode 100644 index 0000000000..a37c64c2be --- /dev/null +++ b/exchanges/execute/README.md @@ -0,0 +1,88 @@ +

@urql/exchange-execute

+ +

An exchange for executing queries against a local schema in urql

+ +`@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 0000000000..2158d7771d --- /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.test.ts b/exchanges/execute/src/execute.test.ts new file mode 100644 index 0000000000..f4f46396ad --- /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, + 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(); + + const response = pipe( + source, + executeExchange({ schema })(exchangeArgs), + take(1), + toPromise + ); + + next(operation); + expect(await response).toEqual(operation); + }); +}); diff --git a/exchanges/execute/src/execute.ts b/exchanges/execute/src/execute.ts new file mode 100644 index 0000000000..f146b54348 --- /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; + typeResolver?: GraphQLTypeResolver; +} + +/** 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 0000000000..cf7bbd94ec --- /dev/null +++ b/exchanges/execute/src/index.ts @@ -0,0 +1 @@ +export { executeExchange } from './execute'; diff --git a/exchanges/execute/tsconfig.json b/exchanges/execute/tsconfig.json new file mode 100644 index 0000000000..5797ce6168 --- /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 cbf0fcdea8..79549ce54d 100644 --- a/exchanges/graphcache/README.md +++ b/exchanges/graphcache/README.md @@ -1,20 +1,6 @@

@urql/exchange-graphcache

-

-An exchange for normalized caching support in urql -

- - NPM Version - - - Minified gzip size - - - Maintenance Status - - - Spectrum badge - -

+ +

An exchange for normalized caching support in urql

`@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