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.

Add multipartFetchExchange package (#585)

* start the multipart-fetch-exchange

* add test for multipartFetchExchange

* add upload test

* golf implementation

* add multipart-fetch to the codesandbox ci packages

* update README

* extract isOperationFetchable to global scope

* add multiple files test

authored by

Jovi De Croock and committed by
GitHub
b7a36c0b e66c1962

+1291
+1
.codesandbox/ci.json
··· 5 5 "packages/preact-urql", 6 6 "packages/svelte-urql", 7 7 "exchanges/graphcache", 8 + "exchanges/multipart-fetch", 8 9 "exchanges/retry", 9 10 "exchanges/suspense" 10 11 ],
+7
exchanges/multipart-fetch/CHANGELOG.md
··· 1 + # @urql/exchange-multipart-fetch 2 + 3 + ## 0.1.0 4 + 5 + ### Initial release 6 + 7 + - Release the `multipartFetchExchange`
+29
exchanges/multipart-fetch/README.md
··· 1 + # @urql/exchange-multipart-fetch 2 + 3 + The `multipartFetchExchange` is an exchange that builds on the regular `fetchExchange` 4 + but adds the multipart file upload capability. 5 + 6 + ## Quick Start Guide 7 + 8 + First install `@urql/exchange-multipart-fetch` alongside `urql`: 9 + 10 + ```sh 11 + yarn add @urql/exchange-multipart-fetch 12 + # or 13 + npm install --save @urql/exchange-multipart-fetch 14 + ``` 15 + 16 + You'll then need to add the `multipartFetchExchange` method, that this package exposes, 17 + to your `exchanges`. 18 + 19 + ```js 20 + import { createClient, dedupExchange, cacheExchange } from 'urql'; 21 + import { multipartFetchExchange } from '@urql/exchange-multipart-fetch'; 22 + 23 + const client = createClient({ 24 + url: 'http://localhost:1234/graphql', 25 + exchanges: [dedupExchange, cacheExchange, multipartFetchExchange], 26 + }); 27 + ``` 28 + 29 + Now we can start uploading files to our server!
+62
exchanges/multipart-fetch/package.json
··· 1 + { 2 + "name": "@urql/exchange-multipart-fetch", 3 + "version": "0.1.0", 4 + "description": "An exchange that allows regular fetch and will transition to multipart when files are included", 5 + "sideEffects": false, 6 + "homepage": "https://formidable.com/open-source/urql/docs/", 7 + "bugs": "https://github.com/FormidableLabs/urql/issues", 8 + "license": "MIT", 9 + "repository": { 10 + "type": "git", 11 + "url": "https://github.com/FormidableLabs/urql.git", 12 + "directory": "exchanges/multipart-fetch" 13 + }, 14 + "keywords": [ 15 + "urql", 16 + "formidablelabs", 17 + "exchanges" 18 + ], 19 + "main": "dist/urql-exchange-multipart-fetch.cjs.js", 20 + "module": "dist/urql-exchange-multipart-fetch.esm.js", 21 + "types": "dist/types/index.d.ts", 22 + "source": "src/index.ts", 23 + "exports": { 24 + ".": { 25 + "import": "dist/urql-exchange-multipart-fetch.esm.js", 26 + "require": "dist/urql-exchange-multipart-fetch.cjs.js", 27 + "types": "dist/types/index.d.ts", 28 + "source": "src/index.ts" 29 + } 30 + }, 31 + "files": [ 32 + "LICENSE", 33 + "CHANGELOG.md", 34 + "README.md", 35 + "dist/", 36 + "extras/" 37 + ], 38 + "scripts": { 39 + "test": "jest", 40 + "clean": "rimraf dist extras", 41 + "check": "tsc --noEmit", 42 + "lint": "eslint --ext=js,jsx,ts,tsx .", 43 + "build": "rollup -c ../../scripts/rollup/config.js", 44 + "prepare": "../../scripts/prepare/index.js", 45 + "prepublishOnly": "run-s clean test build" 46 + }, 47 + "jest": { 48 + "preset": "../../scripts/jest/preset" 49 + }, 50 + "dependencies": { 51 + "@urql/core": ">=1.9.2", 52 + "extract-files": "^7.0.0", 53 + "wonka": "^3.2.1 || ^4.0.0" 54 + }, 55 + "peerDependencies": { 56 + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0" 57 + }, 58 + "devDependencies": { 59 + "graphql": "^14.5.8", 60 + "graphql-tag": "^2.10.1" 61 + } 62 + }
+664
exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
··· 1 + // Jest Snapshot v1, https://goo.gl/fbAQLP 2 + 3 + exports[`on error returns error data 1`] = ` 4 + Object { 5 + "data": undefined, 6 + "error": [CombinedError: [Network] ], 7 + "extensions": undefined, 8 + "operation": Object { 9 + "context": Object { 10 + "fetchOptions": Object { 11 + "method": "POST", 12 + }, 13 + "requestPolicy": "cache-first", 14 + "url": "http://localhost:3000/graphql", 15 + }, 16 + "key": 2, 17 + "operationName": "query", 18 + "query": Object { 19 + "definitions": Array [ 20 + Object { 21 + "directives": Array [], 22 + "kind": "OperationDefinition", 23 + "name": Object { 24 + "kind": "Name", 25 + "value": "getUser", 26 + }, 27 + "operation": "query", 28 + "selectionSet": Object { 29 + "kind": "SelectionSet", 30 + "selections": Array [ 31 + Object { 32 + "alias": undefined, 33 + "arguments": Array [ 34 + Object { 35 + "kind": "Argument", 36 + "name": Object { 37 + "kind": "Name", 38 + "value": "name", 39 + }, 40 + "value": Object { 41 + "kind": "Variable", 42 + "name": Object { 43 + "kind": "Name", 44 + "value": "name", 45 + }, 46 + }, 47 + }, 48 + ], 49 + "directives": Array [], 50 + "kind": "Field", 51 + "name": Object { 52 + "kind": "Name", 53 + "value": "user", 54 + }, 55 + "selectionSet": Object { 56 + "kind": "SelectionSet", 57 + "selections": Array [ 58 + Object { 59 + "alias": undefined, 60 + "arguments": Array [], 61 + "directives": Array [], 62 + "kind": "Field", 63 + "name": Object { 64 + "kind": "Name", 65 + "value": "id", 66 + }, 67 + "selectionSet": undefined, 68 + }, 69 + Object { 70 + "alias": undefined, 71 + "arguments": Array [], 72 + "directives": Array [], 73 + "kind": "Field", 74 + "name": Object { 75 + "kind": "Name", 76 + "value": "firstName", 77 + }, 78 + "selectionSet": undefined, 79 + }, 80 + Object { 81 + "alias": undefined, 82 + "arguments": Array [], 83 + "directives": Array [], 84 + "kind": "Field", 85 + "name": Object { 86 + "kind": "Name", 87 + "value": "lastName", 88 + }, 89 + "selectionSet": undefined, 90 + }, 91 + ], 92 + }, 93 + }, 94 + ], 95 + }, 96 + "variableDefinitions": Array [ 97 + Object { 98 + "defaultValue": undefined, 99 + "directives": Array [], 100 + "kind": "VariableDefinition", 101 + "type": Object { 102 + "kind": "NamedType", 103 + "name": Object { 104 + "kind": "Name", 105 + "value": "String", 106 + }, 107 + }, 108 + "variable": Object { 109 + "kind": "Variable", 110 + "name": Object { 111 + "kind": "Name", 112 + "value": "name", 113 + }, 114 + }, 115 + }, 116 + ], 117 + }, 118 + ], 119 + "kind": "Document", 120 + "loc": Object { 121 + "end": 124, 122 + "start": 0, 123 + }, 124 + }, 125 + "variables": Object { 126 + "name": "Clara", 127 + }, 128 + }, 129 + } 130 + `; 131 + 132 + exports[`on error returns error data with status 400 and manual redirect mode 1`] = ` 133 + Object { 134 + "data": undefined, 135 + "error": [CombinedError: [Network] ], 136 + "extensions": undefined, 137 + "operation": Object { 138 + "context": Object { 139 + "fetchOptions": [MockFunction] { 140 + "calls": Array [ 141 + Array [], 142 + ], 143 + "results": Array [ 144 + Object { 145 + "type": "return", 146 + "value": Object { 147 + "redirect": "manual", 148 + }, 149 + }, 150 + ], 151 + }, 152 + "requestPolicy": "cache-first", 153 + "url": "http://localhost:3000/graphql", 154 + }, 155 + "key": 2, 156 + "operationName": "query", 157 + "query": Object { 158 + "definitions": Array [ 159 + Object { 160 + "directives": Array [], 161 + "kind": "OperationDefinition", 162 + "name": Object { 163 + "kind": "Name", 164 + "value": "getUser", 165 + }, 166 + "operation": "query", 167 + "selectionSet": Object { 168 + "kind": "SelectionSet", 169 + "selections": Array [ 170 + Object { 171 + "alias": undefined, 172 + "arguments": Array [ 173 + Object { 174 + "kind": "Argument", 175 + "name": Object { 176 + "kind": "Name", 177 + "value": "name", 178 + }, 179 + "value": Object { 180 + "kind": "Variable", 181 + "name": Object { 182 + "kind": "Name", 183 + "value": "name", 184 + }, 185 + }, 186 + }, 187 + ], 188 + "directives": Array [], 189 + "kind": "Field", 190 + "name": Object { 191 + "kind": "Name", 192 + "value": "user", 193 + }, 194 + "selectionSet": Object { 195 + "kind": "SelectionSet", 196 + "selections": Array [ 197 + Object { 198 + "alias": undefined, 199 + "arguments": Array [], 200 + "directives": Array [], 201 + "kind": "Field", 202 + "name": Object { 203 + "kind": "Name", 204 + "value": "id", 205 + }, 206 + "selectionSet": undefined, 207 + }, 208 + Object { 209 + "alias": undefined, 210 + "arguments": Array [], 211 + "directives": Array [], 212 + "kind": "Field", 213 + "name": Object { 214 + "kind": "Name", 215 + "value": "firstName", 216 + }, 217 + "selectionSet": undefined, 218 + }, 219 + Object { 220 + "alias": undefined, 221 + "arguments": Array [], 222 + "directives": Array [], 223 + "kind": "Field", 224 + "name": Object { 225 + "kind": "Name", 226 + "value": "lastName", 227 + }, 228 + "selectionSet": undefined, 229 + }, 230 + ], 231 + }, 232 + }, 233 + ], 234 + }, 235 + "variableDefinitions": Array [ 236 + Object { 237 + "defaultValue": undefined, 238 + "directives": Array [], 239 + "kind": "VariableDefinition", 240 + "type": Object { 241 + "kind": "NamedType", 242 + "name": Object { 243 + "kind": "Name", 244 + "value": "String", 245 + }, 246 + }, 247 + "variable": Object { 248 + "kind": "Variable", 249 + "name": Object { 250 + "kind": "Name", 251 + "value": "name", 252 + }, 253 + }, 254 + }, 255 + ], 256 + }, 257 + ], 258 + "kind": "Document", 259 + "loc": Object { 260 + "end": 124, 261 + "start": 0, 262 + }, 263 + }, 264 + "variables": Object { 265 + "name": "Clara", 266 + }, 267 + }, 268 + } 269 + `; 270 + 271 + exports[`on success returns response data 1`] = ` 272 + Object { 273 + "data": Object { 274 + "data": Object { 275 + "user": 1200, 276 + }, 277 + }, 278 + "error": undefined, 279 + "extensions": undefined, 280 + "operation": Object { 281 + "context": Object { 282 + "fetchOptions": [MockFunction] { 283 + "calls": Array [ 284 + Array [], 285 + ], 286 + "results": Array [ 287 + Object { 288 + "type": "return", 289 + "value": Object {}, 290 + }, 291 + ], 292 + }, 293 + "requestPolicy": "cache-first", 294 + "url": "http://localhost:3000/graphql", 295 + }, 296 + "key": 2, 297 + "operationName": "query", 298 + "query": Object { 299 + "definitions": Array [ 300 + Object { 301 + "directives": Array [], 302 + "kind": "OperationDefinition", 303 + "name": Object { 304 + "kind": "Name", 305 + "value": "getUser", 306 + }, 307 + "operation": "query", 308 + "selectionSet": Object { 309 + "kind": "SelectionSet", 310 + "selections": Array [ 311 + Object { 312 + "alias": undefined, 313 + "arguments": Array [ 314 + Object { 315 + "kind": "Argument", 316 + "name": Object { 317 + "kind": "Name", 318 + "value": "name", 319 + }, 320 + "value": Object { 321 + "kind": "Variable", 322 + "name": Object { 323 + "kind": "Name", 324 + "value": "name", 325 + }, 326 + }, 327 + }, 328 + ], 329 + "directives": Array [], 330 + "kind": "Field", 331 + "name": Object { 332 + "kind": "Name", 333 + "value": "user", 334 + }, 335 + "selectionSet": Object { 336 + "kind": "SelectionSet", 337 + "selections": Array [ 338 + Object { 339 + "alias": undefined, 340 + "arguments": Array [], 341 + "directives": Array [], 342 + "kind": "Field", 343 + "name": Object { 344 + "kind": "Name", 345 + "value": "id", 346 + }, 347 + "selectionSet": undefined, 348 + }, 349 + Object { 350 + "alias": undefined, 351 + "arguments": Array [], 352 + "directives": Array [], 353 + "kind": "Field", 354 + "name": Object { 355 + "kind": "Name", 356 + "value": "firstName", 357 + }, 358 + "selectionSet": undefined, 359 + }, 360 + Object { 361 + "alias": undefined, 362 + "arguments": Array [], 363 + "directives": Array [], 364 + "kind": "Field", 365 + "name": Object { 366 + "kind": "Name", 367 + "value": "lastName", 368 + }, 369 + "selectionSet": undefined, 370 + }, 371 + ], 372 + }, 373 + }, 374 + ], 375 + }, 376 + "variableDefinitions": Array [ 377 + Object { 378 + "defaultValue": undefined, 379 + "directives": Array [], 380 + "kind": "VariableDefinition", 381 + "type": Object { 382 + "kind": "NamedType", 383 + "name": Object { 384 + "kind": "Name", 385 + "value": "String", 386 + }, 387 + }, 388 + "variable": Object { 389 + "kind": "Variable", 390 + "name": Object { 391 + "kind": "Name", 392 + "value": "name", 393 + }, 394 + }, 395 + }, 396 + ], 397 + }, 398 + ], 399 + "kind": "Document", 400 + "loc": Object { 401 + "end": 124, 402 + "start": 0, 403 + }, 404 + }, 405 + "variables": Object { 406 + "name": "Clara", 407 + }, 408 + }, 409 + } 410 + `; 411 + 412 + exports[`on success returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\\\n\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"operationName\\":\\"getUser\\"}"`; 413 + 414 + exports[`on success uses a file when given 1`] = ` 415 + Object { 416 + "data": Object { 417 + "data": Object { 418 + "user": 1200, 419 + }, 420 + }, 421 + "error": undefined, 422 + "extensions": undefined, 423 + "operation": Object { 424 + "context": Object { 425 + "fetchOptions": [MockFunction] { 426 + "calls": Array [ 427 + Array [], 428 + ], 429 + "results": Array [ 430 + Object { 431 + "type": "return", 432 + "value": Object {}, 433 + }, 434 + ], 435 + }, 436 + "requestPolicy": "cache-first", 437 + "url": "http://localhost:3000/graphql", 438 + }, 439 + "key": 3, 440 + "operationName": "mutation", 441 + "query": Object { 442 + "definitions": Array [ 443 + Object { 444 + "directives": Array [], 445 + "kind": "OperationDefinition", 446 + "name": Object { 447 + "kind": "Name", 448 + "value": "uploadProfilePicture", 449 + }, 450 + "operation": "mutation", 451 + "selectionSet": Object { 452 + "kind": "SelectionSet", 453 + "selections": Array [ 454 + Object { 455 + "alias": undefined, 456 + "arguments": Array [ 457 + Object { 458 + "kind": "Argument", 459 + "name": Object { 460 + "kind": "Name", 461 + "value": "picture", 462 + }, 463 + "value": Object { 464 + "kind": "Variable", 465 + "name": Object { 466 + "kind": "Name", 467 + "value": "picture", 468 + }, 469 + }, 470 + }, 471 + ], 472 + "directives": Array [], 473 + "kind": "Field", 474 + "name": Object { 475 + "kind": "Name", 476 + "value": "uploadProfilePicture", 477 + }, 478 + "selectionSet": Object { 479 + "kind": "SelectionSet", 480 + "selections": Array [ 481 + Object { 482 + "alias": undefined, 483 + "arguments": Array [], 484 + "directives": Array [], 485 + "kind": "Field", 486 + "name": Object { 487 + "kind": "Name", 488 + "value": "location", 489 + }, 490 + "selectionSet": undefined, 491 + }, 492 + ], 493 + }, 494 + }, 495 + ], 496 + }, 497 + "variableDefinitions": Array [ 498 + Object { 499 + "defaultValue": undefined, 500 + "directives": Array [], 501 + "kind": "VariableDefinition", 502 + "type": Object { 503 + "kind": "NamedType", 504 + "name": Object { 505 + "kind": "Name", 506 + "value": "File", 507 + }, 508 + }, 509 + "variable": Object { 510 + "kind": "Variable", 511 + "name": Object { 512 + "kind": "Name", 513 + "value": "picture", 514 + }, 515 + }, 516 + }, 517 + ], 518 + }, 519 + ], 520 + "kind": "Document", 521 + "loc": Object { 522 + "end": 134, 523 + "start": 0, 524 + }, 525 + }, 526 + "variables": Object { 527 + "picture": File {}, 528 + }, 529 + }, 530 + } 531 + `; 532 + 533 + exports[`on success uses a file when given 2`] = `Object {}`; 534 + 535 + exports[`on success uses a file when given 3`] = `FormData {}`; 536 + 537 + exports[`on success uses multiple files when given 1`] = ` 538 + Object { 539 + "data": Object { 540 + "data": Object { 541 + "user": 1200, 542 + }, 543 + }, 544 + "error": undefined, 545 + "extensions": undefined, 546 + "operation": Object { 547 + "context": Object { 548 + "fetchOptions": [MockFunction] { 549 + "calls": Array [ 550 + Array [], 551 + ], 552 + "results": Array [ 553 + Object { 554 + "type": "return", 555 + "value": Object {}, 556 + }, 557 + ], 558 + }, 559 + "requestPolicy": "cache-first", 560 + "url": "http://localhost:3000/graphql", 561 + }, 562 + "key": 3, 563 + "operationName": "mutation", 564 + "query": Object { 565 + "definitions": Array [ 566 + Object { 567 + "directives": Array [], 568 + "kind": "OperationDefinition", 569 + "name": Object { 570 + "kind": "Name", 571 + "value": "uploadProfilePictures", 572 + }, 573 + "operation": "mutation", 574 + "selectionSet": Object { 575 + "kind": "SelectionSet", 576 + "selections": Array [ 577 + Object { 578 + "alias": undefined, 579 + "arguments": Array [ 580 + Object { 581 + "kind": "Argument", 582 + "name": Object { 583 + "kind": "Name", 584 + "value": "pictures", 585 + }, 586 + "value": Object { 587 + "kind": "Variable", 588 + "name": Object { 589 + "kind": "Name", 590 + "value": "pictures", 591 + }, 592 + }, 593 + }, 594 + ], 595 + "directives": Array [], 596 + "kind": "Field", 597 + "name": Object { 598 + "kind": "Name", 599 + "value": "uploadProfilePicture", 600 + }, 601 + "selectionSet": Object { 602 + "kind": "SelectionSet", 603 + "selections": Array [ 604 + Object { 605 + "alias": undefined, 606 + "arguments": Array [], 607 + "directives": Array [], 608 + "kind": "Field", 609 + "name": Object { 610 + "kind": "Name", 611 + "value": "location", 612 + }, 613 + "selectionSet": undefined, 614 + }, 615 + ], 616 + }, 617 + }, 618 + ], 619 + }, 620 + "variableDefinitions": Array [ 621 + Object { 622 + "defaultValue": undefined, 623 + "directives": Array [], 624 + "kind": "VariableDefinition", 625 + "type": Object { 626 + "kind": "ListType", 627 + "type": Object { 628 + "kind": "NamedType", 629 + "name": Object { 630 + "kind": "Name", 631 + "value": "File", 632 + }, 633 + }, 634 + }, 635 + "variable": Object { 636 + "kind": "Variable", 637 + "name": Object { 638 + "kind": "Name", 639 + "value": "pictures", 640 + }, 641 + }, 642 + }, 643 + ], 644 + }, 645 + ], 646 + "kind": "Document", 647 + "loc": Object { 648 + "end": 140, 649 + "start": 0, 650 + }, 651 + }, 652 + "variables": Object { 653 + "picture": Array [ 654 + File {}, 655 + File {}, 656 + ], 657 + }, 658 + }, 659 + } 660 + `; 661 + 662 + exports[`on success uses multiple files when given 2`] = `Object {}`; 663 + 664 + exports[`on success uses multiple files when given 3`] = `FormData {}`;
+1
exchanges/multipart-fetch/src/index.ts
··· 1 + export * from './multipartFetchExchange';
+237
exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
··· 1 + import { Client, OperationResult, OperationType } from '@urql/core'; 2 + import { empty, fromValue, pipe, Source, subscribe, toPromise } from 'wonka'; 3 + import gql from 'graphql-tag'; 4 + import { print } from 'graphql'; 5 + 6 + import { multipartFetchExchange, convertToGet } from './multipartFetchExchange'; 7 + import { 8 + uploadOperation, 9 + queryOperation, 10 + multipleUploadOperation, 11 + } from './test-utils'; 12 + 13 + const fetch = (global as any).fetch as jest.Mock; 14 + const abort = jest.fn(); 15 + 16 + const abortError = new Error(); 17 + abortError.name = 'AbortError'; 18 + 19 + beforeAll(() => { 20 + (global as any).AbortController = function AbortController() { 21 + this.signal = undefined; 22 + this.abort = abort; 23 + }; 24 + }); 25 + 26 + afterEach(() => { 27 + fetch.mockClear(); 28 + abort.mockClear(); 29 + }); 30 + 31 + afterAll(() => { 32 + (global as any).AbortController = undefined; 33 + }); 34 + 35 + const response = { 36 + status: 200, 37 + data: { 38 + data: { 39 + user: 1200, 40 + }, 41 + }, 42 + }; 43 + 44 + const exchangeArgs = { 45 + forward: () => empty as Source<OperationResult>, 46 + client: {} as Client, 47 + }; 48 + 49 + describe('on success', () => { 50 + beforeEach(() => { 51 + fetch.mockResolvedValue({ 52 + status: 200, 53 + json: jest.fn().mockResolvedValue(response), 54 + }); 55 + }); 56 + 57 + it('uses a file when given', async () => { 58 + const fetchOptions = jest.fn().mockReturnValue({}); 59 + 60 + const data = await pipe( 61 + fromValue({ 62 + ...uploadOperation, 63 + context: { 64 + ...uploadOperation.context, 65 + fetchOptions, 66 + }, 67 + }), 68 + multipartFetchExchange(exchangeArgs), 69 + toPromise 70 + ); 71 + 72 + expect(data).toMatchSnapshot(); 73 + expect(fetchOptions).toHaveBeenCalled(); 74 + expect(fetch.mock.calls[0][1].headers).toMatchSnapshot(); 75 + expect(fetch.mock.calls[0][1].body).toMatchSnapshot(); 76 + }); 77 + 78 + it('uses multiple files when given', async () => { 79 + const fetchOptions = jest.fn().mockReturnValue({}); 80 + 81 + const data = await pipe( 82 + fromValue({ 83 + ...multipleUploadOperation, 84 + context: { 85 + ...multipleUploadOperation.context, 86 + fetchOptions, 87 + }, 88 + }), 89 + multipartFetchExchange(exchangeArgs), 90 + toPromise 91 + ); 92 + 93 + expect(data).toMatchSnapshot(); 94 + expect(fetchOptions).toHaveBeenCalled(); 95 + expect(fetch.mock.calls[0][1].headers).toMatchSnapshot(); 96 + expect(fetch.mock.calls[0][1].body).toMatchSnapshot(); 97 + }); 98 + 99 + it('returns response data', async () => { 100 + const fetchOptions = jest.fn().mockReturnValue({}); 101 + 102 + const data = await pipe( 103 + fromValue({ 104 + ...queryOperation, 105 + context: { 106 + ...queryOperation.context, 107 + fetchOptions, 108 + }, 109 + }), 110 + multipartFetchExchange(exchangeArgs), 111 + toPromise 112 + ); 113 + 114 + expect(data).toMatchSnapshot(); 115 + expect(fetchOptions).toHaveBeenCalled(); 116 + expect(fetch.mock.calls[0][1].body).toMatchSnapshot(); 117 + }); 118 + }); 119 + 120 + describe('on error', () => { 121 + beforeEach(() => { 122 + fetch.mockResolvedValue({ 123 + status: 400, 124 + json: jest.fn().mockResolvedValue(response), 125 + }); 126 + }); 127 + 128 + it('returns error data', async () => { 129 + const data = await pipe( 130 + fromValue(queryOperation), 131 + multipartFetchExchange(exchangeArgs), 132 + toPromise 133 + ); 134 + 135 + expect(data).toMatchSnapshot(); 136 + }); 137 + 138 + it('returns error data with status 400 and manual redirect mode', async () => { 139 + const fetchOptions = jest.fn().mockReturnValue({ redirect: 'manual' }); 140 + 141 + const data = await pipe( 142 + fromValue({ 143 + ...queryOperation, 144 + context: { 145 + ...queryOperation.context, 146 + fetchOptions, 147 + }, 148 + }), 149 + multipartFetchExchange(exchangeArgs), 150 + toPromise 151 + ); 152 + 153 + expect(data).toMatchSnapshot(); 154 + }); 155 + }); 156 + 157 + describe('on teardown', () => { 158 + it('does not start the outgoing request on immediate teardowns', () => { 159 + fetch.mockRejectedValueOnce(abortError); 160 + 161 + const { unsubscribe } = pipe( 162 + fromValue(queryOperation), 163 + multipartFetchExchange(exchangeArgs), 164 + subscribe(fail) 165 + ); 166 + 167 + unsubscribe(undefined); 168 + expect(fetch).toHaveBeenCalledTimes(0); 169 + expect(abort).toHaveBeenCalledTimes(1); 170 + }); 171 + 172 + it('aborts the outgoing request', async () => { 173 + fetch.mockRejectedValueOnce(abortError); 174 + 175 + const { unsubscribe } = pipe( 176 + fromValue(queryOperation), 177 + multipartFetchExchange(exchangeArgs), 178 + subscribe(fail) 179 + ); 180 + 181 + await Promise.resolve(); 182 + 183 + unsubscribe(undefined); 184 + expect(fetch).toHaveBeenCalledTimes(1); 185 + expect(abort).toHaveBeenCalledTimes(1); 186 + }); 187 + 188 + it('does not call the query', () => { 189 + pipe( 190 + fromValue({ 191 + ...queryOperation, 192 + operationName: 'teardown' as OperationType, 193 + }), 194 + multipartFetchExchange(exchangeArgs), 195 + subscribe(fail) 196 + ); 197 + 198 + expect(fetch).toHaveBeenCalledTimes(0); 199 + expect(abort).toHaveBeenCalledTimes(0); 200 + }); 201 + }); 202 + 203 + describe('convert for GET', () => { 204 + it('should do a basic conversion', () => { 205 + const query = `query ($id: ID!) { node(id: $id) { id } }`; 206 + const variables = { id: 2 }; 207 + expect(convertToGet('http://localhost:3000', { query, variables })).toBe( 208 + `http://localhost:3000?query=${encodeURIComponent( 209 + query 210 + )}&variables=${encodeURIComponent(JSON.stringify(variables))}` 211 + ); 212 + }); 213 + 214 + it('should do a basic conversion with fragments', () => { 215 + const nodeFragment = gql` 216 + fragment nodeFragment on Node { 217 + id 218 + } 219 + `; 220 + 221 + const variables = { id: 2 }; 222 + const query = print(gql` 223 + query($id: ID!) { 224 + node(id: $id) { 225 + ...nodeFragment 226 + } 227 + } 228 + ${nodeFragment} 229 + `); 230 + 231 + expect(convertToGet('http://localhost:3000', { query, variables })).toBe( 232 + `http://localhost:3000?query=${encodeURIComponent( 233 + query 234 + )}&variables=${encodeURIComponent(JSON.stringify(variables))}` 235 + ); 236 + }); 237 + });
+194
exchanges/multipart-fetch/src/multipartFetchExchange.ts
··· 1 + import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql'; 2 + import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka'; 3 + import { extractFiles } from 'extract-files'; 4 + import { 5 + Exchange, 6 + Operation, 7 + OperationResult, 8 + makeResult, 9 + makeErrorResult, 10 + } from '@urql/core'; 11 + 12 + interface Body { 13 + query: string; 14 + variables: void | object; 15 + operationName?: string; 16 + } 17 + 18 + const isOperationFetchable = (operation: Operation) => 19 + operation.operationName === 'query' || operation.operationName === 'mutation'; 20 + 21 + export const multipartFetchExchange: Exchange = ({ forward }) => ops$ => { 22 + const sharedOps$ = share(ops$); 23 + 24 + const fetchResults$ = pipe( 25 + sharedOps$, 26 + filter(isOperationFetchable), 27 + mergeMap(operation => { 28 + const teardown$ = pipe( 29 + sharedOps$, 30 + filter( 31 + op => op.operationName === 'teardown' && op.key === operation.key 32 + ) 33 + ); 34 + 35 + return pipe( 36 + createFetchSource( 37 + operation, 38 + operation.operationName === 'query' && 39 + !!operation.context.preferGetMethod 40 + ), 41 + takeUntil(teardown$) 42 + ); 43 + }) 44 + ); 45 + 46 + const forward$ = pipe( 47 + sharedOps$, 48 + filter(op => !isOperationFetchable(op)), 49 + forward 50 + ); 51 + 52 + return merge([fetchResults$, forward$]); 53 + }; 54 + 55 + const getOperationName = (query: DocumentNode): string | null => { 56 + const node = query.definitions.find( 57 + (node: any): node is OperationDefinitionNode => { 58 + return node.kind === Kind.OPERATION_DEFINITION && node.name; 59 + } 60 + ); 61 + 62 + return node && node.name ? node.name.value : null; 63 + }; 64 + 65 + const createFetchSource = (operation: Operation, shouldUseGet: boolean) => { 66 + if ( 67 + process.env.NODE_ENV !== 'production' && 68 + operation.operationName === 'subscription' 69 + ) { 70 + throw new Error( 71 + 'Received a subscription operation in the httpExchange. You are probably trying to create a subscription. Have you added a subscriptionExchange?' 72 + ); 73 + } 74 + 75 + return make<OperationResult>(({ next, complete }) => { 76 + const abortController = 77 + typeof AbortController !== 'undefined' 78 + ? new AbortController() 79 + : undefined; 80 + 81 + const { context } = operation; 82 + const { files } = extractFiles(operation.variables); 83 + 84 + const extraOptions = 85 + typeof context.fetchOptions === 'function' 86 + ? context.fetchOptions() 87 + : context.fetchOptions || {}; 88 + 89 + const operationName = getOperationName(operation.query); 90 + 91 + const body: Body = { 92 + query: print(operation.query), 93 + variables: operation.variables, 94 + }; 95 + 96 + if (operationName !== null) { 97 + body.operationName = operationName; 98 + } 99 + 100 + const fetchOptions = { 101 + ...extraOptions, 102 + method: shouldUseGet ? 'GET' : 'POST', 103 + headers: { 104 + 'content-type': 'application/json', 105 + ...extraOptions.headers, 106 + }, 107 + signal: 108 + abortController !== undefined ? abortController.signal : undefined, 109 + }; 110 + 111 + if (!!files.size) { 112 + fetchOptions.body = new FormData(); 113 + fetchOptions.method = 'POST'; 114 + // Make fetch auto-append this for correctness 115 + delete fetchOptions.headers['content-type']; 116 + 117 + fetchOptions.body.append('operations', JSON.stringify(body)); 118 + 119 + const map = {}; 120 + let i = 0; 121 + files.forEach(paths => { 122 + map[++i] = paths.map(path => `variables.${path}`); 123 + }); 124 + fetchOptions.body.append('map', JSON.stringify(map)); 125 + 126 + i = 0; 127 + files.forEach((_, file) => { 128 + (fetchOptions.body as FormData).append(`${++i}`, file, file.name); 129 + }); 130 + } else if (shouldUseGet) { 131 + operation.context.url = convertToGet(operation.context.url, body); 132 + } else { 133 + fetchOptions.body = JSON.stringify(body); 134 + } 135 + 136 + let ended = false; 137 + 138 + Promise.resolve() 139 + .then(() => (ended ? undefined : executeFetch(operation, fetchOptions))) 140 + .then((result: OperationResult | undefined) => { 141 + if (!ended) { 142 + ended = true; 143 + if (result) next(result); 144 + complete(); 145 + } 146 + }); 147 + 148 + return () => { 149 + ended = true; 150 + if (abortController !== undefined) { 151 + abortController.abort(); 152 + } 153 + }; 154 + }); 155 + }; 156 + 157 + const executeFetch = ( 158 + operation: Operation, 159 + opts: RequestInit 160 + ): Promise<OperationResult> => { 161 + const { url, fetch: fetcher } = operation.context; 162 + let response: Response | undefined; 163 + 164 + return (fetcher || fetch)(url, opts) 165 + .then(res => { 166 + const { status } = res; 167 + const statusRangeEnd = opts.redirect === 'manual' ? 400 : 300; 168 + response = res; 169 + 170 + if (status < 200 || status >= statusRangeEnd) { 171 + throw new Error(res.statusText); 172 + } else { 173 + return res.json(); 174 + } 175 + }) 176 + .then(result => makeResult(operation, result, response)) 177 + .catch(err => { 178 + if (err.name !== 'AbortError') { 179 + return makeErrorResult(operation, err, response); 180 + } 181 + }); 182 + }; 183 + 184 + export const convertToGet = (uri: string, body: Body): string => { 185 + const queryParams: string[] = [`query=${encodeURIComponent(body.query)}`]; 186 + 187 + if (body.variables) { 188 + queryParams.push( 189 + `variables=${encodeURIComponent(JSON.stringify(body.variables))}` 190 + ); 191 + } 192 + 193 + return uri + '?' + queryParams.join('&'); 194 + };
+79
exchanges/multipart-fetch/src/test-utils.ts
··· 1 + import { GraphQLRequest, OperationContext, Operation } from '@urql/core'; 2 + import gql from 'graphql-tag'; 3 + 4 + const context: OperationContext = { 5 + fetchOptions: { 6 + method: 'POST', 7 + }, 8 + requestPolicy: 'cache-first', 9 + url: 'http://localhost:3000/graphql', 10 + }; 11 + 12 + const queryGql: GraphQLRequest = { 13 + key: 2, 14 + query: gql` 15 + query getUser($name: String) { 16 + user(name: $name) { 17 + id 18 + firstName 19 + lastName 20 + } 21 + } 22 + `, 23 + variables: { 24 + name: 'Clara', 25 + }, 26 + }; 27 + 28 + const obj = { hello: 'world' }; 29 + const file = new File( 30 + [new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })], 31 + 'index.ts' 32 + ); 33 + const files = [file, file]; 34 + 35 + const upload: GraphQLRequest = { 36 + key: 3, 37 + query: gql` 38 + mutation uploadProfilePicture($picture: File) { 39 + uploadProfilePicture(picture: $picture) { 40 + location 41 + } 42 + } 43 + `, 44 + variables: { 45 + picture: file, 46 + }, 47 + }; 48 + 49 + const uploads: GraphQLRequest = { 50 + key: 3, 51 + query: gql` 52 + mutation uploadProfilePictures($pictures: [File]) { 53 + uploadProfilePicture(pictures: $pictures) { 54 + location 55 + } 56 + } 57 + `, 58 + variables: { 59 + picture: files, 60 + }, 61 + }; 62 + 63 + export const uploadOperation: Operation = { 64 + ...upload, 65 + operationName: 'mutation', 66 + context, 67 + }; 68 + 69 + export const multipleUploadOperation: Operation = { 70 + ...uploads, 71 + operationName: 'mutation', 72 + context, 73 + }; 74 + 75 + export const queryOperation: Operation = { 76 + ...queryGql, 77 + operationName: 'query', 78 + context, 79 + };
+12
exchanges/multipart-fetch/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": ["src"], 4 + "compilerOptions": { 5 + "baseUrl": "./", 6 + "paths": { 7 + "urql": ["../../node_modules/urql/src"], 8 + "*-urql": ["../../node_modules/*-urql/src"], 9 + "@urql/*": ["../../node_modules/@urql/*/src"] 10 + } 11 + } 12 + }
+5
yarn.lock
··· 5275 5275 webpack-external-import "^1.1.0-beta.3" 5276 5276 webpack-sources "^1.1.0" 5277 5277 5278 + extract-files@^7.0.0: 5279 + version "7.0.0" 5280 + resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-7.0.0.tgz#3dc7853320ff7876ec62d6e98f2f4e6f3e6282f6" 5281 + integrity sha512-3AUlT7TD+DbQXNe3t70QrgJU6Wgcp7rk1Zm0vqWz8OYnw4vxihgG0TgZ2SIGrVqScc4WfOu7B4a0BezGJ0YqvQ== 5282 + 5278 5283 extsprintf@1.3.0: 5279 5284 version "1.3.0" 5280 5285 resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"