···11+---
22+'@urql/core': minor
33+---
44+55+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
···11+---
22+'@urql/exchange-multipart-fetch': patch
33+---
44+55+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
···5566# Multipart Fetch Exchange
7788+> **Deprecation**: The `multipartFetchExchange` has been deprecated, and
99+> `@urql/core` now supports GraphQL Multipart Requests natively. This won't
1010+> break the behaviour of your existing apps, however, it's recommended to remove
1111+> the `multipartFetchExchange` from your apps.
1212+813The `@urql/exchange-multipart-fetch` package contains an addon `multipartFetchExchange` for `urql`
914that enables file uploads via `multipart/form-data` POST requests.
1015
···11# @urql/exchange-multipart-fetch
2233+> **DEPRECATION NOTICE**: The `multipartFetchExchange` has been deprecated, and
44+> `@urql/core` now supports GraphQL Multipart Requests natively. This won't
55+> break the behaviour of your existing apps, however, it's recommended to remove
66+> the `multipartFetchExchange` from your apps.
77+38The `multipartFetchExchange` is an exchange that builds on the regular `fetchExchange`
49but adds the multipart file upload capability.
510
···11+import { expect, describe, it } from 'vitest';
22+import { makeOperation } from '../utils/operation';
33+import { queryOperation, mutationOperation } from '../test-utils';
44+import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions';
55+66+describe('makeFetchURL', () => {
77+ it('returns the URL by default', () => {
88+ const body = makeFetchBody(queryOperation);
99+ expect(makeFetchURL(queryOperation, body)).toBe(
1010+ 'http://localhost:3000/graphql'
1111+ );
1212+ });
1313+1414+ it('returns a query parameter URL when GET is preferred', () => {
1515+ const operation = makeOperation(queryOperation.kind, queryOperation, {
1616+ ...queryOperation.context,
1717+ preferGetMethod: true,
1818+ });
1919+2020+ const body = makeFetchBody(operation);
2121+ expect(makeFetchURL(operation, body)).toMatchInlineSnapshot(
2222+ '"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"'
2323+ );
2424+ });
2525+2626+ it('returns the URL without query parameters when it exceeds given length', () => {
2727+ const operation = makeOperation(queryOperation.kind, queryOperation, {
2828+ ...queryOperation.context,
2929+ preferGetMethod: true,
3030+ });
3131+3232+ operation.variables = {
3333+ ...operation.variables,
3434+ test: 'x'.repeat(2048),
3535+ };
3636+3737+ const body = makeFetchBody(operation);
3838+ expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql');
3939+ // Resets the `preferGetMethod` field
4040+ expect(operation.context.preferGetMethod).toBe(false);
4141+ });
4242+4343+ it('returns the URL without query parameters for mutations', () => {
4444+ const operation = makeOperation(mutationOperation.kind, mutationOperation, {
4545+ ...mutationOperation.context,
4646+ preferGetMethod: true,
4747+ });
4848+4949+ const body = makeFetchBody(operation);
5050+ expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql');
5151+ });
5252+});
5353+5454+describe('makeFetchOptions', () => {
5555+ it('creates a JSON request by default', () => {
5656+ const body = makeFetchBody(queryOperation);
5757+ expect(makeFetchOptions(queryOperation, body)).toMatchInlineSnapshot(`
5858+ {
5959+ "body": "{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n user(name: $name) {\\\\n id\\\\n firstName\\\\n lastName\\\\n }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}",
6060+ "headers": {
6161+ "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
6262+ "content-type": "application/json",
6363+ },
6464+ "method": "POST",
6565+ }
6666+ `);
6767+ });
6868+6969+ it('creates a GET request when preferred for query operations', () => {
7070+ const operation = makeOperation(queryOperation.kind, queryOperation, {
7171+ ...queryOperation.context,
7272+ preferGetMethod: 'force',
7373+ });
7474+7575+ const body = makeFetchBody(operation);
7676+ expect(makeFetchOptions(operation, body)).toMatchInlineSnapshot(`
7777+ {
7878+ "body": undefined,
7979+ "headers": {
8080+ "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
8181+ },
8282+ "method": "GET",
8383+ }
8484+ `);
8585+ });
8686+8787+ it('creates a POST multipart request when a file is detected', () => {
8888+ const operation = makeOperation(mutationOperation.kind, mutationOperation);
8989+ operation.variables = {
9090+ ...operation.variables,
9191+ file: new Blob(),
9292+ };
9393+9494+ const body = makeFetchBody(operation);
9595+ const options = makeFetchOptions(operation, body);
9696+ expect(options).toMatchInlineSnapshot(`
9797+ {
9898+ "body": FormData {},
9999+ "headers": {
100100+ "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
101101+ },
102102+ "method": "POST",
103103+ }
104104+ `);
105105+106106+ expect(options.body).toBeInstanceOf(FormData);
107107+ const form = options.body as FormData;
108108+109109+ expect(JSON.parse(form.get('operations') as string)).toEqual({
110110+ ...body,
111111+ variables: {
112112+ ...body.variables,
113113+ file: { __key: expect.any(String) },
114114+ },
115115+ });
116116+117117+ expect(form.get('map')).toMatchInlineSnapshot(
118118+ '"{\\"0\\":\\"variables.file\\"}"'
119119+ );
120120+ expect(form.get('0')).toBeInstanceOf(Blob);
121121+ });
122122+});
+34-7
packages/core/src/internal/fetchOptions.ts
···22 stringifyDocument,
33 getOperationName,
44 stringifyVariables,
55+ extractFiles,
56} from '../utils';
77+68import { AnyVariables, GraphQLRequest, Operation } from '../types';
79810/** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */
···6971 return finalUrl;
7072};
71737474+/** Serializes a {@link FetchBody} into a {@link RequestInit.body} format. */
7575+const serializeBody = (
7676+ operation: Operation,
7777+ body?: FetchBody
7878+): FormData | string | undefined => {
7979+ const omitBody =
8080+ operation.kind === 'query' && !!operation.context.preferGetMethod;
8181+ if (body && !omitBody) {
8282+ const json = stringifyVariables(body);
8383+ const files = extractFiles(body.variables);
8484+ if (files.size) {
8585+ const form = new FormData();
8686+8787+ form.append('operations', json);
8888+ form.append('map', stringifyVariables({ ...[...files.keys()] }));
8989+9090+ let index = 0;
9191+ for (const file of files.values()) {
9292+ form.append(`${index++}`, file);
9393+ }
9494+9595+ return form;
9696+ }
9797+ return json;
9898+ }
9999+};
100100+72101/** Creates a `RequestInit` object for a given `Operation`.
73102 *
74103 * @param operation - An {@link Operation} for which to make the request.
···86115 operation: Operation,
87116 body?: FetchBody
88117): RequestInit => {
8989- const useGETMethod =
9090- operation.kind === 'query' && !!operation.context.preferGetMethod;
9191-92118 const headers: HeadersInit = {
93119 accept:
94120 'application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed',
95121 };
9696-9797- if (!useGETMethod) headers['content-type'] = 'application/json';
98122 const extraOptions =
99123 (typeof operation.context.fetchOptions === 'function'
100124 ? operation.context.fetchOptions()
···102126 if (extraOptions.headers)
103127 for (const key in extraOptions.headers)
104128 headers[key.toLowerCase()] = extraOptions.headers[key];
129129+ const serializedBody = serializeBody(operation, body);
130130+ if (typeof serializedBody === 'string' && !headers['content-type'])
131131+ headers['content-type'] = 'application/json';
105132 return {
106133 ...extraOptions,
107107- body: !useGETMethod && body ? JSON.stringify(body) : undefined,
108108- method: useGETMethod ? 'GET' : 'POST',
134134+ method: serializedBody ? 'POST' : 'GET',
135135+ body: serializedBody,
109136 headers,
110137 };
111138};
+1-1
packages/core/src/utils/index.ts
···22export * from './request';
33export * from './result';
44export * from './typenames';
55-export * from './stringifyVariables';
55+export * from './variables';
66export * from './maskTypename';
77export * from './streamUtils';
88export * from './operation';
+1-1
packages/core/src/utils/request.ts
···88} from 'graphql';
991010import { HashValue, phash } from './hash';
1111-import { stringifyVariables } from './stringifyVariables';
1111+import { stringifyVariables } from './variables';
1212import { TypedDocumentNode, AnyVariables, GraphQLRequest } from '../types';
13131414interface WritableLocation {