From f4d382f3356a8398edb79aeb44d0fc37f1f564cc Mon Sep 17 00:00:00 2001 From: XiNiHa Date: Tue, 17 Sep 2024 00:23:33 +0900 Subject: [PATCH 1/3] feat(toe): Add @urql/exchange-throw-on-error --- exchanges/throw-on-error/README.md | 15 + exchanges/throw-on-error/jsr.json | 15 + exchanges/throw-on-error/package.json | 66 +++++ exchanges/throw-on-error/src/index.ts | 1 + .../src/throwOnErrorExchange.test.ts | 259 ++++++++++++++++++ .../src/throwOnErrorExchange.ts | 19 ++ exchanges/throw-on-error/tsconfig.json | 4 + exchanges/throw-on-error/vitest.config.ts | 4 + pnpm-lock.yaml | 21 ++ 9 files changed, 404 insertions(+) create mode 100644 exchanges/throw-on-error/README.md create mode 100644 exchanges/throw-on-error/jsr.json create mode 100644 exchanges/throw-on-error/package.json create mode 100644 exchanges/throw-on-error/src/index.ts create mode 100644 exchanges/throw-on-error/src/throwOnErrorExchange.test.ts create mode 100644 exchanges/throw-on-error/src/throwOnErrorExchange.ts create mode 100644 exchanges/throw-on-error/tsconfig.json create mode 100644 exchanges/throw-on-error/vitest.config.ts diff --git a/exchanges/throw-on-error/README.md b/exchanges/throw-on-error/README.md new file mode 100644 index 0000000000..b5539940e6 --- /dev/null +++ b/exchanges/throw-on-error/README.md @@ -0,0 +1,15 @@ +# @urql/exchange-throw-on-error (Exchange factory) + +`@urql/exchange-throw-on-error` is an exchange for the [`urql`](../../README.md) GraphQL client that makes field access to data throw an error if the field was errored. + +It is built on top of the [`graphql-toe`](https://github.com/graphile/graphql-toe) package. + +## Quick Start Guide + +First install `@urql/exchange-throw-on-error` alongside `urql`: + +```sh +yarn add @urql/exchange-throw-on-error +# or +npm install --save @urql/exchange-throw-on-error +``` diff --git a/exchanges/throw-on-error/jsr.json b/exchanges/throw-on-error/jsr.json new file mode 100644 index 0000000000..647962dc9d --- /dev/null +++ b/exchanges/throw-on-error/jsr.json @@ -0,0 +1,15 @@ +{ + "name": "@urql/exchange-throw-on-error", + "version": "0.0.0", + "exports": { + ".": "./src/index.ts" + }, + "exclude": [ + "node_modules", + "cypress", + "**/*.test.*", + "**/*.spec.*", + "**/*.test.*.snap", + "**/*.spec.*.snap" + ] +} diff --git a/exchanges/throw-on-error/package.json b/exchanges/throw-on-error/package.json new file mode 100644 index 0000000000..e64162b51e --- /dev/null +++ b/exchanges/throw-on-error/package.json @@ -0,0 +1,66 @@ +{ + "name": "@urql/exchange-throw-on-error", + "version": "0.0.0", + "description": "An exchange for throw-on-error support in urql", + "sideEffects": false, + "homepage": "https://formidable.com/open-source/urql/docs/", + "bugs": "https://github.com/urql-graphql/urql/issues", + "license": "MIT", + "author": "urql GraphQL Contributors", + "repository": { + "type": "git", + "url": "https://github.com/urql-graphql/urql.git", + "directory": "exchanges/throw-on-error" + }, + "keywords": [ + "urql", + "graphql client", + "graphql", + "exchanges", + "throw on error" + ], + "main": "dist/urql-exchange-throw-on-error", + "module": "dist/urql-exchange-throw-on-error.mjs", + "types": "dist/urql-exchange-throw-on-error.d.ts", + "source": "src/index.ts", + "exports": { + ".": { + "types": "./dist/urql-exchange-throw-on-error.d.ts", + "import": "./dist/urql-exchange-throw-on-error.mjs", + "require": "./dist/urql-exchange-throw-on-error.js", + "source": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "LICENSE", + "CHANGELOG.md", + "README.md", + "dist/" + ], + "scripts": { + "test": "vitest", + "clean": "rimraf dist", + "check": "tsc --noEmit", + "lint": "eslint --ext=js,jsx,ts,tsx .", + "build": "rollup -c ../../scripts/rollup/config.mjs", + "prepare": "node ../../scripts/prepare/index.js", + "prepublishOnly": "run-s clean build" + }, + "devDependencies": { + "@urql/core": "workspace:*", + "graphql": "^16.0.0" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" + }, + "dependencies": { + "@urql/core": "^5.0.0", + "graphql-toe": "0.1.2", + "wonka": "^6.3.2" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/exchanges/throw-on-error/src/index.ts b/exchanges/throw-on-error/src/index.ts new file mode 100644 index 0000000000..624436472b --- /dev/null +++ b/exchanges/throw-on-error/src/index.ts @@ -0,0 +1 @@ +export { throwOnErrorExchange } from './throwOnErrorExchange'; diff --git a/exchanges/throw-on-error/src/throwOnErrorExchange.test.ts b/exchanges/throw-on-error/src/throwOnErrorExchange.test.ts new file mode 100644 index 0000000000..e4d08881f2 --- /dev/null +++ b/exchanges/throw-on-error/src/throwOnErrorExchange.test.ts @@ -0,0 +1,259 @@ +import { pipe, map, fromValue, toPromise, take } from 'wonka'; +import { vi, expect, it, beforeEach } from 'vitest'; +import { GraphQLError } from 'graphql'; + +import { + gql, + createClient, + Operation, + ExchangeIO, + Client, + CombinedError, +} from '@urql/core'; + +import { throwOnErrorExchange } from './throwOnErrorExchange'; + +const dispatchDebug = vi.fn(); + +const query = gql` + { + topLevel + topLevelList + object { + inner + } + objectList { + inner + } + } +`; +const mockData = { + topLevel: 'topLevel', + topLevelList: ['topLevelList'], + object: { inner: 'inner' }, + objectList: [{ inner: 'inner' }], +}; + +let client: Client, op: Operation; +beforeEach(() => { + client = createClient({ + url: 'http://0.0.0.0', + exchanges: [], + }); + op = client.createRequestOperation('query', { key: 1, query, variables: {} }); +}); + +it('throws on top level field error', async () => { + const forward: ExchangeIO = ops$ => + pipe( + ops$, + map( + operation => + ({ + operation, + data: { + ...mockData, + topLevel: null, + }, + error: new CombinedError({ + graphQLErrors: [ + new GraphQLError('top level error', { path: ['topLevel'] }), + ], + }), + }) as any + ) + ); + + const res = await pipe( + fromValue(op), + throwOnErrorExchange()({ forward, client, dispatchDebug }), + take(1), + toPromise + ); + + expect(() => res.data?.topLevel).toThrow('top level error'); + expect(() => res.data).not.toThrow(); + expect(() => res.data?.topLevelList[0]).not.toThrow(); +}); + +it('throws on top level list element error', async () => { + const forward: ExchangeIO = ops$ => + pipe( + ops$, + map( + operation => + ({ + operation, + data: { + ...mockData, + topLevelList: ['topLevelList', null], + }, + error: new CombinedError({ + graphQLErrors: [ + new GraphQLError('top level list error', { + path: ['topLevelList', 1], + }), + ], + }), + }) as any + ) + ); + + const res = await pipe( + fromValue(op), + throwOnErrorExchange()({ forward, client, dispatchDebug }), + take(1), + toPromise + ); + + expect(() => res.data?.topLevelList[1]).toThrow('top level list error'); + expect(() => res.data).not.toThrow(); + expect(() => res.data?.topLevelList[0]).not.toThrow(); +}); + +it('throws on object field error', async () => { + const forward: ExchangeIO = ops$ => + pipe( + ops$, + map( + operation => + ({ + operation, + data: { + ...mockData, + object: null, + }, + error: new CombinedError({ + graphQLErrors: [ + new GraphQLError('object field error', { path: ['object'] }), + ], + }), + }) as any + ) + ); + + const res = await pipe( + fromValue(op), + throwOnErrorExchange()({ forward, client, dispatchDebug }), + take(1), + toPromise + ); + + expect(() => res.data?.object).toThrow('object field error'); + expect(() => res.data?.object.inner).toThrow('object field error'); + expect(() => res.data).not.toThrow(); + expect(() => res.data?.topLevel).not.toThrow(); +}); + +it('throws on object inner field error', async () => { + const forward: ExchangeIO = ops$ => + pipe( + ops$, + map( + operation => + ({ + operation, + data: { + ...mockData, + object: { + inner: null, + }, + }, + error: new CombinedError({ + graphQLErrors: [ + new GraphQLError('object inner field error', { + path: ['object', 'inner'], + }), + ], + }), + }) as any + ) + ); + + const res = await pipe( + fromValue(op), + throwOnErrorExchange()({ forward, client, dispatchDebug }), + take(1), + toPromise + ); + + expect(() => res.data?.object.inner).toThrow('object inner field error'); + expect(() => res.data).not.toThrow(); + expect(() => res.data?.object).not.toThrow(); +}); + +it('throws on object list field error', async () => { + const forward: ExchangeIO = ops$ => + pipe( + ops$, + map( + operation => + ({ + operation, + data: { + ...mockData, + objectList: null, + }, + error: new CombinedError({ + graphQLErrors: [ + new GraphQLError('object list field error', { + path: ['objectList'], + }), + ], + }), + }) as any + ) + ); + + const res = await pipe( + fromValue(op), + throwOnErrorExchange()({ forward, client, dispatchDebug }), + take(1), + toPromise + ); + + expect(() => res.data?.objectList).toThrow('object list field error'); + expect(() => res.data?.objectList[0]).toThrow('object list field error'); + expect(() => res.data?.objectList[0].inner).toThrow( + 'object list field error' + ); + expect(() => res.data).not.toThrow(); + expect(() => res.data?.topLevel).not.toThrow(); +}); + +it('throws on object inner field error', async () => { + const forward: ExchangeIO = ops$ => + pipe( + ops$, + map( + operation => + ({ + operation, + data: { + ...mockData, + objectList: [{ inner: 'inner' }, { inner: null }], + }, + error: new CombinedError({ + graphQLErrors: [ + new GraphQLError('object list inner field error', { + path: ['objectList', 1, 'inner'], + }), + ], + }), + }) as any + ) + ); + + const res = await pipe( + fromValue(op), + throwOnErrorExchange()({ forward, client, dispatchDebug }), + take(1), + toPromise + ); + + expect(() => res.data?.objectList[1].inner).toThrow( + 'object list inner field error' + ); + expect(() => res.data).not.toThrow(); + expect(() => res.data?.objectList[0].inner).not.toThrow(); +}); diff --git a/exchanges/throw-on-error/src/throwOnErrorExchange.ts b/exchanges/throw-on-error/src/throwOnErrorExchange.ts new file mode 100644 index 0000000000..ac1088a99b --- /dev/null +++ b/exchanges/throw-on-error/src/throwOnErrorExchange.ts @@ -0,0 +1,19 @@ +import type { Exchange } from '@urql/core'; +import { mapExchange } from '@urql/core'; +import { toe } from 'graphql-toe'; + +/** Exchange factory that maps the fields of the data to throw an error on access if the field was errored. + * + * @returns the created throw-on-error {@link Exchange}. + */ +export const throwOnErrorExchange = (): Exchange => { + return mapExchange({ + onResult(result) { + if (result.data) { + const errors = result.error && result.error.graphQLErrors; + result.data = toe({ data: result.data, errors }); + } + return result; + }, + }); +}; diff --git a/exchanges/throw-on-error/tsconfig.json b/exchanges/throw-on-error/tsconfig.json new file mode 100644 index 0000000000..596e2cf729 --- /dev/null +++ b/exchanges/throw-on-error/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/exchanges/throw-on-error/vitest.config.ts b/exchanges/throw-on-error/vitest.config.ts new file mode 100644 index 0000000000..6561524839 --- /dev/null +++ b/exchanges/throw-on-error/vitest.config.ts @@ -0,0 +1,4 @@ +import { mergeConfig } from 'vitest/config'; +import baseConfig from '../../vitest.config'; + +export default mergeConfig(baseConfig, {}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28a8b0544f..49286cb4f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,22 @@ importers: specifier: ^16.6.0 version: 16.6.0 + exchanges/throw-on-error: + dependencies: + '@urql/core': + specifier: ^5.0.0 + version: 5.0.6(graphql@16.6.0) + graphql-toe: + specifier: 0.1.2 + version: 0.1.2 + wonka: + specifier: ^6.3.2 + version: 6.3.2 + devDependencies: + graphql: + specifier: ^16.6.0 + version: 16.6.0 + packages/core: dependencies: '@0no-co/graphql.web': @@ -5367,6 +5383,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql-toe@0.1.2: + resolution: {integrity: sha512-XK04wXEHbLY33YHoPAnLMIafRKSOn7FTWzTCob23GC6o8DnO4ibkA8Aje+Udee8QdXx46TV6m6LQM9iU8C9vwQ==} + graphql@16.6.0: resolution: {integrity: sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -17177,6 +17196,8 @@ snapshots: graphemer@1.4.0: {} + graphql-toe@0.1.2: {} + graphql@16.6.0: {} graphql@16.9.0: {} From db916672d215015c86f2f6bdc54e2f57dc26793a Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 24 Sep 2024 10:57:19 +0200 Subject: [PATCH 2/3] Create neat-pandas-punch.md --- .changeset/neat-pandas-punch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/neat-pandas-punch.md diff --git a/.changeset/neat-pandas-punch.md b/.changeset/neat-pandas-punch.md new file mode 100644 index 0000000000..055660b7ce --- /dev/null +++ b/.changeset/neat-pandas-punch.md @@ -0,0 +1,5 @@ +--- +"@urql/exchange-throw-on-error": minor +--- + +Initial release From d08799b0a181c8b01630610dbf3b0b26400103c2 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 24 Sep 2024 11:00:02 +0200 Subject: [PATCH 3/3] Update exchanges/throw-on-error/README.md --- exchanges/throw-on-error/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchanges/throw-on-error/README.md b/exchanges/throw-on-error/README.md index b5539940e6..33e32fb51e 100644 --- a/exchanges/throw-on-error/README.md +++ b/exchanges/throw-on-error/README.md @@ -1,6 +1,6 @@ # @urql/exchange-throw-on-error (Exchange factory) -`@urql/exchange-throw-on-error` is an exchange for the [`urql`](../../README.md) GraphQL client that makes field access to data throw an error if the field was errored. +`@urql/exchange-throw-on-error` is an exchange for the [`urql`](../../README.md) GraphQL client that makes field access to data throw an error if the field errored. It is built on top of the [`graphql-toe`](https://github.com/graphile/graphql-toe) package.