@anvilco/anvil
Version:
Anvil API Client
856 lines (759 loc) • 25.1 kB
JavaScript
import fs from 'fs'
// We are only importing this for the type..
import { Stream } from 'stream' // eslint-disable-line no-unused-vars
import AbortController from 'abort-controller'
import { RateLimiter } from 'limiter'
import UploadWithOptions from './UploadWithOptions'
import { version, description } from '../package.json'
import { looksLikeJsonError, normalizeNodeError, normalizeJsonErrors } from './errors'
import { queries, mutations } from './graphql'
import {
isFile,
graphQLUploadSchemaIsValid,
} from './validation'
class Warning extends Error {}
let extractFiles
let FormDataModule
let Fetch
let fetch
/**
* @typedef AnvilOptions
* @type {Object}
* @property {string} [apiKey]
* @property {string} [accessToken]
* @property {string} [baseURL]
* @property {string} [userAgent]
* @property {number} [requestLimit]
* @property {number} [requestLimitMS]
*/
/**
* @typedef GraphQLResponse
* @type {Object}
* @property {number} statusCode
* @property {GraphQLResponseData} [data]
* @property {Array<ResponseError | NodeError>} [errors]
*/
/** @typedef {{
data: {[ key: string]: any }
}} GraphQLResponseData */
/**
* @typedef RESTResponse
* @type {Object}
* @property {number} statusCode
* @property {Buffer|Stream|Object} [data]
* @property {Array<ResponseError | NodeError>} [errors]
* @property {any} [response] node-fetch Response
*/
/** @typedef {{
name: string;
message: string;
stack: string;
code: string;
cause?: any;
}} NodeError */
/** @typedef {{
message: string,
status?: number,
name?: string,
fields?: Array<ResponseErrorField>
[key: string]: any
}} ResponseError */
/** @typedef {{
message: string,
property?: string,
[key: string]: any
}} ResponseErrorField */
/** @typedef {{
path: string
}} Readable */
// Ignoring the below since they are dynamically created depepending on what's
// inside the `src/graphql` directory.
const {
mutations: {
// @ts-ignore
createEtchPacket: {
generateMutation: generateCreateEtchPacketMutation,
},
// @ts-ignore
forgeSubmit: {
generateMutation: generateForgeSubmitMutation,
},
// @ts-ignore
generateEtchSignUrl: {
generateMutation: generateEtchSignUrlMutation,
},
// @ts-ignore
removeWeldData: {
generateMutation: generateRemoveWeldDataMutation,
},
},
queries: {
// @ts-ignore
etchPacket: {
generateQuery: generateEtchPacketQuery,
},
},
} = { queries, mutations }
const DATA_TYPE_STREAM = 'stream'
const DATA_TYPE_BUFFER = 'buffer'
const DATA_TYPE_ARRAY_BUFFER = 'arrayBuffer'
const DATA_TYPE_JSON = 'json'
const SUPPORTED_BINARY_DATA_TYPES = Object.freeze([
DATA_TYPE_STREAM,
DATA_TYPE_BUFFER,
DATA_TYPE_ARRAY_BUFFER,
])
// Version number to use for latest versions (usually drafts)
const VERSION_LATEST = -1
// Version number to use for the latest published version.
// This is the default when a version is not provided.
const VERSION_LATEST_PUBLISHED = -2
const defaultOptions = {
baseURL: 'https://app.useanvil.com',
userAgent: `${description}/${version}`,
}
const FILENAME_IGNORE_MESSAGE = 'If you think you can ignore this, please pass `options.ignoreFilenameValidation` as `true`.'
const failBufferMS = 50
class Anvil {
// {
// apiKey: <yourAPIKey>,
// accessToken: <yourAPIKey>, // OR oauth access token
// baseURL: 'https://app.useanvil.com'
// userAgent: 'Anvil API Client/2.0.0'
// }
/**
* @param {AnvilOptions?} options
*/
constructor (options) {
if (!options) throw new Error('options are required')
this.options = {
...defaultOptions,
requestLimit: 1,
requestLimitMS: 1000,
...options,
}
const { apiKey, accessToken } = this.options
if (!(apiKey || accessToken)) throw new Error('apiKey or accessToken required')
this.authHeader = accessToken
? `Bearer ${Buffer.from(accessToken, 'ascii').toString('base64')}`
: `Basic ${Buffer.from(`${apiKey}:`, 'ascii').toString('base64')}`
// Indicates that we have not dynamically set the Rate Limit from the API response
this.hasSetLimiterFromResponse = false
// Indicates that we are in the process setting the Rate Limit from an API response
this.limiterSettingInProgress = false
// A Promise that all early requests will have to wait for before continuing on. This
// promise will be resolved by the first API response
this.rateLimiterSetupPromise = new Promise((resolve) => {
this.rateLimiterPromiseResolver = resolve
})
// Set our initial limiter
this._setRateLimiter({ tokens: this.options.requestLimit, intervalMs: this.options.requestLimitMS })
}
/**
* @param {Object} options
* @param {number} options.tokens
* @param {number} options.intervalMs
* @private
*/
_setRateLimiter ({ tokens, intervalMs }) {
if (
// Both must be truthy
!(tokens && intervalMs) ||
// Things should not be the same as they already are
(this.limitTokens === tokens && this.limitIntervalMs === intervalMs)
) {
return
}
const newLimiter = new RateLimiter({ tokensPerInterval: tokens, interval: intervalMs })
// If we already had a limiter, let's try to pick up where it left off
if (this.limiter) {
const tokensInUse = Math.max(
// getTokensRemaining() can return a decimal, so we round it down
// so as to be conservative about potentially hitting the API again
this.limitTokens - Math.floor(this.limiter.getTokensRemaining()),
0,
)
const tokensToRemove = Math.min(tokens, tokensInUse)
if (tokensToRemove) {
newLimiter.tryRemoveTokens(tokensToRemove)
}
delete this.limiter
}
this.limitTokens = tokens
this.limitIntervalMs = intervalMs
this.limiter = newLimiter
}
/**
* Perform some handy/necessary things for a GraphQL file upload to make it work
* with this client and with our backend
*
* @param {string|Buffer|Readable|File|Blob} pathOrStreamLikeThing - Either a string path to a file,
* a Buffer, or a Stream-like thing that is compatible with form-data as an append.
* @param {Object} [formDataAppendOptions] - User can specify options to be passed to the form-data.append
* call. This should be done if a stream-like thing is not one of the common types that
* form-data can figure out on its own.
*
* @return {UploadWithOptions} - A class that wraps the stream-like-thing and any options
* up together nicely in a way that we can also tell that it was us who did it.
*/
static prepareGraphQLFile (pathOrStreamLikeThing, { ignoreFilenameValidation, ...formDataAppendOptions } = {}) {
if (typeof pathOrStreamLikeThing === 'string') {
// @ts-ignore
// no-op for this logic path. It's a path and we will load it later and it will at least
// have the file's name as a filename to possibly use.
} else if (
!formDataAppendOptions ||
(
formDataAppendOptions && !(
// Require the filename or the ignoreFilenameValidation option. This is an escape hatch
// for things we didn't anticipate to cause problems
formDataAppendOptions.filename || ignoreFilenameValidation
)
)
) {
// OK, there's a chance here that a `filename` needs to be provided via formDataAppendOptions
if (
// Buffer has no way to get the filename
pathOrStreamLikeThing instanceof Buffer ||
!(
// Some stream things have a string path in them (can also be a buffer, but we want/need string)
// @ts-ignore
(pathOrStreamLikeThing.path && typeof pathOrStreamLikeThing.path === 'string') ||
// A File might look like this
// @ts-ignore
(pathOrStreamLikeThing.name && typeof pathOrStreamLikeThing.name === 'string')
)
) {
let message = 'For this type of input, `options.filename` must be provided to prepareGraphQLFile.' + ' ' + FILENAME_IGNORE_MESSAGE
try {
if (pathOrStreamLikeThing && pathOrStreamLikeThing.constructor && pathOrStreamLikeThing.constructor.name) {
message = `When passing a ${pathOrStreamLikeThing.constructor.name} to prepareGraphQLFile, \`options.filename\` must be provided. ${FILENAME_IGNORE_MESSAGE}`
}
} catch (err) {
console.error(err)
}
throw new Error(message)
}
}
return new UploadWithOptions(pathOrStreamLikeThing, formDataAppendOptions)
}
/**
* Runs the createEtchPacket mutation.
* @param {Object} data
* @param {Object} data.variables
* @param {string} [data.responseQuery]
* @param {string} [data.mutation]
* @returns {Promise<GraphQLResponse>}
*/
createEtchPacket ({ variables, responseQuery, mutation }) {
return this.requestGraphQL(
{
query: mutation || generateCreateEtchPacketMutation(responseQuery),
variables,
},
{ dataType: DATA_TYPE_JSON },
)
}
/**
* @param {string} documentGroupEid
* @param {Object} [clientOptions]
* @returns {Promise<RESTResponse>}
*/
downloadDocuments (documentGroupEid, clientOptions = {}) {
const { dataType = DATA_TYPE_BUFFER } = clientOptions
if (dataType && !SUPPORTED_BINARY_DATA_TYPES.includes(dataType)) {
throw new Error(`dataType must be one of: ${SUPPORTED_BINARY_DATA_TYPES.join('|')}`)
}
return this.requestREST(
`/api/document-group/${documentGroupEid}.zip`,
{ method: 'GET' },
{
...clientOptions,
dataType,
},
)
}
/**
* @param {string} pdfTemplateID
* @param {Object} payload
* @param {Object} [clientOptions]
* @returns {Promise<RESTResponse>}
*/
fillPDF (pdfTemplateID, payload, clientOptions = {}) {
const { dataType = DATA_TYPE_BUFFER } = clientOptions
if (dataType && !SUPPORTED_BINARY_DATA_TYPES.includes(dataType)) {
throw new Error(`dataType must be one of: ${SUPPORTED_BINARY_DATA_TYPES.join('|')}`)
}
const versionNumber = clientOptions.versionNumber
const url = versionNumber
? `/api/v1/fill/${pdfTemplateID}.pdf?versionNumber=${versionNumber}`
: `/api/v1/fill/${pdfTemplateID}.pdf`
return this.requestREST(
url,
{
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
},
{
...clientOptions,
dataType,
},
)
}
/**
* @param {Object} data
* @param {Object} data.variables
* @param {string} [data.responseQuery]
* @param {string} [data.mutation]
* @returns {Promise<GraphQLResponse>}
*/
forgeSubmit ({ variables, responseQuery, mutation }) {
return this.requestGraphQL(
{
query: mutation || generateForgeSubmitMutation(responseQuery),
variables,
},
{ dataType: DATA_TYPE_JSON },
)
}
/**
* @param {Object} payload
* @param {Object} [clientOptions]
* @returns {Promise<RESTResponse>}
*/
generatePDF (payload, clientOptions = {}) {
const { dataType = DATA_TYPE_BUFFER } = clientOptions
if (dataType && !SUPPORTED_BINARY_DATA_TYPES.includes(dataType)) {
throw new Error(`dataType must be one of: ${SUPPORTED_BINARY_DATA_TYPES.join('|')}`)
}
return this.requestREST(
'/api/v1/generate-pdf',
{
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
},
{
...clientOptions,
dataType,
},
)
}
/**
* @param {Object} data
* @param {Object} data.variables
* @param {string} [data.responseQuery]
* @returns {Promise<GraphQLResponse>}
*/
getEtchPacket ({ variables, responseQuery }) {
return this.requestGraphQL(
{
query: generateEtchPacketQuery(responseQuery),
variables,
},
{ dataType: DATA_TYPE_JSON },
)
}
/**
* @param {Object} data
* @param {Object} data.variables
* @returns {Promise<{url?: string, errors?: Array<ResponseError | NodeError>, statusCode: number}>}
*/
async generateEtchSignUrl ({ variables }) {
const { statusCode, data, errors } = await this.requestGraphQL(
{
query: generateEtchSignUrlMutation(),
variables,
},
{ dataType: DATA_TYPE_JSON },
)
return {
statusCode,
url: data && data.data && data.data.generateEtchSignURL,
errors,
}
}
/**
* @param {Object} data
* @param {Object} data.variables
* @param {string} [data.mutation]
* @returns {Promise<GraphQLResponse>}
*/
removeWeldData ({ variables, mutation }) {
return this.requestGraphQL(
{
query: mutation || generateRemoveWeldDataMutation(),
variables,
},
{ dataType: DATA_TYPE_JSON },
)
}
/**
* @param {Object} data
* @param {string} data.query
* @param {Object} [data.variables]
* @param {Object} [clientOptions]
* @returns {Promise<GraphQLResponse>}
*/
async requestGraphQL ({ query, variables = {} }, clientOptions) {
// Some helpful resources on how this came to be:
// https://github.com/jaydenseric/graphql-upload/issues/125#issuecomment-440853538
// https://zach.codes/building-a-file-upload-hook/
// https://github.com/jaydenseric/graphql-react/blob/1b1234de5de46b7a0029903a1446dcc061f37d09/src/universal/graphqlFetchOptions.mjs
// https://www.npmjs.com/package/extract-files
const options = {
method: 'POST',
headers: {},
}
const originalOperation = { query, variables }
extractFiles ??= (await import('extract-files/extractFiles.mjs')).default
const {
clone: augmentedOperation,
files: filesMap,
} = extractFiles(originalOperation, isFile)
const operationJSON = JSON.stringify(augmentedOperation)
// Checks for both File uploads and Base64 uploads
if (!graphQLUploadSchemaIsValid(originalOperation)) {
throw new Error('Invalid File schema detected')
}
if (filesMap.size) {
// @ts-ignore
const abortController = new AbortController()
Fetch ??= await import('@anvilco/node-fetch')
// This is a dependency of 'node-fetch'`
FormDataModule ??= await import('formdata-polyfill/esm.min.js')
const form = new FormDataModule.FormData()
form.append('operations', operationJSON)
const map = {}
let i = 0
filesMap.forEach(paths => {
map[++i] = paths
})
form.append('map', JSON.stringify(map))
i = 0
filesMap.forEach((paths, file) => {
// Ensure that the file has been run through the prepareGraphQLFile process
// and checks
if (file instanceof UploadWithOptions === false) {
file = Anvil.prepareGraphQLFile(file)
}
let { filename, mimetype, ignoreFilenameValidation } = file.options || {}
file = file.file
if (!file) {
throw new Error('No file provided. Options were: ' + JSON.stringify(options))
}
// If this is a stream-like thing, attach a listener to the 'error' event so that we
// can cancel the API call if something goes wrong
if (typeof file.on === 'function') {
file.on('error', (err) => {
console.warn(err)
abortController.abort()
})
}
// If file a path to a file?
if (typeof file === 'string') {
file = Fetch.fileFromSync(file, mimetype)
} else if (file instanceof Buffer) {
const buffer = file
// https://developer.mozilla.org/en-US/docs/Web/API/File/File
file = new Fetch.File(
[buffer],
filename,
{
type: mimetype,
},
)
} else if (file instanceof Stream) {
// https://github.com/node-fetch/node-fetch#post-data-using-a-file
const stream = file
file = {
[Symbol.toStringTag]: 'File',
// @ts-ignore
size: fs.statSync(stream.path).size,
stream: () => stream,
type: mimetype,
}
// @ts-ignore
filename ??= stream.path.split('/').pop()
} else if (file.constructor.name !== 'File') {
// Like a Blob or something
if (!filename) {
const name = file.name || file.path
if (name) {
filename = name.split('/').pop()
}
if (!filename && !ignoreFilenameValidation) {
console.warn(new Warning('No filename provided. Please provide a filename to the file options.'))
}
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/FormData/append
form.append(`${++i}`, file, filename)
})
options.signal = abortController.signal
options.body = form
} else {
options.headers['Content-Type'] = 'application/json'
options.body = operationJSON
}
const {
statusCode,
data,
errors,
} = await this._wrapRequest(
() => this._request('/graphql', options),
clientOptions,
)
return {
statusCode,
data,
errors,
}
}
/**
* @param {string} url
* @param {Object} fetchOptions
* @param {Object} [clientOptions]
* @returns {Promise<RESTResponse>}
*/
async requestREST (url, fetchOptions, clientOptions) {
const {
response,
statusCode,
data,
errors,
} = await this._wrapRequest(
() => this._request(url, fetchOptions),
clientOptions,
)
return {
response,
statusCode,
data,
errors,
}
}
// ******************************************************************************
// ___ _ __
// / _ \____(_) _____ _/ /____
// / ___/ __/ / |/ / _ `/ __/ -_)
// /_/ /_/ /_/|___/\_,_/\__/\__/
//
// ALL THE BELOW CODE IS CONSIDERED PRIVATE, AND THE API OR INTERNALS MAY CHANGE AT ANY TIME
// USERS OF THIS MODULE SHOULD NOT USE ANY OF THESE METHODS DIRECTLY
// ******************************************************************************
async _request (...args) {
// Only load Fetch once per module process lifetime
Fetch = Fetch || await import('@anvilco/node-fetch')
fetch = Fetch.default
// Monkey-patch so we only try any of this once per Anvil Client instance
this._request = this.__request
return this._request(...args)
}
/**
* @param {string} url
* @param {Object} options
* @returns {Promise}
* @private
*/
__request (url, options) {
if (!url.startsWith(this.options.baseURL)) {
url = this._url(url)
}
const opts = this._addDefaultHeaders(options)
return fetch(url, opts)
}
/**
* @param {CallableFunction} retryableRequestFn
* @param {Object} [clientOptions]
* @returns {Promise<*>}
* @private
*/
_wrapRequest (retryableRequestFn, clientOptions = {}) {
return this._throttle(async (retry) => {
let { dataType, debug } = clientOptions
const response = await retryableRequestFn()
if (!this.hasSetLimiterFromResponse) {
// OK, this is the response sets the rate-limiter values from the
// server response:
// Set up the new Rate Limiter
const tokens = parseInt(response.headers.get('x-ratelimit-limit'))
const intervalMs = parseInt(response.headers.get('x-ratelimit-interval-ms'))
this._setRateLimiter({ tokens, intervalMs })
// Adjust the gates that make this only happen once.
this.hasSetLimiterFromResponse = true
this.limiterSettingInProgress = false
// Resolve the Promise that everyone else was waiting for
this.rateLimiterPromiseResolver()
}
const { status: statusCode, statusText } = response
if (statusCode === 429) {
return retry(getRetryMS(response.headers.get('retry-after')))
}
let json
let isError = false
let nodeError
const contentType = response.headers.get('content-type') || response.headers.get('Content-Type') || ''
// No matter what we were expecting, if the response is JSON, let's parse it and look for
// signs of errors
if (contentType.toLowerCase().includes('application/json')) {
// Re-set the dataType so we don't fall into the wrong flow later on
dataType = DATA_TYPE_JSON
try {
json = await response.json()
isError = looksLikeJsonError({ json })
} catch (err) {
nodeError = err
if (debug) {
console.warn(`Problem parsing JSON response for status ${statusCode}:`)
console.warn(err)
}
}
}
if (nodeError || isError || statusCode >= 300) {
const errors = nodeError ? normalizeNodeError({ error: nodeError }) : normalizeJsonErrors({ json, statusText })
return { response, statusCode, errors }
}
let data
switch (dataType) {
case DATA_TYPE_STREAM:
data = response.body
break
case DATA_TYPE_BUFFER:
// Will ask for it as an arrayBuffer (to avoid deprecation warning) but then convert it to a
// Node Buffer.
// https://github.com/node-fetch/node-fetch/pull/1212
// https://github.com/node-fetch/node-fetch/pull/1345
// https://github.com/anvilco/node-anvil/pull/442
data = Buffer.from(await response.arrayBuffer())
break
case DATA_TYPE_ARRAY_BUFFER:
data = await response.arrayBuffer()
break
case DATA_TYPE_JSON:
// Can't call json() twice, so we'll see if we already did that
data = json || await response.json()
break
default:
console.warn('Using default response dataType of "json". Please specify a dataType.')
data = await response.json()
break
}
return {
response,
data,
statusCode,
}
})
}
/**
* @param {string} path
* @returns {string}
* @private
*/
_url (path) {
return this.options.baseURL + path
}
/**
* @param {Object} headerObject
* @param {Object} headerObject.options
* @param {Object} headerObject.headers
* @param {Object} [internalOptions]
* @returns {*&{headers: {}}}
* @private
*/
_addHeaders ({ options: existingOptions, headers: newHeaders }, internalOptions = {}) {
const { headers: existingHeaders = {} } = existingOptions
const { defaults = false } = internalOptions
newHeaders = defaults
? newHeaders
: Object.entries(newHeaders).reduce((acc, [key, val]) => {
if (val != null) {
acc[key] = val
}
return acc
}, {})
return {
...existingOptions,
headers: {
...existingHeaders,
...newHeaders,
},
}
}
/**
* @param {Object} options
* @returns {*}
* @private
*/
_addDefaultHeaders (options) {
const { userAgent } = this.options
return this._addHeaders(
{
options,
headers: {
'User-Agent': userAgent,
Authorization: this.authHeader,
},
},
{ defaults: true },
)
}
/**
* @param {CallableFunction} fn
* @returns {Promise<*>}
* @private
*/
async _throttle (fn) {
// If this is one of the first requests being made, we'll want to dynamically
// set the Rate Limiter values from the API response, and hold up everyone else
// while this is happening.
// If we've already gone through the whole setup from the response, then nothing
// special to do
if (!this.hasSetLimiterFromResponse) {
// If limiter setting is already in progress, then this request will have to wait
if (this.limiterSettingInProgress) {
await this.rateLimiterSetupPromise
} else {
// Set the gate so that subsequent calls will have to wait for the resolution
this.limiterSettingInProgress = true
}
}
const remainingRequests = await this.limiter.removeTokens(1)
if (remainingRequests < 1) {
await sleep(this.options.requestLimitMS + failBufferMS)
}
const retry = async (ms) => {
await sleep(ms)
return this._throttle(fn)
}
return fn(retry)
}
}
Anvil.UploadWithOptions = UploadWithOptions
/**
* @param {string} retryAfterSeconds
* @returns {number}
* @private
*/
function getRetryMS (retryAfterSeconds) {
return Math.round((Math.abs(parseFloat(retryAfterSeconds)) || 0) * 1000) + failBufferMS
}
/**
* @param {number} ms
* @returns {Promise<any>}
* @private
*/
function sleep (ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
Anvil.VERSION_LATEST = VERSION_LATEST
Anvil.VERSION_LATEST_PUBLISHED = VERSION_LATEST_PUBLISHED
export default Anvil