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): Add support for GraphQL multipart requests to core (#3051)

authored by

Phil Pluckthun and committed by
GitHub
ec6adbe4 8deee397

+306 -81
+5
.changeset/quiet-ants-collect.md
··· 1 + --- 2 + '@urql/core': minor 3 + --- 4 + 5 + Implement GraphQL Multipart Request support in `@urql/core`. This adds the File/Blob upload support to `@urql/core`, which effectively deprecates `@urql/exchange-multipart-fetch`
+5
.changeset/violet-kings-fold.md
··· 1 + --- 2 + '@urql/exchange-multipart-fetch': patch 3 + --- 4 + 5 + Deprecate `@urql/exchange-multipart-fetch` as behaviour has been absorbed into `@urql/core`. If you're using the `multipartFetchExchange`, you should now be able to simply remove it.
+5
docs/api/multipart-fetch-exchange.md
··· 5 5 6 6 # Multipart Fetch Exchange 7 7 8 + > **Deprecation**: The `multipartFetchExchange` has been deprecated, and 9 + > `@urql/core` now supports GraphQL Multipart Requests natively. This won't 10 + > break the behaviour of your existing apps, however, it's recommended to remove 11 + > the `multipartFetchExchange` from your apps. 12 + 8 13 The `@urql/exchange-multipart-fetch` package contains an addon `multipartFetchExchange` for `urql` 9 14 that enables file uploads via `multipart/form-data` POST requests. 10 15
+1 -1
docs/comparison.md
··· 53 53 | Live Queries | 🛑 | 🛑 | ✅ | 54 54 | Defer & Stream Directives | ✅ | ✅ / 🛑 (`@defer` is supported in >=3.7.0, `@stream` is not yet supported) | 🟡 (unreleased) | 55 55 | Switching to `GET` method | ✅ | ✅ | 🟡 `react-relay-network-layer` | 56 - | File Uploads | ✅ `@urql/exchange-multipart-fetch` | 🟡 `apollo-upload-client` | 🛑 | 56 + | File Uploads | ✅ | 🟡 `apollo-upload-client` | 🛑 | 57 57 | Retrying Failed Queries | ✅ `@urql/exchange-retry` | ✅ `apollo-link-retry` | ✅ `DefaultNetworkLayer` | 58 58 | Easy Authentication Flows | ✅ `@urql/exchange-auth` | 🛑 (no docs for refresh-based authentication) | 🟡 `react-relay-network-layer` | 59 59 | Automatic Refetch after Mutation | ✅ (with document cache) | 🛑 | ✅ |
+5
exchanges/multipart-fetch/README.md
··· 1 1 # @urql/exchange-multipart-fetch 2 2 3 + > **DEPRECATION NOTICE**: The `multipartFetchExchange` has been deprecated, and 4 + > `@urql/core` now supports GraphQL Multipart Requests natively. This won't 5 + > break the behaviour of your existing apps, however, it's recommended to remove 6 + > the `multipartFetchExchange` from your apps. 7 + 3 8 The `multipartFetchExchange` is an exchange that builds on the regular `fetchExchange` 4 9 but adds the multipart file upload capability. 5 10
+1 -1
exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
··· 455 455 } 456 456 `; 457 457 458 - exports[`on success > returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 458 + exports[`on success > returns response data 2`] = `"{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 459 459 460 460 exports[`on success > uses a file when given 1`] = ` 461 461 {
+22 -16
exchanges/multipart-fetch/src/multipartFetchExchange.ts
··· 9 9 makeFetchSource, 10 10 } from '@urql/core/internal'; 11 11 12 + /** An Exchange which creates a multipart request when File/Blobs are found in variables. 13 + * @deprecated 14 + * `@urql/core` now supports the GraphQL Multipart Request spec out-of-the-box. 15 + */ 12 16 export const multipartFetchExchange: Exchange = ({ 13 17 forward, 14 18 dispatchDebug, ··· 36 40 if (files.size) { 37 41 url = makeFetchURL(operation); 38 42 fetchOptions = makeFetchOptions(operation); 39 - if (fetchOptions.headers!['content-type'] === 'application/json') { 40 - delete fetchOptions.headers!['content-type']; 41 - } 43 + if (!(fetchOptions.body instanceof FormData)) { 44 + if (fetchOptions.headers!['content-type'] === 'application/json') { 45 + delete fetchOptions.headers!['content-type']; 46 + } 42 47 43 - fetchOptions.method = 'POST'; 44 - fetchOptions.body = new FormData(); 45 - fetchOptions.body.append('operations', JSON.stringify(body)); 48 + fetchOptions.method = 'POST'; 49 + fetchOptions.body = new FormData(); 50 + fetchOptions.body.append('operations', JSON.stringify(body)); 46 51 47 - const map = {}; 48 - let i = 0; 49 - files.forEach(paths => { 50 - map[++i] = paths.map(path => `variables.${path}`); 51 - }); 52 + const map = {}; 53 + let i = 0; 54 + files.forEach(paths => { 55 + map[++i] = paths.map(path => `variables.${path}`); 56 + }); 52 57 53 - fetchOptions.body.append('map', JSON.stringify(map)); 58 + fetchOptions.body.append('map', JSON.stringify(map)); 54 59 55 - i = 0; 56 - files.forEach((_, file) => { 57 - (fetchOptions.body as FormData).append(`${++i}`, file, file.name); 58 - }); 60 + i = 0; 61 + files.forEach((_, file) => { 62 + (fetchOptions.body as FormData).append(`${++i}`, file, file.name); 63 + }); 64 + } 59 65 } else { 60 66 url = makeFetchURL(operation, body); 61 67 fetchOptions = makeFetchOptions(operation, body);
+4 -4
exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
··· 1 1 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 2 3 - exports[`accepts successful persisted query responses 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`; 3 + exports[`accepts successful persisted query responses 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 4 4 5 - exports[`supports cache-miss persisted query errors 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`; 5 + exports[`supports cache-miss persisted query errors 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 6 6 7 - exports[`supports cache-miss persisted query errors 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`; 7 + exports[`supports cache-miss persisted query errors 2`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 8 8 9 - exports[`supports unsupported persisted query errors 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`; 9 + exports[`supports unsupported persisted query errors 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
+1 -1
packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
··· 455 455 } 456 456 `; 457 457 458 - exports[`on success > returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 458 + exports[`on success > returns response data 2`] = `"{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
+122
packages/core/src/internal/fetchOptions.test.ts
··· 1 + import { expect, describe, it } from 'vitest'; 2 + import { makeOperation } from '../utils/operation'; 3 + import { queryOperation, mutationOperation } from '../test-utils'; 4 + import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions'; 5 + 6 + describe('makeFetchURL', () => { 7 + it('returns the URL by default', () => { 8 + const body = makeFetchBody(queryOperation); 9 + expect(makeFetchURL(queryOperation, body)).toBe( 10 + 'http://localhost:3000/graphql' 11 + ); 12 + }); 13 + 14 + it('returns a query parameter URL when GET is preferred', () => { 15 + const operation = makeOperation(queryOperation.kind, queryOperation, { 16 + ...queryOperation.context, 17 + preferGetMethod: true, 18 + }); 19 + 20 + const body = makeFetchBody(operation); 21 + expect(makeFetchURL(operation, body)).toMatchInlineSnapshot( 22 + '"http://localhost:3000/graphql?query=query+getUser%28%24name%3A+String%29+%7B%0A++user%28name%3A+%24name%29+%7B%0A++++id%0A++++firstName%0A++++lastName%0A++%7D%0A%7D&operationName=getUser&variables=%7B%22name%22%3A%22Clara%22%7D"' 23 + ); 24 + }); 25 + 26 + it('returns the URL without query parameters when it exceeds given length', () => { 27 + const operation = makeOperation(queryOperation.kind, queryOperation, { 28 + ...queryOperation.context, 29 + preferGetMethod: true, 30 + }); 31 + 32 + operation.variables = { 33 + ...operation.variables, 34 + test: 'x'.repeat(2048), 35 + }; 36 + 37 + const body = makeFetchBody(operation); 38 + expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql'); 39 + // Resets the `preferGetMethod` field 40 + expect(operation.context.preferGetMethod).toBe(false); 41 + }); 42 + 43 + it('returns the URL without query parameters for mutations', () => { 44 + const operation = makeOperation(mutationOperation.kind, mutationOperation, { 45 + ...mutationOperation.context, 46 + preferGetMethod: true, 47 + }); 48 + 49 + const body = makeFetchBody(operation); 50 + expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql'); 51 + }); 52 + }); 53 + 54 + describe('makeFetchOptions', () => { 55 + it('creates a JSON request by default', () => { 56 + const body = makeFetchBody(queryOperation); 57 + expect(makeFetchOptions(queryOperation, body)).toMatchInlineSnapshot(` 58 + { 59 + "body": "{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}", 60 + "headers": { 61 + "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed", 62 + "content-type": "application/json", 63 + }, 64 + "method": "POST", 65 + } 66 + `); 67 + }); 68 + 69 + it('creates a GET request when preferred for query operations', () => { 70 + const operation = makeOperation(queryOperation.kind, queryOperation, { 71 + ...queryOperation.context, 72 + preferGetMethod: 'force', 73 + }); 74 + 75 + const body = makeFetchBody(operation); 76 + expect(makeFetchOptions(operation, body)).toMatchInlineSnapshot(` 77 + { 78 + "body": undefined, 79 + "headers": { 80 + "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed", 81 + }, 82 + "method": "GET", 83 + } 84 + `); 85 + }); 86 + 87 + it('creates a POST multipart request when a file is detected', () => { 88 + const operation = makeOperation(mutationOperation.kind, mutationOperation); 89 + operation.variables = { 90 + ...operation.variables, 91 + file: new Blob(), 92 + }; 93 + 94 + const body = makeFetchBody(operation); 95 + const options = makeFetchOptions(operation, body); 96 + expect(options).toMatchInlineSnapshot(` 97 + { 98 + "body": FormData {}, 99 + "headers": { 100 + "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed", 101 + }, 102 + "method": "POST", 103 + } 104 + `); 105 + 106 + expect(options.body).toBeInstanceOf(FormData); 107 + const form = options.body as FormData; 108 + 109 + expect(JSON.parse(form.get('operations') as string)).toEqual({ 110 + ...body, 111 + variables: { 112 + ...body.variables, 113 + file: { __key: expect.any(String) }, 114 + }, 115 + }); 116 + 117 + expect(form.get('map')).toMatchInlineSnapshot( 118 + '"{\\"0\\":\\"variables.file\\"}"' 119 + ); 120 + expect(form.get('0')).toBeInstanceOf(Blob); 121 + }); 122 + });
+34 -7
packages/core/src/internal/fetchOptions.ts
··· 2 2 stringifyDocument, 3 3 getOperationName, 4 4 stringifyVariables, 5 + extractFiles, 5 6 } from '../utils'; 7 + 6 8 import { AnyVariables, GraphQLRequest, Operation } from '../types'; 7 9 8 10 /** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */ ··· 69 71 return finalUrl; 70 72 }; 71 73 74 + /** Serializes a {@link FetchBody} into a {@link RequestInit.body} format. */ 75 + const serializeBody = ( 76 + operation: Operation, 77 + body?: FetchBody 78 + ): FormData | string | undefined => { 79 + const omitBody = 80 + operation.kind === 'query' && !!operation.context.preferGetMethod; 81 + if (body && !omitBody) { 82 + const json = stringifyVariables(body); 83 + const files = extractFiles(body.variables); 84 + if (files.size) { 85 + const form = new FormData(); 86 + 87 + form.append('operations', json); 88 + form.append('map', stringifyVariables({ ...[...files.keys()] })); 89 + 90 + let index = 0; 91 + for (const file of files.values()) { 92 + form.append(`${index++}`, file); 93 + } 94 + 95 + return form; 96 + } 97 + return json; 98 + } 99 + }; 100 + 72 101 /** Creates a `RequestInit` object for a given `Operation`. 73 102 * 74 103 * @param operation - An {@link Operation} for which to make the request. ··· 86 115 operation: Operation, 87 116 body?: FetchBody 88 117 ): RequestInit => { 89 - const useGETMethod = 90 - operation.kind === 'query' && !!operation.context.preferGetMethod; 91 - 92 118 const headers: HeadersInit = { 93 119 accept: 94 120 'application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed', 95 121 }; 96 - 97 - if (!useGETMethod) headers['content-type'] = 'application/json'; 98 122 const extraOptions = 99 123 (typeof operation.context.fetchOptions === 'function' 100 124 ? operation.context.fetchOptions() ··· 102 126 if (extraOptions.headers) 103 127 for (const key in extraOptions.headers) 104 128 headers[key.toLowerCase()] = extraOptions.headers[key]; 129 + const serializedBody = serializeBody(operation, body); 130 + if (typeof serializedBody === 'string' && !headers['content-type']) 131 + headers['content-type'] = 'application/json'; 105 132 return { 106 133 ...extraOptions, 107 - body: !useGETMethod && body ? JSON.stringify(body) : undefined, 108 - method: useGETMethod ? 'GET' : 'POST', 134 + method: serializedBody ? 'POST' : 'GET', 135 + body: serializedBody, 109 136 headers, 110 137 }; 111 138 };
+1 -1
packages/core/src/utils/index.ts
··· 2 2 export * from './request'; 3 3 export * from './result'; 4 4 export * from './typenames'; 5 - export * from './stringifyVariables'; 5 + export * from './variables'; 6 6 export * from './maskTypename'; 7 7 export * from './streamUtils'; 8 8 export * from './operation';
+1 -1
packages/core/src/utils/request.ts
··· 8 8 } from 'graphql'; 9 9 10 10 import { HashValue, phash } from './hash'; 11 - import { stringifyVariables } from './stringifyVariables'; 11 + import { stringifyVariables } from './variables'; 12 12 import { TypedDocumentNode, AnyVariables, GraphQLRequest } from '../types'; 13 13 14 14 interface WritableLocation {
-49
packages/core/src/utils/stringifyVariables.test.ts
··· 1 - import { stringifyVariables } from './stringifyVariables'; 2 - import { it, expect } from 'vitest'; 3 - 4 - it('stringifies objects stabily', () => { 5 - expect(stringifyVariables({ b: 'b', a: 'a' })).toBe('{"a":"a","b":"b"}'); 6 - expect(stringifyVariables({ x: { b: 'b', a: 'a' } })).toBe( 7 - '{"x":{"a":"a","b":"b"}}' 8 - ); 9 - }); 10 - 11 - it('stringifies arrays', () => { 12 - expect(stringifyVariables([1, 2])).toBe('[1,2]'); 13 - expect(stringifyVariables({ x: [1, 2] })).toBe('{"x":[1,2]}'); 14 - }); 15 - 16 - it('stringifies scalars', () => { 17 - expect(stringifyVariables(1)).toBe('1'); 18 - expect(stringifyVariables('test')).toBe('"test"'); 19 - expect(stringifyVariables(null)).toBe('null'); 20 - expect(stringifyVariables(undefined)).toBe(''); 21 - expect(stringifyVariables(Infinity)).toBe('null'); 22 - expect(stringifyVariables(1 / 0)).toBe('null'); 23 - }); 24 - 25 - it('returns null for circular structures', () => { 26 - const x = { x: null } as any; 27 - x.x = x; 28 - expect(stringifyVariables(x)).toBe('{"x":null}'); 29 - }); 30 - 31 - it('stringifies dates correctly', () => { 32 - const date = new Date('2019-12-11T04:20:00'); 33 - expect(stringifyVariables(date)).toBe(`"${date.toJSON()}"`); 34 - }); 35 - 36 - it('stringifies dictionaries (Object.create(null)) correctly', () => { 37 - expect(stringifyVariables(Object.create(null))).toBe('{}'); 38 - }); 39 - 40 - it('stringifies files correctly', () => { 41 - const file = new File([0] as any, 'test.js'); 42 - Object.defineProperty(file, 'lastModified', { value: 123 }); 43 - const str = stringifyVariables(file); 44 - expect(str).toBe(stringifyVariables(file)); 45 - 46 - const otherFile = new File([0] as any, 'otherFile.js'); 47 - Object.defineProperty(otherFile, 'lastModified', { value: 234 }); 48 - expect(str).not.toBe(stringifyVariables(otherFile)); 49 - });
+32
packages/core/src/utils/stringifyVariables.ts packages/core/src/utils/variables.ts
··· 1 + export type FileMap = Map<string, File | Blob>; 2 + 1 3 const seen = new Set(); 2 4 const cache = new WeakMap(); 3 5 ··· 38 40 seen.delete(x); 39 41 out += '}'; 40 42 return out; 43 + }; 44 + 45 + const extract = (map: FileMap, path: string, x: any) => { 46 + if (x == null || typeof x !== 'object' || x.toJSON || seen.has(x)) { 47 + /*noop*/ 48 + } else if (Array.isArray(x)) { 49 + for (let i = 0, l = x.length; i < l; i++) 50 + extract(map, `${path}.${i}`, x[i]); 51 + } else if (x instanceof FileConstructor || x instanceof BlobConstructor) { 52 + map.set(path, x as File | Blob); 53 + } else { 54 + seen.add(x); 55 + for (const key of Object.keys(x)) extract(map, `${path}.${key}`, x[key]); 56 + } 41 57 }; 42 58 43 59 /** A stable stringifier for GraphQL variables objects. ··· 58 74 seen.clear(); 59 75 return stringify(x); 60 76 }; 77 + 78 + class NoopConstructor {} 79 + const FileConstructor = typeof File !== 'undefined' ? File : NoopConstructor; 80 + const BlobConstructor = typeof Blob !== 'undefined' ? Blob : NoopConstructor; 81 + 82 + export const extractFiles = (x: any): FileMap => { 83 + const map: FileMap = new Map(); 84 + if ( 85 + FileConstructor !== NoopConstructor || 86 + BlobConstructor !== NoopConstructor 87 + ) { 88 + seen.clear(); 89 + extract(map, 'variables', x); 90 + } 91 + return map; 92 + };
+67
packages/core/src/utils/variables.test.ts
··· 1 + import { stringifyVariables, extractFiles } from './variables'; 2 + import { describe, it, expect } from 'vitest'; 3 + 4 + describe('stringifyVariables', () => { 5 + it('stringifies objects stabily', () => { 6 + expect(stringifyVariables({ b: 'b', a: 'a' })).toBe('{"a":"a","b":"b"}'); 7 + expect(stringifyVariables({ x: { b: 'b', a: 'a' } })).toBe( 8 + '{"x":{"a":"a","b":"b"}}' 9 + ); 10 + }); 11 + 12 + it('stringifies arrays', () => { 13 + expect(stringifyVariables([1, 2])).toBe('[1,2]'); 14 + expect(stringifyVariables({ x: [1, 2] })).toBe('{"x":[1,2]}'); 15 + }); 16 + 17 + it('stringifies scalars', () => { 18 + expect(stringifyVariables(1)).toBe('1'); 19 + expect(stringifyVariables('test')).toBe('"test"'); 20 + expect(stringifyVariables(null)).toBe('null'); 21 + expect(stringifyVariables(undefined)).toBe(''); 22 + expect(stringifyVariables(Infinity)).toBe('null'); 23 + expect(stringifyVariables(1 / 0)).toBe('null'); 24 + }); 25 + 26 + it('returns null for circular structures', () => { 27 + const x = { x: null } as any; 28 + x.x = x; 29 + expect(stringifyVariables(x)).toBe('{"x":null}'); 30 + }); 31 + 32 + it('stringifies dates correctly', () => { 33 + const date = new Date('2019-12-11T04:20:00'); 34 + expect(stringifyVariables(date)).toBe(`"${date.toJSON()}"`); 35 + }); 36 + 37 + it('stringifies dictionaries (Object.create(null)) correctly', () => { 38 + expect(stringifyVariables(Object.create(null))).toBe('{}'); 39 + }); 40 + 41 + it('stringifies files correctly', () => { 42 + const file = new File([0] as any, 'test.js'); 43 + Object.defineProperty(file, 'lastModified', { value: 123 }); 44 + const str = stringifyVariables(file); 45 + expect(str).toBe(stringifyVariables(file)); 46 + 47 + const otherFile = new File([0] as any, 'otherFile.js'); 48 + Object.defineProperty(otherFile, 'lastModified', { value: 234 }); 49 + expect(str).not.toBe(stringifyVariables(otherFile)); 50 + }); 51 + }); 52 + 53 + describe('extractFiles', () => { 54 + it('extracts files from nested objects', () => { 55 + const file = new Blob(); 56 + expect(extractFiles({ files: { a: file } })).toEqual( 57 + new Map([['variables.files.a', file]]) 58 + ); 59 + }); 60 + 61 + it('extracts files from nested arrays', () => { 62 + const file = new Blob(); 63 + expect(extractFiles({ files: [file] })).toEqual( 64 + new Map([['variables.files.0', file]]) 65 + ); 66 + }); 67 + });