apollo-upload-client
Version:
A terminating Apollo Link for Apollo Client that fetches a GraphQL multipart request if the GraphQL variables contain files (by default FileList, File, or Blob instances), or else fetches a regular GraphQL POST or GET request (depending on the config and
302 lines (272 loc) • 11.3 kB
JavaScript
// @ts-check
import { ApolloLink } from "@apollo/client/link/core/ApolloLink.js";
import { createSignalIfSupported } from "@apollo/client/link/http/createSignalIfSupported.js";
import { parseAndCheckHttpResponse } from "@apollo/client/link/http/parseAndCheckHttpResponse.js";
import { rewriteURIForGET } from "@apollo/client/link/http/rewriteURIForGET.js";
import {
defaultPrinter,
fallbackHttpConfig,
selectHttpOptionsAndBodyInternal,
} from "@apollo/client/link/http/selectHttpOptionsAndBody.js";
import { selectURI } from "@apollo/client/link/http/selectURI.js";
import { serializeFetchParameter } from "@apollo/client/link/http/serializeFetchParameter.js";
import { Observable } from "@apollo/client/utilities/observables/Observable.js";
import extractFiles from "extract-files/extractFiles.mjs";
import formDataAppendFile from "./formDataAppendFile.mjs";
import isExtractableFile from "./isExtractableFile.mjs";
/**
* Creates a
* [terminating Apollo Link](https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link)
* for [Apollo Client](https://www.apollographql.com/docs/react) that fetches a
* [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec)
* if the GraphQL variables contain files (by default
* [`FileList`](https://developer.mozilla.org/en-US/docs/Web/API/FileList),
* [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File), or
* [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) instances),
* or else fetches a regular
* [GraphQL POST or GET request](https://www.apollographql.com/docs/apollo-server/workflow/requests)
* (depending on the config and GraphQL operation).
*
* Some of the options are similar to the
* [`createHttpLink` options](https://www.apollographql.com/docs/react/api/link/apollo-link-http/#httplink-constructor-options).
* @see [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
* @param {object} options Options.
* @param {Parameters<typeof selectURI>[1]} [options.uri] GraphQL endpoint URI.
* Defaults to `"/graphql"`.
* @param {boolean} [options.useGETForQueries] Should GET be used to fetch
* queries, if there are no files to upload.
* @param {ExtractableFileMatcher} [options.isExtractableFile] Matches
* extractable files in the GraphQL operation. Defaults to
* {@linkcode isExtractableFile}.
* @param {typeof FormData} [options.FormData]
* [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* class. Defaults to the {@linkcode FormData} global.
* @param {FormDataFileAppender} [options.formDataAppendFile]
* Customizes how extracted files are appended to the
* [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* instance. Defaults to {@linkcode formDataAppendFile}.
* @param {import("@apollo/client/link/http/selectHttpOptionsAndBody.js").Printer} [options.print]
* Prints the GraphQL query or mutation AST to a string for transport.
* Defaults to {@linkcode defaultPrinter}.
* @param {typeof fetch} [options.fetch] [`fetch`](https://fetch.spec.whatwg.org)
* implementation. Defaults to the {@linkcode fetch} global.
* @param {RequestInit} [options.fetchOptions] `fetch` options; overridden by
* upload requirements.
* @param {string} [options.credentials] Overrides
* {@linkcode RequestInit.credentials credentials} in
* {@linkcode fetchOptions}.
* @param {{ [headerName: string]: string }} [options.headers] Merges with and
* overrides {@linkcode RequestInit.headers headers} in
* {@linkcode fetchOptions}.
* @param {boolean} [options.includeExtensions] Toggles sending `extensions`
* fields to the GraphQL server. Defaults to `false`.
* @returns A [terminating Apollo Link](https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link).
* @example
* A basic Apollo Client setup:
*
* ```js
* import { ApolloClient, InMemoryCache } from "@apollo/client";
* import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
*
* const client = new ApolloClient({
* cache: new InMemoryCache(),
* link: createUploadLink(),
* });
* ```
*/
export default function createUploadLink({
uri: fetchUri = "/graphql",
useGETForQueries,
isExtractableFile: customIsExtractableFile = isExtractableFile,
FormData: CustomFormData,
formDataAppendFile: customFormDataAppendFile = formDataAppendFile,
print = defaultPrinter,
fetch: customFetch,
fetchOptions,
credentials,
headers,
includeExtensions,
} = {}) {
const linkConfig = {
http: { includeExtensions },
options: fetchOptions,
credentials,
headers,
};
return new ApolloLink((operation) => {
const context =
/**
* @type {import("@apollo/client/core/types.js").DefaultContext & {
* clientAwareness?: {
* name?: string,
* version?: string,
* },
* }}
*/
(operation.getContext());
const {
// Apollo Studio client awareness `name` and `version` can be configured
// via `ApolloClient` constructor options:
// https://www.apollographql.com/docs/graphos/metrics/client-awareness/#setup
clientAwareness: { name, version } = {},
headers,
} = context;
const contextConfig = {
http: context.http,
options: context.fetchOptions,
credentials: context.credentials,
headers: {
// Client awareness headers can be overridden by context `headers`.
...(name && { "apollographql-client-name": name }),
...(version && { "apollographql-client-version": version }),
...headers,
},
};
const { options, body } = selectHttpOptionsAndBodyInternal(
operation,
print,
fallbackHttpConfig,
linkConfig,
contextConfig,
);
const { clone, files } = extractFiles(body, customIsExtractableFile, "");
let uri = selectURI(operation, fetchUri);
if (files.size) {
if (options.headers)
// Automatically set by `fetch` when the `body` is a `FormData` instance.
delete options.headers["content-type"];
// GraphQL multipart request spec:
// https://github.com/jaydenseric/graphql-multipart-request-spec
const RuntimeFormData = CustomFormData || FormData;
const form = new RuntimeFormData();
form.append("operations", serializeFetchParameter(clone, "Payload"));
/** @type {{ [key: string]: Array<string> }} */
const map = {};
let i = 0;
files.forEach((paths) => {
map[++i] = paths;
});
form.append("map", JSON.stringify(map));
i = 0;
files.forEach((_paths, file) => {
customFormDataAppendFile(form, String(++i), file);
});
options.body = form;
} else {
if (
useGETForQueries &&
// If the operation contains some mutations GET shouldn’t be used.
!operation.query.definitions.some(
(definition) =>
definition.kind === "OperationDefinition" &&
definition.operation === "mutation",
)
)
options.method = "GET";
if (options.method === "GET") {
const { newURI, parseError } = rewriteURIForGET(uri, body);
if (parseError)
// Apollo’s `HttpLink` uses `fromError` for this, but it’s not
// exported from `@apollo/client/link/http`.
return new Observable((observer) => {
observer.error(parseError);
});
uri = newURI;
} else options.body = serializeFetchParameter(clone, "Payload");
}
const { controller } = createSignalIfSupported();
if (typeof controller !== "boolean") {
if (options.signal)
// Respect the user configured abort controller signal.
options.signal.aborted
? // Signal already aborted, so immediately abort.
controller.abort()
: // Signal not already aborted, so setup a listener to abort when it
// does.
options.signal.addEventListener(
"abort",
() => {
controller.abort();
},
{
// Prevent a memory leak if the user configured abort controller
// is long lasting, or controls multiple things.
once: true,
},
);
options.signal = controller.signal;
}
const runtimeFetch = customFetch || fetch;
return new Observable((observer) => {
/**
* Is the observable being cleaned up.
* @type {boolean}
*/
let cleaningUp;
runtimeFetch(uri, options)
.then((response) => {
// Forward the response on the context.
operation.setContext({ response });
return response;
})
.then(parseAndCheckHttpResponse(operation))
.then((result) => {
observer.next(result);
observer.complete();
})
.catch((error) => {
// If the observable is being cleaned up, there is no need to call
// next or error because there are no more subscribers. An error after
// cleanup begins is likely from the cleanup function aborting the
// fetch.
if (!cleaningUp) {
// For errors such as an invalid fetch URI there will be no GraphQL
// result with errors or data to forward.
if (error.result && error.result.errors && error.result.data)
observer.next(error.result);
observer.error(error);
}
});
// Cleanup function.
return () => {
cleaningUp = true;
// Abort fetch. It’s ok to signal an abort even when not fetching.
if (typeof controller !== "boolean") controller.abort();
};
});
});
}
/**
* Checks if a value is an extractable file.
* @template [ExtractableFile=any] Extractable file.
* @callback ExtractableFileMatcher
* @param {unknown} value Value to check.
* @returns {value is ExtractableFile} Is the value an extractable file.
* @example
* How to check for the default exactable files, as well as a custom type of
* file:
*
* ```js
* import isExtractableFile from "apollo-upload-client/isExtractableFile.mjs";
*
* const isExtractableFileEnhanced = (value) =>
* isExtractableFile(value) ||
* (typeof CustomFile !== "undefined" && value instanceof CustomFile);
* ```
*/
/**
* Appends a file extracted from the GraphQL operation to the
* [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* instance used as the
* [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
* `options.body` for the
* [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec).
* @template [ExtractableFile=any] Extractable file.
* @callback FormDataFileAppender
* @param {FormData} formData
* [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* instance to append the specified file to.
* @param {string} fieldName Form data field name to append the file with.
* @param {ExtractableFile} file File to append. The file type depends on what
* the extractable file matcher extracts.
*/