awesome-graphql-client
Version:
GraphQL Client with file upload support for NodeJS and browser
355 lines (346 loc) • 11.1 kB
JavaScript
//#region src/GraphQLRequestError.ts
var GraphQLRequestError = class extends Error {
query;
variables;
response;
extensions;
fieldErrors;
constructor({ query, variables, response, message, extensions, fieldErrors }) {
super(`GraphQL Request Error: ${message}`);
this.query = query;
if (variables) this.variables = variables;
if (extensions) this.extensions = extensions;
if (fieldErrors) this.fieldErrors = fieldErrors;
Object.defineProperty(this, "response", {
enumerable: false,
value: response
});
}
};
//#endregion
//#region src/util/assert.ts
function assert(condition, msg) {
if (!condition) throw new Error(msg);
}
//#endregion
//#region src/util/extractFiles.ts
const isIterable = (value) => Array.isArray(value) || typeof FileList !== "undefined" && value instanceof FileList;
function isPlainObject(value) {
if (typeof value !== "object" || value === null) return false;
const prototype = Object.getPrototypeOf(value);
return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value);
}
/**
* Parses object and detects files according to GraphQL Upload Spec:
* https://github.com/jaydenseric/graphql-multipart-request-spec
*
* @param operation GraphQL-operation
* @param isUpload predicate for checking if value is a file
*/
function extractFiles(operation, isUpload) {
const clone = {};
const files = /* @__PURE__ */ new Map();
const stackSet = /* @__PURE__ */ new Set();
function extract(paths, value, target) {
const currentPath = paths.at(-1);
if (isUpload(value)) {
let saved = files.get(value);
if (!saved) {
saved = [];
files.set(value, saved);
}
saved.push(paths.join("."));
target[currentPath] = null;
} else if (isIterable(value) || isPlainObject(value)) {
if (stackSet.has(value)) throw new Error(`Circular dependency detected in ${paths.join(".")}`);
stackSet.add(value);
let newObj;
if (isIterable(value)) {
newObj = [];
for (let i = 0; i < value.length; i++) extract([...paths, String(i)], value[i], newObj);
} else {
newObj = {};
for (const [key, item] of Object.entries(value)) extract([...paths, key], item, newObj);
}
target[currentPath] = newObj;
stackSet.delete(value);
} else target[currentPath] = value;
}
for (const [key, item] of Object.entries(operation)) extract([key], item, clone);
return {
clone,
files
};
}
//#endregion
//#region src/util/formatGetRequestUrl.ts
/**
* Returns URL for GraphQL GET Requests:
* https://graphql.org/learn/serving-over-http/#get-request
*/
function formatGetRequestUrl({ endpoint, query, variables }) {
const searchParams = new URLSearchParams();
searchParams.set("query", query);
if (variables && Object.keys(variables).length > 0) searchParams.set("variables", JSON.stringify(variables));
return `${endpoint}?${searchParams.toString()}`;
}
//#endregion
//#region src/util/isFileUpload.ts
/**
* Duck-typing if value is a stream
* https://github.com/sindresorhus/is-stream/blob/3750505b0727f6df54324784fe369365ef78841e/index.js#L3
*
* @param value incoming value
*/
const isStreamLike = (value) => typeof value === "object" && value !== null && typeof value.pipe === "function";
/**
* Duck-typing if value is a promise
*
* @param value incoming value
*/
const isPromiseLike = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
/**
* Returns true if value is a file.
* Supports File, Blob, Buffer and stream-like instances
*
* @param value incoming value
*/
const isFileUpload = (value) => typeof File !== "undefined" && value instanceof File || typeof Blob !== "undefined" && value instanceof Blob || typeof Buffer !== "undefined" && value instanceof Buffer || isStreamLike(value) || isPromiseLike(value);
//#endregion
//#region src/util/isResponseJSON.ts
const isResponseJSON = (response) => (response.headers.get("Content-Type") ?? "").includes("application/json");
//#endregion
//#region src/util/normalizeHeaders.ts
const isHeaders = (headers) => typeof headers.get === "function";
const isIterableHeaders = (headers) => typeof headers[Symbol.iterator] === "function";
/**
* This function will convert headers to { [key: string]: string }
* and make header keys lowercase (as per https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2)
*
* @param headers request headers
*/
function normalizeHeaders(headers) {
if (headers === void 0) return {};
if (isHeaders(headers)) {
const newHeaders = {};
headers.forEach((value, key) => {
newHeaders[key] = value;
});
return newHeaders;
}
if (isIterableHeaders(headers)) {
const newHeaders = {};
for (const [key, value] of headers) newHeaders[key.toLowerCase()] = value;
return newHeaders;
}
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
}
//#endregion
//#region src/AwesomeGraphQLClient.ts
var AwesomeGraphQLClient = class {
endpoint;
fetch;
fetchOptions;
formatQuery;
FormData;
onError;
isFileUpload;
constructor(config) {
assert(config.endpoint !== void 0, "endpoint is required");
assert(config.fetch !== void 0 || typeof fetch !== "undefined", "Fetch must be polyfilled or passed in new AwesomeGraphQLClient({ fetch })");
assert(!config.formatQuery || typeof config.formatQuery === "function", "Invalid config value: `formatQuery` must be a function");
assert(!config.onError || typeof config.onError === "function", "Invalid config value: `onError` must be a function");
assert(!config.isFileUpload || typeof config.isFileUpload === "function", "Invalid config value: `isFileUpload` should be a function");
this.endpoint = config.endpoint;
this.fetch = config.fetch || fetch.bind(null);
this.fetchOptions = config.fetchOptions;
this.FormData = config.FormData !== void 0 ? config.FormData : typeof FormData !== "undefined" ? FormData : void 0;
this.formatQuery = config.formatQuery;
this.onError = config.onError;
this.isFileUpload = config.isFileUpload || isFileUpload;
}
createRequestBody(query, variables) {
const { clone, files } = extractFiles({
query,
variables
}, this.isFileUpload);
const operationJSON = JSON.stringify(clone);
if (files.size === 0) return operationJSON;
assert(this.FormData !== void 0, "FormData must be polyfilled or passed in new AwesomeGraphQLClient({ FormData })");
const form = new this.FormData();
form.append("operations", operationJSON);
const map = {};
let i = 0;
for (const paths of files.values()) map[++i] = paths;
form.append("map", JSON.stringify(map));
i = 0;
for (const file of files.keys()) form.append(`${++i}`, file);
return form;
}
/**
* Sets a new GraphQL endpoint
*
* @param endpoint new overrides for endpoint
*/
setEndpoint(endpoint) {
assert(endpoint !== void 0, "endpoint is required");
this.endpoint = endpoint;
}
/**
* Returns current GraphQL endpoint
*/
getEndpoint() {
return this.endpoint;
}
/**
* Sets new overrides for fetch options
*
* @param fetchOptions new overrides for fetch options
*/
setFetchOptions(fetchOptions) {
this.fetchOptions = fetchOptions;
}
/**
* Returns current overrides for fetch options
*/
getFetchOptions() {
return this.fetchOptions;
}
/**
* Sends GraphQL Request and returns object with 'ok: true', 'data' and 'response' fields
* or with 'ok: false' and 'error' fields.
* Notice: this function never throws
*
* @example
* const result = await requestSafe(...)
* if (!result.ok) {
* throw result.error
* }
* console.log(result.data)
*
* @param query query
* @param variables variables
* @param fetchOptions overrides for fetch options
*/
async requestSafe(query, variables, fetchOptions) {
let partialData = void 0;
try {
const queryAsString = this.formatQuery ? this.formatQuery(query) : query;
assert(typeof queryAsString === "string", `Query should be a string, not ${typeof queryAsString}. Otherwise provide formatQuery option`);
const options = {
method: "POST",
...this.fetchOptions,
...fetchOptions,
headers: {
...normalizeHeaders(this.fetchOptions?.headers),
...normalizeHeaders(fetchOptions?.headers)
}
};
let response;
if (options.method?.toUpperCase() === "GET") {
const url = formatGetRequestUrl({
endpoint: this.endpoint,
query: queryAsString,
variables
});
response = await this.fetch(url, options);
} else {
const body = this.createRequestBody(queryAsString, variables);
response = await this.fetch(this.endpoint, {
...options,
body,
headers: typeof body === "string" ? {
...options.headers,
"Content-Type": "application/json"
} : options.headers
});
}
if (!response.ok) {
if (isResponseJSON(response)) {
const { data, errors } = await response.json();
if (errors?.[0] !== void 0) {
partialData = data;
throw new GraphQLRequestError({
query: queryAsString,
variables,
response,
message: errors[0].message,
extensions: errors[0].extensions,
fieldErrors: errors
});
}
}
throw new GraphQLRequestError({
query: queryAsString,
variables,
response,
message: `Http Status ${response.status}`
});
}
const result = await response.json();
if (result.errors?.[0] !== void 0) {
partialData = result.data;
throw new GraphQLRequestError({
query: queryAsString,
variables,
response,
message: result.errors[0].message,
extensions: result.errors[0].extensions,
fieldErrors: result.errors
});
}
return {
ok: true,
data: result.data,
response
};
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
if (this.onError) try {
this.onError(error);
} catch {
return {
ok: false,
error,
partialData
};
}
return {
ok: false,
error,
partialData
};
}
}
/**
* Makes GraphQL request and returns data or throws an error
*
* @example
* const data = await request(...)
*
* @param query query
* @param variables variables
* @param fetchOptions overrides for fetch options
*/
async request(query, variables, fetchOptions) {
const result = await this.requestSafe(query, variables, fetchOptions);
if (!result.ok) throw result.error;
return result.data;
}
};
//#endregion
//#region src/util/gql.ts
/**
* Fake `graphql-tag`.
* Recommended if you're using `graphql-tag` only for syntax highlighting
* and static analysis such as linting and types generation.
* It has less computational cost and makes overall smaller bundles. See:
* https://github.com/lynxtaa/awesome-graphql-client#approach-2-use-fake-graphql-tag
*/
const gql = (strings, ...values) => {
let result = "";
for (const [index, string] of strings.entries()) result += `${string}${index in values ? values[index] : ""}`;
return result.trim();
};
//#endregion
export { AwesomeGraphQLClient, GraphQLRequestError, gql, isFileUpload };