UNPKG

awesome-graphql-client

Version:

GraphQL Client with file upload support for NodeJS and browser

355 lines (346 loc) 11.1 kB
//#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 };