Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(core): Implement mapExchange (replacing errorExchange) (#2846)

authored by

Phil Pluckthun and committed by
GitHub
60b3efd8 e86f50d1

+379 -155
+5
.changeset/violet-eyes-fetch.md
··· 1 + --- 2 + '@urql/core': minor 3 + --- 4 + 5 + Implement `mapExchange`, which replaces `errorExchange`, allowing `onOperation` and `onResult` to be called to either react to or replace operations and results. For backwards compatibility, this exchange is also exported as `errorExchange` and supports `onError`.
+12 -10
docs/advanced/authentication.md
··· 325 325 326 326 [Read more about `@urql/exchange-auth`'s API in our API docs.](../api/auth-exchange.md) 327 327 328 - ## Handling Logout with the Error Exchange 328 + ## Handling Logout by reacting to Errors 329 329 330 - We can also handle authentication errors in an `errorExchange` instead of the `authExchange`. To do this, we'll need to add the 331 - `errorExchange` to the exchanges array, _before_ the `authExchange`. The order is very important here: 330 + We can also handle authentication errors in a `mapExchange` instead of the `authExchange`. 331 + To do this, we'll need to add the `mapExchange` to the exchanges array, _before_ the `authExchange`. 332 + The order is very important here: 332 333 333 334 ```js 334 335 import { createClient, dedupExchange, cacheExchange, fetchExchange, errorExchange } from 'urql'; ··· 339 340 exchanges: [ 340 341 dedupExchange, 341 342 cacheExchange, 342 - errorExchange({ 343 - onError: error => { 343 + mapExchange({ 344 + onError(error, _operation) { 344 345 const isAuthError = error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 345 - 346 346 if (isAuthError) { 347 347 logout(); 348 348 } ··· 356 356 }); 357 357 ``` 358 358 359 - The `errorExchange` will only receive an auth error when the auth exchange has already tried and failed to handle it. This means we have 360 - either failed to refresh the token, or there is no token refresh functionality. If we receive an auth error in the `errorExchange` (as defined in 361 - the `didAuthError` configuration section above), then we can be confident that it is an auth error that the `authExchange` isn't able to recover 362 - from, and the user should be logged out. 359 + The `mapExchange` will only receive an auth error when the auth exchange has already tried and failed 360 + to handle it. This means we have either failed to refresh the token, or there is no token refresh 361 + functionality. If we receive an auth error in the `mapExchange`'s `onError` function 362 + (as defined in the `didAuthError` configuration section above), then we can be confident that it is 363 + an authentication error that the `authExchange` isn't able to recover from, and the user should be 364 + logged out. 363 365 364 366 ## Cache Invalidation on Logout 365 367
+58 -6
docs/api/core.md
··· 322 322 An exchange that writes incoming `Operation`s to `console.log` and 323 323 writes completed `OperationResult`s to `console.log`. 324 324 325 + This exchange is disabled in production and is based on the `mapExchange`. 326 + If you'd like to customise it, you can replace it with a custom `mapExchange`. 327 + 325 328 ### dedupExchange 326 329 327 330 An exchange that keeps track of ongoing `Operation`s that haven't returned had ··· 334 337 The `fetchExchange` of type `Exchange` is responsible for sending operations of type `'query'` and 335 338 `'mutation'` to a GraphQL API using `fetch`. 336 339 337 - ### errorExchange 340 + ### mapExchange 338 341 339 - An exchange that lets you inspect errors. This can be useful for logging, or reacting to 340 - different types of errors (e.g. logging the user out in case of a permission error). 342 + The `mapExchange` allows you to: 343 + 344 + - react to or replace operations with `onOperation`, 345 + - react to or replace results with `onResult`, 346 + - and; react to errors in results with `onError`. 347 + 348 + It can therefore be used to quickly react to the core events in the `Client` without writing a custom 349 + exchange, effectively allowing you to ship your own `debugExchange`. 341 350 342 351 ```ts 343 - errorExchange({ 344 - onError: (error: CombinedError, operation: Operation) => { 345 - console.log('An error!', error); 352 + mapExchange({ 353 + onOperation(operation) { 354 + console.log('operation', operation); 355 + }, 356 + onResult(result) { 357 + console.log('result', result); 346 358 }, 347 359 }); 348 360 ``` 361 + 362 + It can also be used to react only to errors, which is the same as checking for `result.error`: 363 + 364 + ```ts 365 + mapExchange({ 366 + onError(error, operation) { 367 + console.log(`The operation ${operation.key} has errored with:`, error); 368 + }, 369 + }); 370 + ``` 371 + 372 + Lastly, it can be used to map operations and results, which may be useful to update the 373 + `OperationContext` or perform other standard tasks that require you to wait for a result: 374 + 375 + ```ts 376 + import { mapExchange, makeOperation } from '@urql/core'; 377 + 378 + mapExchange({ 379 + async onOperation(operation) { 380 + // NOTE: This is only for illustration purposes 381 + return makeOperation(operation.kind, operation, { 382 + ...operation.context, 383 + test: true, 384 + }); 385 + }, 386 + async onResult(result) { 387 + // NOTE: This is only for illustration purposes 388 + if (result.data === undefined) result.data = null; 389 + return result; 390 + }, 391 + }); 392 + ``` 393 + 394 + ### errorExchange (deprecated) 395 + 396 + An exchange that lets you inspect errors. This can be useful for logging, or reacting to 397 + different types of errors (e.g. logging the user out in case of a permission error). 398 + 399 + In newer versions of `@urql/core`, it's identical to the `mapExchange` and its export has been 400 + replaced as the `mapExchange` also allows you to pass an `onError` function. 349 401 350 402 ## Utilities 351 403
+1 -1
docs/architecture.md
··· 138 138 139 139 Some of the exchanges that are available to us are: 140 140 141 - - [`errorExchange`](./api/core.md#errorexchange): Allows a global callback to be called when any error occurs 141 + - [`mapExchange`](./api/core.md#mapexchange): Allows reacting to operations, results, and errors 142 142 - [`ssrExchange`](./advanced/server-side-rendering.md): Allows for a server-side renderer to 143 143 collect results for client-side rehydration. 144 144 - [`retryExchange`](./advanced/retry-operations.md): Allows operations to be retried
-119
packages/core/src/exchanges/error.test.ts
··· 1 - import { makeSubject, map, pipe, publish, Subject } from 'wonka'; 2 - import { vi, expect, it, beforeEach } from 'vitest'; 3 - 4 - import { Client } from '../client'; 5 - import { queryOperation } from '../test-utils'; 6 - import { makeErrorResult, CombinedError } from '../utils'; 7 - import { Operation } from '../types'; 8 - import { errorExchange } from './error'; 9 - 10 - const error = new Error('Sad times'); 11 - let input: Subject<Operation>; 12 - 13 - beforeEach(() => { 14 - input = makeSubject<Operation>(); 15 - }); 16 - 17 - it('does not trigger when there are no errors', async () => { 18 - const onError = vi.fn(); 19 - const { source: ops$, next, complete } = input; 20 - const exchangeArgs = { 21 - forward: op$ => 22 - pipe( 23 - op$, 24 - map((operation: Operation) => ({ operation })) 25 - ), 26 - client: {} as Client, 27 - dispatchDebug: () => null, 28 - }; 29 - const exchange = errorExchange({ onError })(exchangeArgs)(ops$); 30 - 31 - publish(exchange); 32 - next(queryOperation); 33 - complete(); 34 - expect(onError).toBeCalledTimes(0); 35 - }); 36 - 37 - it('triggers correctly when the operations has an error', async () => { 38 - const onError = vi.fn(); 39 - const { source: ops$, next, complete } = input; 40 - const exchangeArgs = { 41 - forward: op$ => 42 - pipe( 43 - op$, 44 - map((operation: Operation) => makeErrorResult(operation, error)) 45 - ), 46 - client: {} as Client, 47 - dispatchDebug: () => null, 48 - }; 49 - const exchange = errorExchange({ onError })(exchangeArgs)(ops$); 50 - 51 - publish(exchange); 52 - next(queryOperation); 53 - complete(); 54 - expect(onError).toBeCalledTimes(1); 55 - expect(onError).toBeCalledWith( 56 - new CombinedError({ networkError: error }), 57 - queryOperation 58 - ); 59 - }); 60 - 61 - it('triggers correctly multiple times the operations has an error', async () => { 62 - const onError = vi.fn(); 63 - const { source: ops$, next, complete } = input; 64 - 65 - const firstQuery = { 66 - ...queryOperation, 67 - context: { 68 - ...queryOperation.context, 69 - item: 1, 70 - }, 71 - }; 72 - 73 - const secondQuery = { 74 - ...queryOperation, 75 - context: { 76 - ...queryOperation.context, 77 - item: 2, 78 - }, 79 - }; 80 - 81 - const thirdQuery = { 82 - ...queryOperation, 83 - context: { 84 - ...queryOperation.context, 85 - item: 3, 86 - }, 87 - }; 88 - 89 - const exchangeArgs = { 90 - forward: op$ => 91 - pipe( 92 - op$, 93 - map((operation: Operation) => { 94 - if (operation.context.item === 2) { 95 - return { operation }; 96 - } 97 - return makeErrorResult(operation, error); 98 - }) 99 - ), 100 - client: {} as Client, 101 - dispatchDebug: () => null, 102 - }; 103 - const exchange = errorExchange({ onError })(exchangeArgs)(ops$); 104 - 105 - publish(exchange); 106 - next(firstQuery); 107 - next(secondQuery); 108 - next(thirdQuery); 109 - complete(); 110 - expect(onError).toBeCalledTimes(2); 111 - expect(onError).toBeCalledWith( 112 - new CombinedError({ networkError: error }), 113 - firstQuery 114 - ); 115 - expect(onError).toBeCalledWith( 116 - new CombinedError({ networkError: error }), 117 - thirdQuery 118 - ); 119 - });
-18
packages/core/src/exchanges/error.ts
··· 1 - import { pipe, tap } from 'wonka'; 2 - import { Exchange, Operation } from '../types'; 3 - import { CombinedError } from '../utils'; 4 - 5 - export const errorExchange = ({ 6 - onError, 7 - }: { 8 - onError: (error: CombinedError, operation: Operation) => void; 9 - }): Exchange => ({ forward }) => ops$ => { 10 - return pipe( 11 - forward(ops$), 12 - tap(({ error, operation }) => { 13 - if (error) { 14 - onError(error, operation); 15 - } 16 - }) 17 - ); 18 - };
+9 -1
packages/core/src/exchanges/index.ts
··· 6 6 export { fetchExchange } from './fetch'; 7 7 export { fallbackExchangeIO } from './fallback'; 8 8 export { composeExchanges } from './compose'; 9 - export { errorExchange } from './error'; 9 + 10 + export type { 11 + SubscriptionOperation, 12 + SubscriptionForwarder, 13 + SubscriptionExchangeOpts, 14 + } from './subscription'; 15 + 16 + export { mapExchange, mapExchange as errorExchange } from './map'; 17 + export type { MapExchangeOpts } from './map'; 10 18 11 19 import { cacheExchange } from './cache'; 12 20 import { dedupExchange } from './dedup';
+254
packages/core/src/exchanges/map.test.ts
··· 1 + import { map, tap, pipe, fromValue, toArray, toPromise } from 'wonka'; 2 + import { vi, expect, describe, it } from 'vitest'; 3 + 4 + import { Client } from '../client'; 5 + import { queryOperation } from '../test-utils'; 6 + import { Operation } from '../types'; 7 + import { mapExchange } from './map'; 8 + 9 + import { 10 + makeOperation, 11 + makeErrorResult, 12 + makeResult, 13 + CombinedError, 14 + } from '../utils'; 15 + 16 + describe('onOperation', () => { 17 + it('triggers and maps on operations', () => { 18 + const mockOperation = makeOperation('query', queryOperation, { 19 + ...queryOperation.context, 20 + mock: true, 21 + }); 22 + 23 + const onOperation = vi.fn().mockReturnValue(mockOperation); 24 + const onExchangeResult = vi.fn(); 25 + 26 + const exchangeArgs = { 27 + forward: op$ => 28 + pipe( 29 + op$, 30 + tap(onExchangeResult), 31 + map((operation: Operation) => makeResult(operation, { data: null })) 32 + ), 33 + client: {} as Client, 34 + dispatchDebug: () => null, 35 + }; 36 + 37 + pipe( 38 + fromValue(queryOperation), 39 + mapExchange({ onOperation })(exchangeArgs), 40 + toArray 41 + ); 42 + 43 + expect(onOperation).toBeCalledTimes(1); 44 + expect(onOperation).toBeCalledWith(queryOperation); 45 + expect(onExchangeResult).toBeCalledTimes(1); 46 + expect(onExchangeResult).toBeCalledWith(mockOperation); 47 + }); 48 + 49 + it('triggers and forwards identity when returning undefined', () => { 50 + const onOperation = vi.fn().mockReturnValue(undefined); 51 + const onExchangeResult = vi.fn(); 52 + 53 + const exchangeArgs = { 54 + forward: op$ => 55 + pipe( 56 + op$, 57 + tap(onExchangeResult), 58 + map((operation: Operation) => makeResult(operation, { data: null })) 59 + ), 60 + client: {} as Client, 61 + dispatchDebug: () => null, 62 + }; 63 + 64 + pipe( 65 + fromValue(queryOperation), 66 + mapExchange({ onOperation })(exchangeArgs), 67 + toArray 68 + ); 69 + 70 + expect(onOperation).toBeCalledTimes(1); 71 + expect(onOperation).toBeCalledWith(queryOperation); 72 + expect(onExchangeResult).toBeCalledTimes(1); 73 + expect(onExchangeResult).toBeCalledWith(queryOperation); 74 + }); 75 + 76 + it('awaits returned promises as needed', async () => { 77 + const mockOperation = makeOperation('query', queryOperation, { 78 + ...queryOperation.context, 79 + mock: true, 80 + }); 81 + 82 + const onOperation = vi.fn().mockResolvedValue(mockOperation); 83 + const onExchangeResult = vi.fn(); 84 + 85 + const exchangeArgs = { 86 + forward: op$ => 87 + pipe( 88 + op$, 89 + tap(onExchangeResult), 90 + map((operation: Operation) => makeResult(operation, { data: null })) 91 + ), 92 + client: {} as Client, 93 + dispatchDebug: () => null, 94 + }; 95 + 96 + await pipe( 97 + fromValue(queryOperation), 98 + mapExchange({ onOperation })(exchangeArgs), 99 + toPromise 100 + ); 101 + 102 + expect(onOperation).toBeCalledTimes(1); 103 + expect(onOperation).toBeCalledWith(queryOperation); 104 + expect(onExchangeResult).toBeCalledTimes(1); 105 + expect(onExchangeResult).toBeCalledWith(mockOperation); 106 + }); 107 + }); 108 + 109 + describe('onResult', () => { 110 + it('triggers and maps on results', async () => { 111 + const mockOperation = makeOperation('query', queryOperation, { 112 + ...queryOperation.context, 113 + mock: true, 114 + }); 115 + 116 + const mockResult = makeErrorResult(mockOperation, new Error('Mock')); 117 + const onResult = vi.fn().mockReturnValue(mockResult); 118 + const onExchangeResult = vi.fn(); 119 + 120 + const exchangeArgs = { 121 + forward: op$ => 122 + pipe( 123 + op$, 124 + map((operation: Operation) => makeResult(operation, { data: null })) 125 + ), 126 + client: {} as Client, 127 + dispatchDebug: () => null, 128 + }; 129 + 130 + pipe( 131 + fromValue(queryOperation), 132 + mapExchange({ onResult })(exchangeArgs), 133 + tap(onExchangeResult), 134 + toArray 135 + ); 136 + 137 + expect(onResult).toBeCalledTimes(1); 138 + expect(onResult).toBeCalledWith(makeResult(queryOperation, { data: null })); 139 + expect(onExchangeResult).toBeCalledTimes(1); 140 + expect(onExchangeResult).toBeCalledWith(mockResult); 141 + }); 142 + 143 + it('triggers and forwards identity when returning undefined', async () => { 144 + const onResult = vi.fn().mockReturnValue(undefined); 145 + const onExchangeResult = vi.fn(); 146 + 147 + const exchangeArgs = { 148 + forward: op$ => 149 + pipe( 150 + op$, 151 + map((operation: Operation) => makeResult(operation, { data: null })) 152 + ), 153 + client: {} as Client, 154 + dispatchDebug: () => null, 155 + }; 156 + 157 + pipe( 158 + fromValue(queryOperation), 159 + mapExchange({ onResult })(exchangeArgs), 160 + tap(onExchangeResult), 161 + toArray 162 + ); 163 + 164 + const result = makeResult(queryOperation, { data: null }); 165 + expect(onResult).toBeCalledTimes(1); 166 + expect(onResult).toBeCalledWith(result); 167 + expect(onExchangeResult).toBeCalledTimes(1); 168 + expect(onExchangeResult).toBeCalledWith(result); 169 + }); 170 + 171 + it('awaits returned promises as needed', async () => { 172 + const mockOperation = makeOperation('query', queryOperation, { 173 + ...queryOperation.context, 174 + mock: true, 175 + }); 176 + 177 + const mockResult = makeErrorResult(mockOperation, new Error('Mock')); 178 + const onResult = vi.fn().mockResolvedValue(mockResult); 179 + const onExchangeResult = vi.fn(); 180 + 181 + const exchangeArgs = { 182 + forward: op$ => 183 + pipe( 184 + op$, 185 + map((operation: Operation) => makeResult(operation, { data: null })) 186 + ), 187 + client: {} as Client, 188 + dispatchDebug: () => null, 189 + }; 190 + 191 + await pipe( 192 + fromValue(queryOperation), 193 + mapExchange({ onResult })(exchangeArgs), 194 + tap(onExchangeResult), 195 + toPromise 196 + ); 197 + 198 + expect(onResult).toBeCalledTimes(1); 199 + expect(onResult).toBeCalledWith(makeResult(queryOperation, { data: null })); 200 + expect(onExchangeResult).toBeCalledTimes(1); 201 + expect(onExchangeResult).toBeCalledWith(mockResult); 202 + }); 203 + }); 204 + 205 + describe('onError', () => { 206 + it('does not trigger when there are no errors', async () => { 207 + const onError = vi.fn(); 208 + 209 + const exchangeArgs = { 210 + forward: op$ => 211 + pipe( 212 + op$, 213 + map((operation: Operation) => ({ operation })) 214 + ), 215 + client: {} as Client, 216 + dispatchDebug: () => null, 217 + }; 218 + 219 + pipe( 220 + fromValue(queryOperation), 221 + mapExchange({ onError })(exchangeArgs), 222 + toArray 223 + ); 224 + 225 + expect(onError).toBeCalledTimes(0); 226 + }); 227 + 228 + it('triggers correctly when the operations has an error', async () => { 229 + const onError = vi.fn(); 230 + const error = new Error('Sad times'); 231 + 232 + const exchangeArgs = { 233 + forward: op$ => 234 + pipe( 235 + op$, 236 + map((operation: Operation) => makeErrorResult(operation, error)) 237 + ), 238 + client: {} as Client, 239 + dispatchDebug: () => null, 240 + }; 241 + 242 + pipe( 243 + fromValue(queryOperation), 244 + mapExchange({ onError })(exchangeArgs), 245 + toArray 246 + ); 247 + 248 + expect(onError).toBeCalledTimes(1); 249 + expect(onError).toBeCalledWith( 250 + new CombinedError({ networkError: error }), 251 + queryOperation 252 + ); 253 + }); 254 + });
+40
packages/core/src/exchanges/map.ts
··· 1 + import { mergeMap, fromValue, fromPromise, pipe } from 'wonka'; 2 + import { Operation, OperationResult, Exchange } from '../types'; 3 + import { CombinedError } from '../utils'; 4 + 5 + export interface MapExchangeOpts { 6 + onOperation?(operation: Operation): Promise<Operation> | Operation | void; 7 + onResult?( 8 + result: OperationResult 9 + ): Promise<OperationResult> | OperationResult | void; 10 + onError?(error: CombinedError, operation: Operation): void; 11 + } 12 + 13 + export const mapExchange = ({ 14 + onOperation, 15 + onResult, 16 + onError, 17 + }: MapExchangeOpts): Exchange => { 18 + return ({ forward }) => ops$ => { 19 + return pipe( 20 + pipe( 21 + ops$, 22 + mergeMap(operation => { 23 + const newOperation = 24 + (onOperation && onOperation(operation)) || operation; 25 + return 'then' in newOperation 26 + ? fromPromise(newOperation) 27 + : fromValue(newOperation); 28 + }) 29 + ), 30 + forward, 31 + mergeMap(result => { 32 + if (onError && result.error) onError(result.error, result.operation); 33 + const newResult = (onResult && onResult(result)) || result; 34 + return 'then' in newResult 35 + ? fromPromise(newResult) 36 + : fromValue(newResult); 37 + }) 38 + ); 39 + }; 40 + };