1import {
2 stringifyDocument,
3 getOperationName,
4 stringifyVariables,
5 extractFiles,
6} from '../utils';
7
8import type { AnyVariables, GraphQLRequest, Operation } from '../types';
9
10/** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */
11export interface FetchBody {
12 query?: string;
13 documentId?: string;
14 operationName: string | undefined;
15 variables: undefined | Record<string, any>;
16 extensions: undefined | Record<string, any>;
17}
18
19/** Creates a GraphQL over HTTP compliant JSON request body.
20 * @param request - An object containing a `query` document and `variables`.
21 * @returns A {@link FetchBody}
22 * @see {@link https://github.com/graphql/graphql-over-http} for the GraphQL over HTTP spec.
23 */
24export function makeFetchBody<
25 Data = any,
26 Variables extends AnyVariables = AnyVariables,
27>(request: Omit<GraphQLRequest<Data, Variables>, 'key'>): FetchBody {
28 const body: FetchBody = {
29 query: undefined,
30 documentId: undefined,
31 operationName: getOperationName(request.query),
32 variables: request.variables || undefined,
33 extensions: request.extensions,
34 };
35
36 if (
37 'documentId' in request.query &&
38 request.query.documentId &&
39 // NOTE: We have to check that the document will definitely be sent
40 // as a persisted document to avoid breaking changes
41 (!request.query.definitions || !request.query.definitions.length)
42 ) {
43 body.documentId = request.query.documentId;
44 } else if (
45 !request.extensions ||
46 !request.extensions.persistedQuery ||
47 !!request.extensions.persistedQuery.miss
48 ) {
49 body.query = stringifyDocument(request.query);
50 }
51
52 return body;
53}
54
55/** Creates a URL that will be called for a GraphQL HTTP request.
56 *
57 * @param operation - An {@link Operation} for which to make the request.
58 * @param body - A {@link FetchBody} which may be replaced with a URL.
59 *
60 * @remarks
61 * Creates the URL that’ll be called as part of a GraphQL HTTP request.
62 * Built-in fetch exchanges support sending GET requests, even for
63 * non-persisted full requests, which this function supports by being
64 * able to serialize GraphQL requests into the URL.
65 */
66export const makeFetchURL = (
67 operation: Operation,
68 body?: FetchBody
69): string => {
70 const useGETMethod =
71 operation.kind === 'query' && operation.context.preferGetMethod;
72 if (!useGETMethod || !body) return operation.context.url;
73
74 const urlParts = splitOutSearchParams(operation.context.url);
75 for (const key in body) {
76 const value = body[key];
77 if (value) {
78 urlParts[1].set(
79 key,
80 typeof value === 'object' ? stringifyVariables(value) : value
81 );
82 }
83 }
84 const finalUrl = urlParts.join('?');
85 if (finalUrl.length > 2047 && useGETMethod !== 'force') {
86 operation.context.preferGetMethod = false;
87 return operation.context.url;
88 }
89
90 return finalUrl;
91};
92
93const splitOutSearchParams = (
94 url: string
95): readonly [string, URLSearchParams] => {
96 const start = url.indexOf('?');
97 return start > -1
98 ? [url.slice(0, start), new URLSearchParams(url.slice(start + 1))]
99 : [url, new URLSearchParams()];
100};
101
102/** Serializes a {@link FetchBody} into a {@link RequestInit.body} format. */
103const serializeBody = (
104 operation: Operation,
105 body?: FetchBody
106): FormData | string | undefined => {
107 const omitBody =
108 operation.kind === 'query' && !!operation.context.preferGetMethod;
109 if (body && !omitBody) {
110 const json = stringifyVariables(body);
111 const files = extractFiles(body.variables);
112 if (files.size) {
113 const form = new FormData();
114 form.append('operations', json);
115 form.append(
116 'map',
117 stringifyVariables({
118 ...[...files.keys()].map(value => [value]),
119 })
120 );
121 let index = 0;
122 for (const file of files.values()) form.append(`${index++}`, file);
123 return form;
124 }
125 return json;
126 }
127};
128
129const isHeaders = (headers: HeadersInit): headers is Headers =>
130 'has' in headers && !Object.keys(headers).length;
131
132/** Creates a `RequestInit` object for a given `Operation`.
133 *
134 * @param operation - An {@link Operation} for which to make the request.
135 * @param body - A {@link FetchBody} which is added to the options, if the request isn’t a GET request.
136 *
137 * @remarks
138 * Creates the fetch options {@link RequestInit} object that’ll be passed to the Fetch API
139 * as part of a GraphQL over HTTP request. It automatically sets a default `Content-Type`
140 * header.
141 *
142 * @see {@link https://github.com/graphql/graphql-over-http} for the GraphQL over HTTP spec.
143 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} for the Fetch API spec.
144 */
145export const makeFetchOptions = (
146 operation: Operation,
147 body?: FetchBody
148): RequestInit => {
149 const headers: HeadersInit = {
150 accept:
151 operation.kind === 'subscription'
152 ? 'text/event-stream, multipart/mixed'
153 : 'application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed',
154 };
155 const extraOptions =
156 (typeof operation.context.fetchOptions === 'function'
157 ? operation.context.fetchOptions()
158 : operation.context.fetchOptions) || {};
159 if (extraOptions.headers) {
160 if (isHeaders(extraOptions.headers)) {
161 extraOptions.headers.forEach((value, key) => {
162 headers[key] = value;
163 });
164 } else if (Array.isArray(extraOptions.headers)) {
165 (extraOptions.headers as Array<[string, string]>).forEach(
166 (value, key) => {
167 if (Array.isArray(value)) {
168 if (headers[value[0]]) {
169 headers[value[0]] = `${headers[value[0]]},${value[1]}`;
170 } else {
171 headers[value[0]] = value[1];
172 }
173 } else {
174 headers[key] = value;
175 }
176 }
177 );
178 } else {
179 for (const key in extraOptions.headers) {
180 headers[key.toLowerCase()] = extraOptions.headers[key];
181 }
182 }
183 }
184
185 const serializedBody = serializeBody(operation, body);
186 if (typeof serializedBody === 'string' && !headers['content-type'])
187 headers['content-type'] = 'application/json';
188 return {
189 ...extraOptions,
190 method: serializedBody ? 'POST' : 'GET',
191 body: serializedBody,
192 headers,
193 };
194};