From 55ad5b26165537889960411f8263bc0013cd600d Mon Sep 17 00:00:00 2001 From: newhouse Date: Wed, 19 Aug 2020 17:21:08 -0400 Subject: [PATCH 01/18] init. basic getCurrentUser works --- src/index.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/index.js b/src/index.js index de0c345..547032b 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,47 @@ const defaultOptions = { const failBufferMS = 50 +const Mutation = ` + mutation CreateEtchPacket ( + $name: String, + $organizationEid: String!, + $files: [EtchFile!], + $send: Boolean, + $isTest: Boolean, + $signatureEmailSubject: String, + $signatureEmailBody: String, + $signaturePageOptions: JSON, + $signers: [JSON!], + $fillPayload: JSON, + ) { + createEtchPacket ( + name: $name, + organizationEid: $organizationEid, + files: $files, + send: $send, + isTest: $isTest, + signatureEmailSubject: $signatureEmailSubject, + signatureEmailBody: $signatureEmailBody, + signaturePageOptions: $signaturePageOptions, + signers: $signers, + fillPayload: $fillPayload + ) { + id + eid + etchTemplate { + id + eid + config + casts { + id + eid + config + } + } + } + } +` + class Anvil { // { // apiKey: , @@ -62,6 +103,24 @@ class Anvil { ) } + getCurrentUser () { + const query = ` + query { + currentUser { + id + email + } + } + ` + const variables = {} + + return this.requestGraphQL({ query, variables }) + } + + createEtchPacket () { + + } + // Private async requestREST (url, options, clientOptions = {}) { @@ -101,6 +160,32 @@ class Anvil { }) } + async requestGraphQL ({ query, variables = {} }) { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: this.options.cookie, + }, + body: JSON.stringify({ + query, + variables, + }), + } + + const response = await this.request('/graphql', options) + + console.log({ response }) + + const data = await response.json() + + console.log({ data }) + return { + statusCode: response.status, + data, + } + } + throttle (fn) { return new Promise((resolve, reject) => { this.limiter.removeTokens(1, async (err, remainingRequests) => { From 69c16a3c0c8cdd57517ffd823eb92597d9e78f6d Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 20 Aug 2020 14:40:51 -0400 Subject: [PATCH 02/18] Adding support for GraphQL createEtchPacket --- package.json | 3 + src/index.js | 153 +++++++++++++++++++++++++++++++++++++++++++++++---- yarn.lock | 43 +++++++++++++++ 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 01c363f..820fb65 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,10 @@ "yargs": "^15.1.0" }, "dependencies": { + "extract-files": "^6", + "form-data": "^3.0.0", "limiter": "^1.1.5", + "mime-types": "^2.1.27", "node-fetch": "^2.6.0" }, "resolutions": { diff --git a/src/index.js b/src/index.js index 547032b..20e48d9 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,10 @@ +const fs = require('fs') +const path = require('path') + const fetch = require('node-fetch') +const FormData = require('form-data') +const mime = require('mime-types') +const extractFiles = require('extract-files').extractFiles const RateLimiter = require('limiter').RateLimiter const { version, description } = require('../package.json') @@ -79,6 +85,64 @@ class Anvil { this.limiter = new RateLimiter(this.requestLimit, this.requestLimitMS, true) } + static prepareFile (path) { + const readStream = fs.createReadStream(path) + const fileName = this.getFilename(readStream) + const mimeType = this.getMimetype(readStream) + return { + name: fileName, + mimetype: mimeType, + file: readStream, + } + } + + static getFilename (thing, options = {}) { + if (typeof options.filepath === 'string') { + // custom filepath for relative paths + return path.normalize(options.filepath).replace(/\\/g, '/') + } else if (options.filename || thing.name || thing.path) { + // custom filename take precedence + // formidable and the browser add a name property + // fs- and request- streams have path property + return path.basename(options.filename || thing.name || thing.path) + } else if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { + // or try http response + return path.basename(thing.client._httpMessage.path || '') + } + } + + static getMimetype (thing, options = {}) { + // use custom content-type above all + if (typeof options.mimeType === 'string') { + return options.mimeType + } + + // or try `name` from formidable, browser + if (thing.name || thing.path) { + return mime.lookup(thing.name || thing.path) + } + + // or try `path` from fs-, request- streams + if (thing.path) { + mime.lookup(thing.path) + } + + // or if it's http-reponse + if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { + return thing.headers['content-type'] || thing.headers['Content-Type'] + } + + // or guess it from the filepath or filename + if ((options.filepath || options.filename)) { + mime.lookup(options.filepath || options.filename) + } + + // fallback to the default content type if `value` is not simple value + if (typeof thing === 'object') { + return 'application/octet-stream' + } + } + fillPDF (pdfTemplateID, payload, clientOptions = {}) { const supportedDataTypes = [DATA_TYPE_STREAM, DATA_TYPE_BUFFER] const { dataType = DATA_TYPE_BUFFER } = clientOptions @@ -117,8 +181,8 @@ class Anvil { return this.requestGraphQL({ query, variables }) } - createEtchPacket () { - + createEtchPacket ({ variables }) { + return this.requestGraphQL({ query: Mutation, variables }, { dataType: 'json' }) } // Private @@ -160,28 +224,95 @@ class Anvil { }) } - async requestGraphQL ({ query, variables = {} }) { + async requestGraphQL ({ query, variables = {} }, clientOptions) { const options = { method: 'POST', headers: { - 'Content-Type': 'application/json', + // 'Content-Type': 'application/json', Cookie: this.options.cookie, }, - body: JSON.stringify({ - query, - variables, - }), + // body: JSON.stringify({ + // query, + // variables, + // }), + } + + // const operation = { + // query: Mutation, + // variables, + // } + + const { clone: augmentedOperation, files: filesMap } = extractFiles({ query, variables }, '', (value) => { + return value instanceof fs.ReadStream || value instanceof Buffer + }) + + const operationJSON = JSON.stringify(augmentedOperation) + + // const options = { + // url: '/graphql', + // method: 'POST', + // headers: { + // Accept: 'application/json', + // }, + // } + + if (filesMap.size) { + const form = new 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) => { + form.append(`${++i}`, file) + }) + + console.log('map:', JSON.stringify(map)) + console.log('filesMap:', JSON.stringify(filesMap)) + + console.log(JSON.stringify(form)) + // console.log(form.getBuffer()) + // console.log(form.toString()) + // process.exit() + + options.body = form + } else { + options.headers['Content-Type'] = 'application/json' + options.body = operationJSON } const response = await this.request('/graphql', options) console.log({ response }) - const data = await response.json() + const statusCode = response.status + + const { dataType } = clientOptions + let data + switch (dataType) { + case DATA_TYPE_JSON: + data = await response.json() + break + case DATA_TYPE_STREAM: + data = response.body + break + case DATA_TYPE_BUFFER: + data = await response.buffer() + break + default: + data = await response.buffer() + break + } - console.log({ data }) + // console.log({ data }) return { - statusCode: response.status, + statusCode, data, } } diff --git a/yarn.lock b/yarn.lock index 250a461..ad80ebc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -246,6 +246,11 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + auto-changelog@^1.16.2: version "1.16.2" resolved "https://registry.yarnpkg.com/auto-changelog/-/auto-changelog-1.16.2.tgz#4b08b7cbd07fdbd9139c6e06ea0b704db3f5485c" @@ -475,6 +480,13 @@ color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@^3.0.1: version "3.0.2" resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" @@ -594,6 +606,11 @@ define-properties@^1.1.2, define-properties@^1.1.3: dependencies: object-keys "^1.0.12" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + diff@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -915,6 +932,11 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" +extract-files@^6: + version "6.0.0" + resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-6.0.0.tgz#a273fd666aac97fd32e788b62d72d978bf43bb71" + integrity sha512-v9UVTPkERZR1NjEOIPvmbzLFdh8YZFEGjRdSJraop6HJe9PQ8HU9iv6eRMuF06CXXXO/R5OBmnWMixZHuZ8CsA== + fast-deep-equal@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" @@ -994,6 +1016,15 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== +form-data@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" + integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1509,6 +1540,18 @@ make-dir@^1.0.0: dependencies: pify "^3.0.0" +mime-db@1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + +mime-types@^2.1.12, mime-types@^2.1.27: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" From 036c615290fbd4c4d830dde6e8ea28275647ab64 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 20 Aug 2020 17:14:07 -0400 Subject: [PATCH 03/18] added graphql folders. wrapping all request/responses in throttle and body parsing helper. tidied up a lot --- src/graphql/index.js | 5 + src/graphql/mutations/createEtchPacket.js | 44 +++ src/graphql/mutations/index.js | 11 + src/index.js | 431 +++++++++++----------- 4 files changed, 276 insertions(+), 215 deletions(-) create mode 100644 src/graphql/index.js create mode 100644 src/graphql/mutations/createEtchPacket.js create mode 100644 src/graphql/mutations/index.js diff --git a/src/graphql/index.js b/src/graphql/index.js new file mode 100644 index 0000000..ce73e5b --- /dev/null +++ b/src/graphql/index.js @@ -0,0 +1,5 @@ +const mutations = require('./mutations') + +module.exports = { + mutations, +} diff --git a/src/graphql/mutations/createEtchPacket.js b/src/graphql/mutations/createEtchPacket.js new file mode 100644 index 0000000..39089eb --- /dev/null +++ b/src/graphql/mutations/createEtchPacket.js @@ -0,0 +1,44 @@ + +const defaultResponseQuery = `{ + id + eid + etchTemplate { + id + eid + config + casts { + id + eid + config + } + } +}` + +module.exports = { + getMutation: (responseQuery = defaultResponseQuery) => ` + mutation CreateEtchPacket ( + $name: String, + $organizationEid: String!, + $files: [EtchFile!], + $send: Boolean, + $isTest: Boolean, + $signatureEmailSubject: String, + $signatureEmailBody: String, + $signaturePageOptions: JSON, + $signers: [JSON!], + $fillPayload: JSON, + ) { + createEtchPacket ( + name: $name, + organizationEid: $organizationEid, + files: $files, + send: $send, + isTest: $isTest, + signatureEmailSubject: $signatureEmailSubject, + signatureEmailBody: $signatureEmailBody, + signaturePageOptions: $signaturePageOptions, + signers: $signers, + fillPayload: $fillPayload + ) ${responseQuery} + }`, +} diff --git a/src/graphql/mutations/index.js b/src/graphql/mutations/index.js new file mode 100644 index 0000000..62e3a54 --- /dev/null +++ b/src/graphql/mutations/index.js @@ -0,0 +1,11 @@ +const fs = require('fs') + +const IGNORE_FILES = ['index.js'] + +module.exports = fs.readdirSync(__dirname) + .filter((fileName) => (fileName.endsWith('.js') && !fileName.startsWith('.') && !IGNORE_FILES.includes(fileName))) + .reduce((acc, fileName) => { + const mutationName = fileName.slice(0, fileName.length - 3) + acc[mutationName] = require(`./${mutationName}`) + return acc + }, {}) diff --git a/src/index.js b/src/index.js index 20e48d9..42a373c 100644 --- a/src/index.js +++ b/src/index.js @@ -4,11 +4,19 @@ const path = require('path') const fetch = require('node-fetch') const FormData = require('form-data') const mime = require('mime-types') -const extractFiles = require('extract-files').extractFiles -const RateLimiter = require('limiter').RateLimiter +const { extractFiles } = require('extract-files') +const { RateLimiter } = require('limiter') const { version, description } = require('../package.json') +const { + mutations: { + createEtchPacket: { + getMutation: getCreateEtchPacketMutation, + }, + }, +} = require('./graphql') + const DATA_TYPE_STREAM = 'stream' const DATA_TYPE_BUFFER = 'buffer' const DATA_TYPE_JSON = 'json' @@ -20,47 +28,6 @@ const defaultOptions = { const failBufferMS = 50 -const Mutation = ` - mutation CreateEtchPacket ( - $name: String, - $organizationEid: String!, - $files: [EtchFile!], - $send: Boolean, - $isTest: Boolean, - $signatureEmailSubject: String, - $signatureEmailBody: String, - $signaturePageOptions: JSON, - $signers: [JSON!], - $fillPayload: JSON, - ) { - createEtchPacket ( - name: $name, - organizationEid: $organizationEid, - files: $files, - send: $send, - isTest: $isTest, - signatureEmailSubject: $signatureEmailSubject, - signatureEmailBody: $signatureEmailBody, - signaturePageOptions: $signaturePageOptions, - signers: $signers, - fillPayload: $fillPayload - ) { - id - eid - etchTemplate { - id - eid - config - casts { - id - eid - config - } - } - } - } -` - class Anvil { // { // apiKey: , @@ -85,62 +52,18 @@ class Anvil { this.limiter = new RateLimiter(this.requestLimit, this.requestLimitMS, true) } - static prepareFile (path) { - const readStream = fs.createReadStream(path) - const fileName = this.getFilename(readStream) - const mimeType = this.getMimetype(readStream) - return { - name: fileName, - mimetype: mimeType, - file: readStream, - } - } - - static getFilename (thing, options = {}) { - if (typeof options.filepath === 'string') { - // custom filepath for relative paths - return path.normalize(options.filepath).replace(/\\/g, '/') - } else if (options.filename || thing.name || thing.path) { - // custom filename take precedence - // formidable and the browser add a name property - // fs- and request- streams have path property - return path.basename(options.filename || thing.name || thing.path) - } else if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { - // or try http response - return path.basename(thing.client._httpMessage.path || '') + static addStream (pathOrStream) { + if (typeof pathOrStream === 'string') { + pathOrStream = fs.createReadStream(pathOrStream) } + return this._prepareFile(pathOrStream) } - static getMimetype (thing, options = {}) { - // use custom content-type above all - if (typeof options.mimeType === 'string') { - return options.mimeType - } - - // or try `name` from formidable, browser - if (thing.name || thing.path) { - return mime.lookup(thing.name || thing.path) - } - - // or try `path` from fs-, request- streams - if (thing.path) { - mime.lookup(thing.path) - } - - // or if it's http-reponse - if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { - return thing.headers['content-type'] || thing.headers['Content-Type'] - } - - // or guess it from the filepath or filename - if ((options.filepath || options.filename)) { - mime.lookup(options.filepath || options.filename) - } - - // fallback to the default content type if `value` is not simple value - if (typeof thing === 'object') { - return 'application/octet-stream' + static addBuffer (pathOrBuffer) { + if (typeof pathOrBuffer === 'string') { + pathOrBuffer = fs.readFileSync(pathOrBuffer) } + return this._prepareFile(pathOrBuffer) } fillPDF (pdfTemplateID, payload, clientOptions = {}) { @@ -150,7 +73,7 @@ class Anvil { throw new Error(`dataType must be one of: ${supportedDataTypes.join('|')}`) } - return this.requestREST( + return this._requestREST( `/api/v1/fill/${pdfTemplateID}.pdf`, { method: 'POST', @@ -167,6 +90,7 @@ class Anvil { ) } + // Just here for now to highlight authentication questions/concerns getCurrentUser () { const query = ` query { @@ -176,86 +100,74 @@ class Anvil { } } ` - const variables = {} - - return this.requestGraphQL({ query, variables }) + return this._requestGraphQL({ query }) } - createEtchPacket ({ variables }) { - return this.requestGraphQL({ query: Mutation, variables }, { dataType: 'json' }) + // QUESTION: maybe we want to keeep responseQuery to ourselves while we figure out how we want it to + // feel to the Users? + createEtchPacket ({ variables, responseQuery }) { + return this._requestGraphQL( + { + query: getCreateEtchPacketMutation(responseQuery), + variables, + }, + { dataType: 'json' }, + ) } - // Private - - async requestREST (url, options, clientOptions = {}) { - return this.throttle(async (retry) => { - const response = await this.request(url, options) - const statusCode = response.status - - if (statusCode === 429) { - return retry(getRetryMS(response.headers.get('retry-after'))) - } - - if (statusCode >= 300) { - const json = await response.json() - const errors = json.errors || (json.message && [json]) - - return errors ? { statusCode, errors } : { statusCode, ...json } - } - - const { dataType } = clientOptions - let data - switch (dataType) { - case DATA_TYPE_JSON: - data = await response.json() - break - case DATA_TYPE_STREAM: - data = response.body - break - case DATA_TYPE_BUFFER: - data = await response.buffer() - break - default: - data = await response.buffer() - break - } + // ****************************************************************************** + // ___ _ __ + // / _ \____(_) _____ _/ /____ + // / ___/ __/ / |/ / _ `/ __/ -_) + // /_/ /_/ /_/|___/\_,_/\__/\__/ + // + // 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 _requestREST (url, options, clientOptions) { + const { + response, + statusCode, + data, + errors, + } = await this._wrapRequest( + async () => this._request(url, options), + clientOptions, + ) - return { statusCode, data } - }) + return { + response, + statusCode, + data, + errors, + } } - async requestGraphQL ({ query, variables = {} }, clientOptions) { + 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: { - // 'Content-Type': 'application/json', + // FIXME: How does /graphql auth work? Cookie: this.options.cookie, }, - // body: JSON.stringify({ - // query, - // variables, - // }), } - // const operation = { - // query: Mutation, - // variables, - // } + const operation = { query, variables } - const { clone: augmentedOperation, files: filesMap } = extractFiles({ query, variables }, '', (value) => { - return value instanceof fs.ReadStream || value instanceof Buffer - }) + const { + clone: augmentedOperation, + files: filesMap, + } = extractFiles(operation, '', isExtractableFile) const operationJSON = JSON.stringify(augmentedOperation) - // const options = { - // url: '/graphql', - // method: 'POST', - // headers: { - // Accept: 'application/json', - // }, - // } - if (filesMap.size) { const form = new FormData() @@ -273,51 +185,105 @@ class Anvil { form.append(`${++i}`, file) }) - console.log('map:', JSON.stringify(map)) - console.log('filesMap:', JSON.stringify(filesMap)) - - console.log(JSON.stringify(form)) - // console.log(form.getBuffer()) - // console.log(form.toString()) - // process.exit() - options.body = form } else { options.headers['Content-Type'] = 'application/json' options.body = operationJSON } - const response = await this.request('/graphql', options) - - console.log({ response }) - - const statusCode = response.status - - const { dataType } = clientOptions - let data - switch (dataType) { - case DATA_TYPE_JSON: - data = await response.json() - break - case DATA_TYPE_STREAM: - data = response.body - break - case DATA_TYPE_BUFFER: - data = await response.buffer() - break - default: - data = await response.buffer() - break - } + const { + statusCode, + data, + errors, + } = await this._wrapRequest( + () => this._request('/graphql', options), + clientOptions, + ) - // console.log({ data }) return { statusCode, data, + errors, + } + } + + _request (url, options) { + if (!url.startsWith(this.options.baseURL)) { + url = this._url(url) } + const opts = this._addDefaultHeaders(options) + return fetch(url, opts) } - throttle (fn) { + async _wrapRequest (preparedRequest, clientOptions = {}) { + return this._throttle(async (retry) => { + const response = await preparedRequest() + const statusCode = response.status + + if (statusCode >= 300) { + if (statusCode === 429) { + return retry(getRetryMS(response.headers.get('retry-after'))) + } + + const json = await response.json() + const errors = json.errors || (json.message && [json]) + + return errors ? { statusCode, errors } : { statusCode, ...json } + } + + const { dataType } = clientOptions + let data + + switch (dataType) { + case DATA_TYPE_STREAM: + data = response.body + break + case DATA_TYPE_BUFFER: + data = await response.buffer() + break + case DATA_TYPE_JSON: + data = await response.json() + break + default: + console.warn('Using default response dataType of "json". Please specifiy a dataType.') + data = await response.json() + break + } + + return { + response, + data, + statusCode, + } + }) + } + + _url (path) { + return this.options.baseURL + path + } + + _addHeaders ({ options: existingOptions, headers: newHeaders }) { + const { headers: existingHeaders = {} } = existingOptions + return { + ...existingOptions, + headers: { + ...existingHeaders, + ...newHeaders, + }, + } + } + + _addDefaultHeaders (options) { + const { userAgent } = this.options + return this._addHeaders({ + options, + headers: { + 'User-Agent': userAgent, + }, + }) + } + + _throttle (fn) { return new Promise((resolve, reject) => { this.limiter.removeTokens(1, async (err, remainingRequests) => { if (err) reject(err) @@ -326,7 +292,7 @@ class Anvil { } const retry = async (ms) => { await sleep(ms) - return this.throttle(fn) + return this._throttle(fn) } try { resolve(await fn(retry)) @@ -337,40 +303,75 @@ class Anvil { }) } - request (url, options) { - if (!url.startsWith(this.options.baseURL)) { - url = this.url(url) + static _prepareFile (streamOrBuffer) { + const fileName = this._getFilename(streamOrBuffer) + const mimeType = this._getMimetype(streamOrBuffer) + return { + name: fileName, + mimetype: mimeType, + file: streamOrBuffer, } - const opts = this.addDefaultHeaders(options) - return fetch(url, opts) } - url (path) { - return this.options.baseURL + path - } + static _getFilename (thing, options = {}) { + // Very heavily influenced by: + // https://github.com/form-data/form-data/blob/55d90ce4a4c22b0ea0647991d85cb946dfb7395b/lib/form_data.js#L217 - addHeaders ({ options: existingOptions, headers: newHeaders }) { - const { headers: existingHeaders = {} } = existingOptions - return { - ...existingOptions, - headers: { - ...existingHeaders, - ...newHeaders, - }, + if (typeof options.filepath === 'string') { + // custom filepath for relative paths + return path.normalize(options.filepath).replace(/\\/g, '/') + } else if (options.filename || thing.name || thing.path) { + // custom filename take precedence + // formidable and the browser add a name property + // fs- and request- streams have path property + return path.basename(options.filename || thing.name || thing.path) + } else if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { + // or try http response + return path.basename(thing.client._httpMessage.path || '') } } - addDefaultHeaders (options) { - const { userAgent } = this.options - return this.addHeaders({ - options, - headers: { - 'User-Agent': userAgent, - }, - }) + static _getMimetype (thing, options = {}) { + // Very heavily influenced by: + // https://github.com/form-data/form-data/blob/55d90ce4a4c22b0ea0647991d85cb946dfb7395b/lib/form_data.js#L243 + + // use custom content-type above all + if (typeof options.mimeType === 'string') { + return options.mimeType + } + + // or try `name` from formidable, browser + if (thing.name || thing.path) { + return mime.lookup(thing.name || thing.path) + } + + // or try `path` from fs-, request- streams + if (thing.path) { + mime.lookup(thing.path) + } + + // or if it's http-reponse + if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { + return thing.headers['content-type'] || thing.headers['Content-Type'] + } + + // or guess it from the filepath or filename + if ((options.filepath || options.filename)) { + mime.lookup(options.filepath || options.filename) + } + + // fallback to the default content type if `value` is not simple value + if (typeof thing === 'object') { + return 'application/octet-stream' + } } } +// https://www.npmjs.com/package/extract-files/v/6.0.0#type-extractablefilematcher +function isExtractableFile (value) { + return value instanceof fs.ReadStream || value instanceof Buffer +} + function getRetryMS (retryAfterSeconds) { return Math.round((Math.abs(parseFloat(retryAfterSeconds)) || 0) * 1000) + failBufferMS } From 3b567a9af29a7c96bf8e3b0c7b0c1607b03e4d94 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 20 Aug 2020 17:20:27 -0400 Subject: [PATCH 04/18] touch ups --- src/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 42a373c..4e0f34e 100644 --- a/src/index.js +++ b/src/index.js @@ -132,7 +132,7 @@ class Anvil { data, errors, } = await this._wrapRequest( - async () => this._request(url, options), + () => this._request(url, options), clientOptions, ) @@ -215,9 +215,9 @@ class Anvil { return fetch(url, opts) } - async _wrapRequest (preparedRequest, clientOptions = {}) { + async _wrapRequest (retryableRequestFn, clientOptions = {}) { return this._throttle(async (retry) => { - const response = await preparedRequest() + const response = await retryableRequestFn() const statusCode = response.status if (statusCode >= 300) { From f3baae76bc630cb23b1b88b6f10ab9488e674c8e Mon Sep 17 00:00:00 2001 From: newhouse Date: Fri, 21 Aug 2020 10:50:02 -0400 Subject: [PATCH 05/18] tidying up --- src/index.js | 105 ++++++++++++++++++++++------------------------ src/validation.js | 35 ++++++++++++++++ 2 files changed, 85 insertions(+), 55 deletions(-) create mode 100644 src/validation.js diff --git a/src/index.js b/src/index.js index 4e0f34e..b8c78d1 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,11 @@ const { }, } = require('./graphql') +const { + isFile, + graphQLUploadSchemaIsValid, +} = require('./validation') + const DATA_TYPE_STREAM = 'stream' const DATA_TYPE_BUFFER = 'buffer' const DATA_TYPE_JSON = 'json' @@ -37,11 +42,15 @@ class Anvil { // } constructor (options) { if (!options) throw new Error('options are required') - if (!options.apiKey && !options.accessToken) throw new Error('apiKey or accessToken required') - this.options = Object.assign({}, defaultOptions, options) + this.options = { + ...defaultOptions, + ...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')}` @@ -73,7 +82,7 @@ class Anvil { throw new Error(`dataType must be one of: ${supportedDataTypes.join('|')}`) } - return this._requestREST( + return this.requestREST( `/api/v1/fill/${pdfTemplateID}.pdf`, { method: 'POST', @@ -90,23 +99,10 @@ class Anvil { ) } - // Just here for now to highlight authentication questions/concerns - getCurrentUser () { - const query = ` - query { - currentUser { - id - email - } - } - ` - return this._requestGraphQL({ query }) - } - // QUESTION: maybe we want to keeep responseQuery to ourselves while we figure out how we want it to // feel to the Users? createEtchPacket ({ variables, responseQuery }) { - return this._requestGraphQL( + return this.requestGraphQL( { query: getCreateEtchPacketMutation(responseQuery), variables, @@ -115,36 +111,7 @@ class Anvil { ) } - // ****************************************************************************** - // ___ _ __ - // / _ \____(_) _____ _/ /____ - // / ___/ __/ / |/ / _ `/ __/ -_) - // /_/ /_/ /_/|___/\_,_/\__/\__/ - // - // 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 _requestREST (url, options, clientOptions) { - const { - response, - statusCode, - data, - errors, - } = await this._wrapRequest( - () => this._request(url, options), - clientOptions, - ) - - return { - response, - statusCode, - data, - errors, - } - } - - async _requestGraphQL ({ query, variables = {} }, clientOptions) { + 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/ @@ -159,16 +126,20 @@ class Anvil { }, } - const operation = { query, variables } + const originalOperation = { query, variables } const { clone: augmentedOperation, files: filesMap, - } = extractFiles(operation, '', isExtractableFile) + } = extractFiles(originalOperation, '', isFile) const operationJSON = JSON.stringify(augmentedOperation) if (filesMap.size) { + if (!graphQLUploadSchemaIsValid(originalOperation)) { + throw new Error('Invalid File schema detected') + } + const form = new FormData() form.append('operations', operationJSON) @@ -207,6 +178,35 @@ class Anvil { } } + 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 + // ****************************************************************************** + _request (url, options) { if (!url.startsWith(this.options.baseURL)) { url = this._url(url) @@ -215,7 +215,7 @@ class Anvil { return fetch(url, opts) } - async _wrapRequest (retryableRequestFn, clientOptions = {}) { + _wrapRequest (retryableRequestFn, clientOptions = {}) { return this._throttle(async (retry) => { const response = await retryableRequestFn() const statusCode = response.status @@ -367,11 +367,6 @@ class Anvil { } } -// https://www.npmjs.com/package/extract-files/v/6.0.0#type-extractablefilematcher -function isExtractableFile (value) { - return value instanceof fs.ReadStream || value instanceof Buffer -} - function getRetryMS (retryAfterSeconds) { return Math.round((Math.abs(parseFloat(retryAfterSeconds)) || 0) * 1000) + failBufferMS } diff --git a/src/validation.js b/src/validation.js new file mode 100644 index 0000000..e3215f6 --- /dev/null +++ b/src/validation.js @@ -0,0 +1,35 @@ +const fs = require('fs') + +// https://www.npmjs.com/package/extract-files/v/6.0.0#type-extractablefilematcher +function isFile (value) { + return value instanceof fs.ReadStream || value instanceof Buffer +} + +function graphQLUploadSchemaIsValid (schema, parent) { + if (schema instanceof Array) { + return schema.every((subSchema) => graphQLUploadSchemaIsValid(subSchema, schema)) + } + + if (schema.constructor.name === 'Object') { + return Object.entries(schema).every(([_key, subSchema]) => graphQLUploadSchemaIsValid(subSchema, schema)) + } + + if (!isFile(schema)) { + return true + } + + if (!parent) { + return false + } + + if (parent.file !== schema) { + return false + } + + return ['name', 'mimetype'].every((requiredKey) => parent[requiredKey]) +} + +module.exports = { + isFile, + graphQLUploadSchemaIsValid, +} From bf1c99979d813dddb367b864ecbcecdcc3762095 Mon Sep 17 00:00:00 2001 From: newhouse Date: Wed, 26 Aug 2020 17:24:05 -0400 Subject: [PATCH 06/18] added example base for create-etch-packet --- example/script/create-etch-packet.js | 149 +++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 example/script/create-etch-packet.js diff --git a/example/script/create-etch-packet.js b/example/script/create-etch-packet.js new file mode 100644 index 0000000..5031581 --- /dev/null +++ b/example/script/create-etch-packet.js @@ -0,0 +1,149 @@ +const fs = require('fs') +const path = require('path') +const Anvil = require('../../src/index') +const argv = require('yargs') + .usage('Usage: $0 apiKey orgEid castEid, fileName') + .option('user-agent', { + alias: 'a', + type: 'string', + description: 'Set the User-Agent on any requests made (default is "Anvil API Client")', + }) + .demandCommand(4).argv + +const [apiKey, orgEid, castEid, fileName] = argv._ +const userAgent = argv['user-agent'] + +const pathToFile = path.resolve(__dirname, fileName) + +async function main () { + const clientOptions = { + apiKey, + } + if (userAgent) { + clientOptions.userAgent = userAgent + } + + const client = new Anvil(clientOptions) + + const fileStream = await Anvil.addStream(pathToFile) + const base64File = fs.readFileSync(pathToFile, { encoding: 'base64' }) + const variables = { + organizationEid: orgEid, + send: false, + isTest: true, + signers: [ + { + id: 'signerOne', + name: 'Sally Signer', + email: 'sally@example.com', + fields: [ + { + fileId: 'fileOne', + fieldId: 'aDateField', + }, + { + fileId: 'fileOne', + fieldId: 'aSignatureField', + }, + ], + }, + { + id: 'signerTwo', + name: 'Scotty Signer', + email: 'scotty@example.com', + fields: [ + { + fileId: 'base64upload', + fieldId: 'anotherSignatureField', + }, + ], + }, + ], + files: [ + { + id: 'fileUpload', + title: 'Important PDF One', + file: fileStream, + fields: [ + { + aliasId: 'aDateField', + type: 'signatureDate', + pageNum: 1, + rect: { + x: 203.88, + y: 171.66, + width: 33.94, + height: 27.60, + }, + }, + { + aliasId: 'aSignatureField', + type: 'signature', + pageNum: 1, + rect: { + x: 203.88, + y: 121.66, + width: 33.94, + height: 27.60, + }, + }, + ], + }, + { + id: 'base64upload', + title: 'Important PDF 2', + base64File: { + filename: fileName, + mimetype: 'application/pdf', + data: base64File, + }, + fields: [ + { + aliasId: 'anotherSignatureField', + type: 'signature', + pageNum: 1, + rect: { + x: 203.88, + y: 171.66, + width: 33.94, + height: 27.60, + }, + }, + ], + }, + { + id: 'preExistingCastReference', + castEid: castEid, + }, + ], + } + + const responseQuery = `{ + id + eid + payload + etchTemplate { + id + eid + config + casts { + id + eid + config + } + } + }` + + const { statusCode, data, errors } = await client.createEtchPacket({ variables, responseQuery }) + + console.log(statusCode, JSON.stringify(errors || data, null, 2)) +} + +main() + .then(() => { + process.exit(0) + }) + .catch((err) => { + console.log(err.stack || err.message) + process.exit(1) + }) From b7574f12aa81e426e36e720dff4b2e0e37db50e0 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 09:29:33 -0400 Subject: [PATCH 07/18] ignore scratch directory --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ce13eba..1341a81 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules package-lock.json example/script/*.pdf example/script/*.json + +scratch/ From b0fb5f5ff023771a7ecf8f690e0908249abbe0e1 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 14:13:26 -0400 Subject: [PATCH 08/18] updated docs --- README.md | 69 ++++++++++--- example/script/create-etch-packet.js | 35 +++---- package.json | 1 + src/index.js | 149 +++++++++++++++++++-------- src/validation.js | 45 ++++++-- yarn.lock | 2 +- 6 files changed, 213 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index deefd91..e23f210 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ fs.writeFileSync('output.pdf', data, { encoding: null }) ## API -### new Anvil(options) +### Instance Methods + +##### new Anvil(options) Creates an Anvil client instance. @@ -57,18 +59,9 @@ Creates an Anvil client instance. ```js const anvilClient = new Anvil({ apiKey: 'abc123' }) ``` +
-### Options - -Options for the Anvil Client. Defaults are shown after each option key. - -```js -{ - apiKey: // Required. Your API key from your Anvil organization settings -} -``` - -### Anvil::fillPDF(pdfTemplateID, payload[, options]) +##### fillPDF(pdfTemplateID, payload[, options]) Fills a PDF with your JSON data. @@ -115,15 +108,63 @@ const { statusCode, data } = await anvilClient.fillPDF(pdfTemplateID, payload, o * `errors` (Array of Objects) - Will be present if status >= 400. See Errors * `message` (String) +##### createEtchPacket(variables[, responseQuery]) + +Creates an Etch Packet and optionally sends it to the first signer. See the [API Documentation](#api-documentation) area for details. See [Examples](#examples) area for examples. + +### Class Methods + +##### prepareGraphQLStream(pathOrStream[, options]) +A nice helper to prepare a Stream-backed file upload for use with our GraphQL API. +* `pathOrStream` (Stream | String) - Either an existing `Stream` or a string representing a fully resolved path to a file to be read into a new `Stream`. +* `options` (Object) - [UploadOptions](#uploadoptions) for the resulting object. +* Returns an `Object` that is properly formatted to be coerced by the client for use against our GraphQL API wherever an `Upload` type is required. + +##### prepareGraphQLBuffer(pathOrBuffer[, options]) +A nice helper to prepare a Buffer-backed file upload for use with our GraphQL API. +* `pathOrBuffer` (Buffer | String) - Either an existing `Buffer` or a string representing a fully resolved path to a file to be read into a new `Buffer`. +* `options` (Object) - [UploadOptions](#uploadoptions) for the resulting object. +* Returns an `Object` that is properly formatted to be coerced by the client for use against our GraphQL API wherever an `Upload` type is required. + +##### prepareGraphQLBase64(data, options) +A nice helper to prepare a Base64-encoded-string-backed upload for use with our GraphQL API. +* `data` (String) - A `base64`-encoded string. +* `options` (Object) - [UploadOptions](#uploadoptions) for the resulting object. Also supports a `bufferize (Boolean)` option - set to `true` to convert the data to a `Buffer` and then call `prepareGraphQLBuffer`. +* Returns an `Object` that is properly formatted to be coerced by the client for use against our GraphQL API wherever a `Base64Upload` type is required. + +### Types + +##### Options + +Options for the Anvil Client. Defaults are shown after each option key. + +```js +{ + apiKey: // Required. Your API key from your Anvil organization settings +} +``` + +##### UploadOptions + +Options for the upload preparation class methods. +```js +{ + filename: , // String + mimetype: // String +} +``` + ### Rate Limits Our API has request rate limits in place. This API client handles `429 Too Many Requests` errors by waiting until it can retry again, then retrying the request. The client attempts to avoid `429` errors by throttling requests after the number of requests within the specified time period has been reached. See the [Anvil API docs](https://useanvil.com/api/fill-pdf) for more information on the specifics of the rate limits. -### More Info +### API Documentation + +Our general API Documentation can be found [here](https://www.useanvil.com/api/). It's the best resource for up-to-date information about our API and its capabilities. -See the [PDF filling API docs](https://useanvil.com/api/fill-pdf) for more information. +See the [PDF filling API docs](https://useanvil.com/api/fill-pdf) for more information about the `fillPDF` method. ## Examples diff --git a/example/script/create-etch-packet.js b/example/script/create-etch-packet.js index 5031581..3067255 100644 --- a/example/script/create-etch-packet.js +++ b/example/script/create-etch-packet.js @@ -3,30 +3,25 @@ const path = require('path') const Anvil = require('../../src/index') const argv = require('yargs') .usage('Usage: $0 apiKey orgEid castEid, fileName') - .option('user-agent', { - alias: 'a', - type: 'string', - description: 'Set the User-Agent on any requests made (default is "Anvil API Client")', - }) .demandCommand(4).argv const [apiKey, orgEid, castEid, fileName] = argv._ -const userAgent = argv['user-agent'] - const pathToFile = path.resolve(__dirname, fileName) async function main () { const clientOptions = { apiKey, } - if (userAgent) { - clientOptions.userAgent = userAgent - } const client = new Anvil(clientOptions) - const fileStream = await Anvil.addStream(pathToFile) - const base64File = fs.readFileSync(pathToFile, { encoding: 'base64' }) + // Stream example. Can also use prepareBuffer for Buffers + const streamFile = Anvil.prepareStream(pathToFile) + + // Base64 data example. Filename and mimetype are required with a Base64 upload. + const base64Data = fs.readFileSync(pathToFile, { encoding: 'base64' }) + const base64File = Anvil.prepareBase64(base64Data, { filename: fileName, mimetype: 'application/pdf' }) + const variables = { organizationEid: orgEid, send: false, @@ -63,7 +58,7 @@ async function main () { { id: 'fileUpload', title: 'Important PDF One', - file: fileStream, + file: streamFile, fields: [ { aliasId: 'aDateField', @@ -92,11 +87,7 @@ async function main () { { id: 'base64upload', title: 'Important PDF 2', - base64File: { - filename: fileName, - mimetype: 'application/pdf', - data: base64File, - }, + base64File: base64File, fields: [ { aliasId: 'anotherSignatureField', @@ -118,6 +109,7 @@ async function main () { ], } + // Show this to the world? const responseQuery = `{ id eid @@ -135,8 +127,11 @@ async function main () { }` const { statusCode, data, errors } = await client.createEtchPacket({ variables, responseQuery }) - - console.log(statusCode, JSON.stringify(errors || data, null, 2)) + console.log({ + statusCode, + data, + errors, + }) } main() diff --git a/package.json b/package.json index 820fb65..604716d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "extract-files": "^6", "form-data": "^3.0.0", "limiter": "^1.1.5", + "lodash.get": "^4.4.2", "mime-types": "^2.1.27", "node-fetch": "^2.6.0" }, diff --git a/src/index.js b/src/index.js index b8c78d1..a7bb8d1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,10 @@ const fs = require('fs') const path = require('path') +const get = require('lodash.get') const fetch = require('node-fetch') const FormData = require('form-data') -const mime = require('mime-types') +const Mime = require('mime-types') const { extractFiles } = require('extract-files') const { RateLimiter } = require('limiter') @@ -61,18 +62,37 @@ class Anvil { this.limiter = new RateLimiter(this.requestLimit, this.requestLimitMS, true) } - static addStream (pathOrStream) { + static prepareStream (pathOrStream, options) { if (typeof pathOrStream === 'string') { pathOrStream = fs.createReadStream(pathOrStream) } - return this._prepareFile(pathOrStream) + + return this._prepareStreamOrBuffer(pathOrStream, options) } - static addBuffer (pathOrBuffer) { + static prepareBuffer (pathOrBuffer, options) { if (typeof pathOrBuffer === 'string') { pathOrBuffer = fs.readFileSync(pathOrBuffer) } - return this._prepareFile(pathOrBuffer) + + return this._prepareStreamOrBuffer(pathOrBuffer, options) + } + + static prepareBase64 (data, options = {}) { + const { filename, mimetype } = options + if (!filename) { + throw new Error('options.filename must be provided for Base64 upload') + } + if (!mimetype) { + throw new Error('options.mimetype must be provided for Base64 upload') + } + + if (options.bufferize) { + const buffer = Buffer.from(data, 'base64') + return this.addBuffer(buffer, options) + } + + return this._prepareBase64(data, options) } fillPDF (pdfTemplateID, payload, clientOptions = {}) { @@ -89,7 +109,6 @@ class Anvil { body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json', - Authorization: this.authHeader, }, }, { @@ -107,7 +126,7 @@ class Anvil { query: getCreateEtchPacketMutation(responseQuery), variables, }, - { dataType: 'json' }, + { dataType: DATA_TYPE_JSON }, ) } @@ -120,10 +139,7 @@ class Anvil { const options = { method: 'POST', - headers: { - // FIXME: How does /graphql auth work? - Cookie: this.options.cookie, - }, + headers: {}, } const originalOperation = { query, variables } @@ -135,11 +151,12 @@ class Anvil { const operationJSON = JSON.stringify(augmentedOperation) - if (filesMap.size) { - if (!graphQLUploadSchemaIsValid(originalOperation)) { - throw new Error('Invalid File schema detected') - } + // Checks for both File uploads and Base64 uploads + if (!graphQLUploadSchemaIsValid(originalOperation)) { + throw new Error('Invalid File schema detected') + } + if (filesMap.size) { const form = new FormData() form.append('operations', operationJSON) @@ -153,7 +170,10 @@ class Anvil { i = 0 filesMap.forEach((paths, file) => { - form.append(`${++i}`, file) + // Pass in some things explicitly to the form.append so that we get the + // desired/expected filename and mimetype, etc + const appendOptions = extractFormAppendOptions({ paths, object: originalOperation }) + form.append(`${++i}`, file, appendOptions) }) options.body = form @@ -262,8 +282,18 @@ class Anvil { return this.options.baseURL + path } - _addHeaders ({ options: existingOptions, headers: newHeaders }) { + _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: { @@ -275,12 +305,16 @@ class Anvil { _addDefaultHeaders (options) { const { userAgent } = this.options - return this._addHeaders({ - options, - headers: { - 'User-Agent': userAgent, + return this._addHeaders( + { + options, + headers: { + 'User-Agent': userAgent, + Authorization: this.authHeader, + }, }, - }) + { defaults: true }, + ) } _throttle (fn) { @@ -303,16 +337,32 @@ class Anvil { }) } - static _prepareFile (streamOrBuffer) { - const fileName = this._getFilename(streamOrBuffer) - const mimeType = this._getMimetype(streamOrBuffer) + static _prepareStreamOrBuffer (streamOrBuffer, options) { + const filename = this._getFilename(streamOrBuffer, options) + const mimetype = this._getMimetype(streamOrBuffer, options) return { - name: fileName, - mimetype: mimeType, + name: filename, + mimetype, file: streamOrBuffer, } } + static _prepareBase64 (data, options = {}) { + const { filename, mimetype } = options + if (!filename) { + throw new Error('options.filename must be provided for Base64 upload') + } + if (!mimetype) { + throw new Error('options.mimetype must be provided for Base64 upload') + } + + return { + data, + filename, + mimetype, + } + } + static _getFilename (thing, options = {}) { // Very heavily influenced by: // https://github.com/form-data/form-data/blob/55d90ce4a4c22b0ea0647991d85cb946dfb7395b/lib/form_data.js#L217 @@ -320,15 +370,19 @@ class Anvil { if (typeof options.filepath === 'string') { // custom filepath for relative paths return path.normalize(options.filepath).replace(/\\/g, '/') - } else if (options.filename || thing.name || thing.path) { + } + if (options.filename || thing.name || thing.path) { // custom filename take precedence // formidable and the browser add a name property // fs- and request- streams have path property return path.basename(options.filename || thing.name || thing.path) - } else if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { + } + if (thing.readable && Object.prototype.hasOwnProperty.call(thing, 'httpVersion')) { // or try http response return path.basename(thing.client._httpMessage.path || '') } + + throw new Error('Unable to determine file name for this upload. Please pass it via options.filename.') } static _getMimetype (thing, options = {}) { @@ -336,18 +390,13 @@ class Anvil { // https://github.com/form-data/form-data/blob/55d90ce4a4c22b0ea0647991d85cb946dfb7395b/lib/form_data.js#L243 // use custom content-type above all - if (typeof options.mimeType === 'string') { - return options.mimeType + if (typeof options.mimetype === 'string') { + return options.mimetype } // or try `name` from formidable, browser if (thing.name || thing.path) { - return mime.lookup(thing.name || thing.path) - } - - // or try `path` from fs-, request- streams - if (thing.path) { - mime.lookup(thing.path) + return Mime.lookup(thing.name || thing.path) } // or if it's http-reponse @@ -357,13 +406,10 @@ class Anvil { // or guess it from the filepath or filename if ((options.filepath || options.filename)) { - mime.lookup(options.filepath || options.filename) + Mime.lookup(options.filepath || options.filename) } - // fallback to the default content type if `value` is not simple value - if (typeof thing === 'object') { - return 'application/octet-stream' - } + throw new Error('Unable to determine mime type for this upload. Please pass it via options.mimetype.') } } @@ -377,4 +423,23 @@ function sleep (ms) { }) } +function extractFormAppendOptions ({ paths, object }) { + const length = paths.length + if (length !== 1) { + if (length === 0) { + throw new Error('No file map paths received') + } + console.warn(`WARNING: received ${length} file map paths. Expected exactly 1.`) + } + const path = paths[0].split('.') + path.pop() + + const parent = get(object, path.join('.')) + + return { + filename: parent.name, + contentType: parent.mimetype, + } +} + module.exports = Anvil diff --git a/src/validation.js b/src/validation.js index e3215f6..805c3d0 100644 --- a/src/validation.js +++ b/src/validation.js @@ -5,28 +5,51 @@ function isFile (value) { return value instanceof fs.ReadStream || value instanceof Buffer } -function graphQLUploadSchemaIsValid (schema, parent) { - if (schema instanceof Array) { - return schema.every((subSchema) => graphQLUploadSchemaIsValid(subSchema, schema)) +function graphQLUploadSchemaIsValid (schema, parent, key) { + if (typeof schema === 'undefined') { + return true } - if (schema.constructor.name === 'Object') { - return Object.entries(schema).every(([_key, subSchema]) => graphQLUploadSchemaIsValid(subSchema, schema)) - } + // Not a great/easy/worthwhile way to determine if a string is base64-encoded data, + // so our best proxy is to check the keyname + if (key !== 'base64File') { + if (schema instanceof Array) { + return schema.every((subSchema) => graphQLUploadSchemaIsValid(subSchema, schema)) + } - if (!isFile(schema)) { - return true + if (schema.constructor.name === 'Object') { + return Object.entries(schema).every(([key, subSchema]) => graphQLUploadSchemaIsValid(subSchema, schema, key)) + } + + if (!isFile(schema)) { + return true + } } + // All flavors should be nested, and not top-level if (!parent) { return false } - if (parent.file !== schema) { - return false + // File Upload + if (key === 'file') { + if (parent.file !== schema) { + return false + } + + return ['name', 'mimetype'].every((requiredKey) => parent[requiredKey]) + } + + // Base64 Upload + if (key === 'base64File') { + if (parent.base64File !== schema) { + return false + } + + return ['filename', 'mimetype'].every((requiredKey) => schema[requiredKey]) } - return ['name', 'mimetype'].every((requiredKey) => parent[requiredKey]) + return false } module.exports = { diff --git a/yarn.lock b/yarn.lock index ad80ebc..0982a97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1493,7 +1493,7 @@ locate-path@^5.0.0: lodash.get@^4.4.2: version "4.4.2" - resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= lodash.uniqby@^4.7.0: From ae0372eb01739cd680a0833b25a7a895e455f4b8 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 14:18:49 -0400 Subject: [PATCH 09/18] touch up --- example/script/create-etch-packet.js | 6 +++--- src/index.js | 16 ++++++++-------- src/validation.js | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/example/script/create-etch-packet.js b/example/script/create-etch-packet.js index 3067255..67eb313 100644 --- a/example/script/create-etch-packet.js +++ b/example/script/create-etch-packet.js @@ -15,12 +15,12 @@ async function main () { const client = new Anvil(clientOptions) - // Stream example. Can also use prepareBuffer for Buffers - const streamFile = Anvil.prepareStream(pathToFile) + // Stream example. Can also use prepareGraphQLBuffer for Buffers + const streamFile = Anvil.prepareGraphQLStream(pathToFile) // Base64 data example. Filename and mimetype are required with a Base64 upload. const base64Data = fs.readFileSync(pathToFile, { encoding: 'base64' }) - const base64File = Anvil.prepareBase64(base64Data, { filename: fileName, mimetype: 'application/pdf' }) + const base64File = Anvil.prepareGraphQLBase64(base64Data, { filename: fileName, mimetype: 'application/pdf' }) const variables = { organizationEid: orgEid, diff --git a/src/index.js b/src/index.js index a7bb8d1..8e79de9 100644 --- a/src/index.js +++ b/src/index.js @@ -62,23 +62,23 @@ class Anvil { this.limiter = new RateLimiter(this.requestLimit, this.requestLimitMS, true) } - static prepareStream (pathOrStream, options) { + static prepareGraphQLStream (pathOrStream, options) { if (typeof pathOrStream === 'string') { pathOrStream = fs.createReadStream(pathOrStream) } - return this._prepareStreamOrBuffer(pathOrStream, options) + return this._prepareGraphQLStreamOrBuffer(pathOrStream, options) } - static prepareBuffer (pathOrBuffer, options) { + static prepareGraphQLBuffer (pathOrBuffer, options) { if (typeof pathOrBuffer === 'string') { pathOrBuffer = fs.readFileSync(pathOrBuffer) } - return this._prepareStreamOrBuffer(pathOrBuffer, options) + return this._prepareGraphQLStreamOrBuffer(pathOrBuffer, options) } - static prepareBase64 (data, options = {}) { + static prepareGraphQLBase64 (data, options = {}) { const { filename, mimetype } = options if (!filename) { throw new Error('options.filename must be provided for Base64 upload') @@ -92,7 +92,7 @@ class Anvil { return this.addBuffer(buffer, options) } - return this._prepareBase64(data, options) + return this._prepareGraphQLBase64(data, options) } fillPDF (pdfTemplateID, payload, clientOptions = {}) { @@ -337,7 +337,7 @@ class Anvil { }) } - static _prepareStreamOrBuffer (streamOrBuffer, options) { + static _prepareGraphQLStreamOrBuffer (streamOrBuffer, options) { const filename = this._getFilename(streamOrBuffer, options) const mimetype = this._getMimetype(streamOrBuffer, options) return { @@ -347,7 +347,7 @@ class Anvil { } } - static _prepareBase64 (data, options = {}) { + static _prepareGraphQLBase64 (data, options = {}) { const { filename, mimetype } = options if (!filename) { throw new Error('options.filename must be provided for Base64 upload') diff --git a/src/validation.js b/src/validation.js index 805c3d0..8ba2206 100644 --- a/src/validation.js +++ b/src/validation.js @@ -10,7 +10,7 @@ function graphQLUploadSchemaIsValid (schema, parent, key) { return true } - // Not a great/easy/worthwhile way to determine if a string is base64-encoded data, + // There is not a great/easy/worthwhile way to determine if a string is base64-encoded data, // so our best proxy is to check the keyname if (key !== 'base64File') { if (schema instanceof Array) { From 3d6b099e3f13b37ccd7a82a712ec1949ed6a3eba Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 14:20:35 -0400 Subject: [PATCH 10/18] touched up README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e23f210..65990a6 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Our API has request rate limits in place. This API client handles `429 Too Many See the [Anvil API docs](https://useanvil.com/api/fill-pdf) for more information on the specifics of the rate limits. -### API Documentation +## API Documentation Our general API Documentation can be found [here](https://www.useanvil.com/api/). It's the best resource for up-to-date information about our API and its capabilities. From e5ccbd4bdfd18de7b673888db3bd080d0f0e9e63 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 14:33:47 -0400 Subject: [PATCH 11/18] added abort signal --- package.json | 1 + src/index.js | 11 +++++++++++ yarn.lock | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/package.json b/package.json index 604716d..2822346 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "yargs": "^15.1.0" }, "dependencies": { + "abort-controller": "^3.0.0", "extract-files": "^6", "form-data": "^3.0.0", "limiter": "^1.1.5", diff --git a/src/index.js b/src/index.js index 8e79de9..051386a 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ const get = require('lodash.get') const fetch = require('node-fetch') const FormData = require('form-data') const Mime = require('mime-types') +const AbortController = require('abort-controller') const { extractFiles } = require('extract-files') const { RateLimiter } = require('limiter') @@ -157,6 +158,7 @@ class Anvil { } if (filesMap.size) { + const abortController = new AbortController() const form = new FormData() form.append('operations', operationJSON) @@ -170,12 +172,21 @@ class Anvil { i = 0 filesMap.forEach((paths, file) => { + // If this is a Stream, will attach a listener to the 'error' event so that we + // can cancel the API call if something goes wrong + if (file instanceof fs.ReadStream) { + file.on('error', (err) => { + console.warn(err) + abortController.abort() + }) + } // Pass in some things explicitly to the form.append so that we get the // desired/expected filename and mimetype, etc const appendOptions = extractFormAppendOptions({ paths, object: originalOperation }) form.append(`${++i}`, file, appendOptions) }) + options.signal = abortController.signal options.body = form } else { options.headers['Content-Type'] = 'application/json' diff --git a/yarn.lock b/yarn.lock index 0982a97..e4a5112 100644 --- a/yarn.lock +++ b/yarn.lock @@ -135,6 +135,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-jsx@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" @@ -910,6 +917,11 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" From d79f1ca0db3ce94950d3b4d9aa2f41e4be02f8d7 Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 14:50:09 -0400 Subject: [PATCH 12/18] just The Way --- README.md | 18 ++------ example/script/create-etch-packet.js | 40 ++++------------ src/index.js | 68 +++++++++++++--------------- 3 files changed, 45 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 65990a6..60e8cb9 100644 --- a/README.md +++ b/README.md @@ -114,24 +114,12 @@ Creates an Etch Packet and optionally sends it to the first signer. See the [API ### Class Methods -##### prepareGraphQLStream(pathOrStream[, options]) -A nice helper to prepare a Stream-backed file upload for use with our GraphQL API. -* `pathOrStream` (Stream | String) - Either an existing `Stream` or a string representing a fully resolved path to a file to be read into a new `Stream`. +##### prepareGraphQLFile(pathOrStreamOrBuffer[, options]) +A nice helper to prepare a Stream-backed or Buffer-backed file upload for use with our GraphQL API. +* `pathOrStream` (String | Stream | Buffer) - An existing `Stream` OR an existing `Buffer` OR a string representing a fully resolved path to a file to be read into a new `Stream`. * `options` (Object) - [UploadOptions](#uploadoptions) for the resulting object. * Returns an `Object` that is properly formatted to be coerced by the client for use against our GraphQL API wherever an `Upload` type is required. -##### prepareGraphQLBuffer(pathOrBuffer[, options]) -A nice helper to prepare a Buffer-backed file upload for use with our GraphQL API. -* `pathOrBuffer` (Buffer | String) - Either an existing `Buffer` or a string representing a fully resolved path to a file to be read into a new `Buffer`. -* `options` (Object) - [UploadOptions](#uploadoptions) for the resulting object. -* Returns an `Object` that is properly formatted to be coerced by the client for use against our GraphQL API wherever an `Upload` type is required. - -##### prepareGraphQLBase64(data, options) -A nice helper to prepare a Base64-encoded-string-backed upload for use with our GraphQL API. -* `data` (String) - A `base64`-encoded string. -* `options` (Object) - [UploadOptions](#uploadoptions) for the resulting object. Also supports a `bufferize (Boolean)` option - set to `true` to convert the data to a `Buffer` and then call `prepareGraphQLBuffer`. -* Returns an `Object` that is properly formatted to be coerced by the client for use against our GraphQL API wherever a `Base64Upload` type is required. - ### Types ##### Options diff --git a/example/script/create-etch-packet.js b/example/script/create-etch-packet.js index 67eb313..6e5fb0a 100644 --- a/example/script/create-etch-packet.js +++ b/example/script/create-etch-packet.js @@ -1,4 +1,3 @@ -const fs = require('fs') const path = require('path') const Anvil = require('../../src/index') const argv = require('yargs') @@ -15,12 +14,9 @@ async function main () { const client = new Anvil(clientOptions) - // Stream example. Can also use prepareGraphQLBuffer for Buffers - const streamFile = Anvil.prepareGraphQLStream(pathToFile) - - // Base64 data example. Filename and mimetype are required with a Base64 upload. - const base64Data = fs.readFileSync(pathToFile, { encoding: 'base64' }) - const base64File = Anvil.prepareGraphQLBase64(base64Data, { filename: fileName, mimetype: 'application/pdf' }) + // Example where pathToFile will be used to create a new Stream. Can also + // pass an existing Stream or Buffer + const streamFile = Anvil.prepareGraphQLFile(pathToFile) const variables = { organizationEid: orgEid, @@ -84,24 +80,6 @@ async function main () { }, ], }, - { - id: 'base64upload', - title: 'Important PDF 2', - base64File: base64File, - fields: [ - { - aliasId: 'anotherSignatureField', - type: 'signature', - pageNum: 1, - rect: { - x: 203.88, - y: 171.66, - width: 33.94, - height: 27.60, - }, - }, - ], - }, { id: 'preExistingCastReference', castEid: castEid, @@ -127,11 +105,13 @@ async function main () { }` const { statusCode, data, errors } = await client.createEtchPacket({ variables, responseQuery }) - console.log({ - statusCode, - data, - errors, - }) + console.log( + JSON.stringify({ + statusCode, + data, + errors, + }), + ) } main() diff --git a/src/index.js b/src/index.js index 051386a..49a7d6a 100644 --- a/src/index.js +++ b/src/index.js @@ -63,37 +63,12 @@ class Anvil { this.limiter = new RateLimiter(this.requestLimit, this.requestLimitMS, true) } - static prepareGraphQLStream (pathOrStream, options) { - if (typeof pathOrStream === 'string') { - pathOrStream = fs.createReadStream(pathOrStream) - } - - return this._prepareGraphQLStreamOrBuffer(pathOrStream, options) - } - - static prepareGraphQLBuffer (pathOrBuffer, options) { - if (typeof pathOrBuffer === 'string') { - pathOrBuffer = fs.readFileSync(pathOrBuffer) - } - - return this._prepareGraphQLStreamOrBuffer(pathOrBuffer, options) - } - - static prepareGraphQLBase64 (data, options = {}) { - const { filename, mimetype } = options - if (!filename) { - throw new Error('options.filename must be provided for Base64 upload') - } - if (!mimetype) { - throw new Error('options.mimetype must be provided for Base64 upload') - } - - if (options.bufferize) { - const buffer = Buffer.from(data, 'base64') - return this.addBuffer(buffer, options) + static prepareGraphQLFile (pathOrStreamOrBuffer, options) { + if (pathOrStreamOrBuffer instanceof Buffer) { + return this._prepareGraphQLBuffer(pathOrStreamOrBuffer, options) } - return this._prepareGraphQLBase64(data, options) + return this._prepareGraphQLStream(pathOrStreamOrBuffer, options) } fillPDF (pdfTemplateID, payload, clientOptions = {}) { @@ -348,14 +323,20 @@ class Anvil { }) } - static _prepareGraphQLStreamOrBuffer (streamOrBuffer, options) { - const filename = this._getFilename(streamOrBuffer, options) - const mimetype = this._getMimetype(streamOrBuffer, options) - return { - name: filename, - mimetype, - file: streamOrBuffer, + static _prepareGraphQLStream (pathOrStream, options) { + if (typeof pathOrStream === 'string') { + pathOrStream = fs.createReadStream(pathOrStream) + } + + return this._prepareGraphQLStreamOrBuffer(pathOrStream, options) + } + + static _prepareGraphQLBuffer (pathOrBuffer, options) { + if (typeof pathOrBuffer === 'string') { + pathOrBuffer = fs.readFileSync(pathOrBuffer) } + + return this._prepareGraphQLStreamOrBuffer(pathOrBuffer, options) } static _prepareGraphQLBase64 (data, options = {}) { @@ -367,6 +348,11 @@ class Anvil { throw new Error('options.mimetype must be provided for Base64 upload') } + if (options.bufferize) { + const buffer = Buffer.from(data, 'base64') + return this._prepareGraphQLBuffer(buffer, options) + } + return { data, filename, @@ -374,6 +360,16 @@ class Anvil { } } + static _prepareGraphQLStreamOrBuffer (streamOrBuffer, options) { + const filename = this._getFilename(streamOrBuffer, options) + const mimetype = this._getMimetype(streamOrBuffer, options) + return { + name: filename, + mimetype, + file: streamOrBuffer, + } + } + static _getFilename (thing, options = {}) { // Very heavily influenced by: // https://github.com/form-data/form-data/blob/55d90ce4a4c22b0ea0647991d85cb946dfb7395b/lib/form_data.js#L217 From e948a8c292b2ccdcc9da0ae00043e7d774f6f93b Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 14:57:51 -0400 Subject: [PATCH 13/18] updated docs --- README.md | 7 +++++-- src/index.js | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 60e8cb9..6ffb870 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,12 @@ const { statusCode, data } = await anvilClient.fillPDF(pdfTemplateID, payload, o * `errors` (Array of Objects) - Will be present if status >= 400. See Errors * `message` (String) -##### createEtchPacket(variables[, responseQuery]) +##### createEtchPacket(options) -Creates an Etch Packet and optionally sends it to the first signer. See the [API Documentation](#api-documentation) area for details. See [Examples](#examples) area for examples. +Creates an Etch Packet and optionally sends it to the first signer. +* `options` (Object) - An object with the following structure: + * `variables` (Object) - See the [API Documentation](#api-documentation) area for details. See [Examples](#examples) area for examples. + * `responseQuery` (String) - _optional_ A GraphQL Query compliant query to use for the data desired in the mutation response. Can be left out to use default. ### Class Methods diff --git a/src/index.js b/src/index.js index 49a7d6a..8518b70 100644 --- a/src/index.js +++ b/src/index.js @@ -94,8 +94,6 @@ class Anvil { ) } - // QUESTION: maybe we want to keeep responseQuery to ourselves while we figure out how we want it to - // feel to the Users? createEtchPacket ({ variables, responseQuery }) { return this.requestGraphQL( { From cfc596eb14cb4bf3f114dfa379a6ba6e4d09c01c Mon Sep 17 00:00:00 2001 From: newhouse Date: Thu, 27 Aug 2020 15:08:35 -0400 Subject: [PATCH 14/18] removed stuff don't want them to see --- example/script/create-etch-packet.js | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/example/script/create-etch-packet.js b/example/script/create-etch-packet.js index 6e5fb0a..fe38d09 100644 --- a/example/script/create-etch-packet.js +++ b/example/script/create-etch-packet.js @@ -87,24 +87,7 @@ async function main () { ], } - // Show this to the world? - const responseQuery = `{ - id - eid - payload - etchTemplate { - id - eid - config - casts { - id - eid - config - } - } - }` - - const { statusCode, data, errors } = await client.createEtchPacket({ variables, responseQuery }) + const { statusCode, data, errors } = await client.createEtchPacket({ variables }) console.log( JSON.stringify({ statusCode, From 1e0f92664a8ae96dfa9595099f9fc284f871dbf1 Mon Sep 17 00:00:00 2001 From: Ben Ogle Date: Tue, 1 Sep 2020 15:06:52 -0700 Subject: [PATCH 15/18] Fix connections to signature fields --- example/script/create-etch-packet.js | 84 +++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/example/script/create-etch-packet.js b/example/script/create-etch-packet.js index fe38d09..ecc987c 100644 --- a/example/script/create-etch-packet.js +++ b/example/script/create-etch-packet.js @@ -22,6 +22,7 @@ async function main () { organizationEid: orgEid, send: false, isTest: true, + signatureEmailSubject: 'Test Create Packet', signers: [ { id: 'signerOne', @@ -29,11 +30,11 @@ async function main () { email: 'sally@example.com', fields: [ { - fileId: 'fileOne', + fileId: 'fileUpload', fieldId: 'aDateField', }, { - fileId: 'fileOne', + fileId: 'fileUpload', fieldId: 'aSignatureField', }, ], @@ -44,12 +45,44 @@ async function main () { email: 'scotty@example.com', fields: [ { - fileId: 'base64upload', + fileId: 'fileUpload', fieldId: 'anotherSignatureField', }, + { + fileId: 'preExistingCastReference', + fieldId: 'signature1', + }, + { + fileId: 'preExistingCastReference', + fieldId: 'signatureDate1', + }, ], }, ], + fillPayload: { + payloads: { + fileUpload: { + textColor: '#CC0000', + data: { + myShortText: 'Something Filled', + }, + }, + preExistingCastReference: { + textColor: '#00CC00', + data: { + name: { + firstName: 'Robin', + lastName: 'Smith', + }, + dateOfBirth: '2020-09-01', + socialSecurityNumber: '456454567', + primaryPhone: { + num: '5554443333', + }, + }, + }, + }, + }, files: [ { id: 'fileUpload', @@ -57,25 +90,50 @@ async function main () { file: streamFile, fields: [ { - aliasId: 'aDateField', + id: 'myShortText', + type: 'shortText', + pageNum: 0, + rect: { + x: 20, + y: 100, + width: 100, + height: 30, + }, + }, + { + id: 'aDateField', type: 'signatureDate', pageNum: 1, + name: 'Some Date', + rect: { + x: 200, + y: 170, + width: 100, + height: 30, + }, + }, + { + id: 'aSignatureField', + type: 'signature', + name: 'Some Sig', + pageNum: 1, rect: { - x: 203.88, - y: 171.66, - width: 33.94, - height: 27.60, + x: 200, + y: 120, + width: 100, + height: 30, }, }, { - aliasId: 'aSignatureField', + id: 'anotherSignatureField', type: 'signature', + name: 'Another Sig', pageNum: 1, rect: { - x: 203.88, - y: 121.66, - width: 33.94, - height: 27.60, + x: 200, + y: 400, + width: 100, + height: 30, }, }, ], From 5e9f52da3e5d380f308d4bc05b385070670c5e64 Mon Sep 17 00:00:00 2001 From: newhouse Date: Tue, 8 Sep 2020 12:12:35 -0400 Subject: [PATCH 16/18] tests fixed. --- package.json | 3 +- test/index.test.js | 138 +++++++++++++++++++++++---------------------- yarn.lock | 5 ++ 3 files changed, 79 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index 2822346..7eac2a4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Anvil API Client", "main": "src/index.js", "scripts": { - "test": "mocha --require test/environment.js 'test/**/*.test.js'", + "test": "mocha --config ./test/mocha.js", "test:watch": "nodemon --signal SIGINT --watch test --watch src -x 'yarn test'", "version": "auto-changelog -p --template keepachangelog && git add CHANGELOG.md" }, @@ -30,6 +30,7 @@ "devDependencies": { "auto-changelog": "^1.16.2", "babel-eslint": "^10.0.3", + "bdd-lazy-var": "^2.5.4", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", "eslint": "^6.8.0", diff --git a/test/index.test.js b/test/index.test.js index d42bfbc..bbf8800 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -57,7 +57,7 @@ describe('Anvil API Client', function () { beforeEach(async function () { client = new Anvil({ apiKey: 'abc123' }) - sinon.stub(client, 'request') + sinon.stub(client, '_request') }) describe('requestREST', function () { @@ -72,7 +72,7 @@ describe('Anvil API Client', function () { } data = { result: 'ok' } - client.request.callsFake((url, options) => { + client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 200, @@ -81,16 +81,16 @@ describe('Anvil API Client', function () { ) }) const result = await client.requestREST('/test', options, clientOptions) - expect(result).to.eql({ - statusCode: 200, - data, - }) + + expect(client._request).to.have.been.calledOnce + expect(result.statusCode).to.eql(200) + expect(result.data).to.eql(data) }) it('rejects promise when error', async function () { options = { method: 'POST' } - client.request.callsFake((url, options) => { + client._request.callsFake((url, options) => { throw new Error('problem') }) @@ -101,9 +101,8 @@ describe('Anvil API Client', function () { options = { method: 'POST' } clientOptions = { dataType: 'json' } data = { result: 'ok' } - const successResponse = { statusCode: 200 } - client.request.onCall(0).callsFake((url, options) => { + client._request.onCall(0).callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 429, @@ -114,7 +113,7 @@ describe('Anvil API Client', function () { ) }) - client.request.onCall(1).callsFake((url, options) => { + client._request.onCall(1).callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 200, @@ -122,86 +121,93 @@ describe('Anvil API Client', function () { }), ) }) + result = await client.requestREST('/test', options, clientOptions) - expect(client.request).to.have.been.calledTwice - expect(result).to.eql({ - statusCode: successResponse.statusCode, - data, - }) + + expect(client._request).to.have.been.calledTwice + expect(result.statusCode).to.eql(200) + expect(result.data).to.eql(data) }) }) describe('fillPDF', function () { - let response, data, result, payload + def('statusCode', 200) beforeEach(async function () { - response = { statusCode: 200 } - data = 'The PDF file' - client.request.callsFake((url, options) => { + client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ - status: response.statusCode, - buffer: data, - json: data, + status: $.statusCode, + buffer: $.buffer, + json: $.json, }), ) }) }) - it('returns statusCode and data when specified', async function () { - payload = { - title: 'Test', - fontSize: 8, - textColor: '#CC0000', - data: { - helloId: 'hello!', - }, - } - - result = await client.fillPDF('cast123', payload) - expect(result).to.eql({ - statusCode: response.statusCode, - data, - }) - - expect(client.request).to.have.been.calledOnce - const [url, options] = client.request.lastCall.args - expect(url).to.eql('/api/v1/fill/cast123.pdf') - expect(options).to.eql({ - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - Authorization: client.authHeader, - }, + context('everything goes well', function () { + def('buffer', 'This would be PDF data...') + + it('returns data', async function () { + const payload = { + title: 'Test', + fontSize: 8, + textColor: '#CC0000', + data: { + helloId: 'hello!', + }, + } + + const result = await client.fillPDF('cast123', payload) + + expect(result.statusCode).to.eql(200) + expect(result.data).to.eql('This would be PDF data...') + + expect(client._request).to.have.been.calledOnce + + const [url, options] = client._request.lastCall.args + expect(url).to.eql('/api/v1/fill/cast123.pdf') + expect(options).to.eql({ + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + }, + }) }) }) - it('returns errors when not status code 200', async function () { - response = { statusCode: 400 } - data = { errors: [{ message: 'problem' }] } - payload = {} + context('server 400s with errors array in JSON', function () { + const errors = [{ message: 'problem' }] + def('statusCode', 400) + def('json', { errors }) + + it('finds errors and puts them in response', async function () { + const result = await client.fillPDF('cast123', {}) - result = await client.fillPDF('cast123', payload) - expect(result).to.eql({ - statusCode: response.statusCode, - errors: data.errors, + expect(client._request).to.have.been.calledOnce + expect(result.statusCode).to.eql(400) + expect(result.errors).to.eql(errors) }) - expect(client.request).to.have.been.calledOnce }) - it('returns errors when not status code 200 and single error', async function () { - response = { statusCode: 401 } - data = { name: 'AuthorizationError', message: 'problem' } - payload = {} + context('server 401s with single error in response', function () { + const error = { name: 'AuthorizationError', message: 'problem' } + def('statusCode', 401) + def('json', error) - result = await client.fillPDF('cast123', payload) - expect(result).to.eql({ - statusCode: response.statusCode, - errors: [data], + it('finds error and puts it in the response', async function () { + const result = await client.fillPDF('cast123', {}) + + expect(client._request).to.have.been.calledOnce + expect(result.statusCode).to.eql(401) + expect(result.errors).to.eql([error]) }) - expect(client.request).to.have.been.calledOnce }) }) }) + + describe('GraphQL', function () { + + }) }) diff --git a/yarn.lock b/yarn.lock index e4a5112..358a407 100644 --- a/yarn.lock +++ b/yarn.lock @@ -289,6 +289,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +bdd-lazy-var@^2.5.4: + version "2.5.4" + resolved "https://registry.yarnpkg.com/bdd-lazy-var/-/bdd-lazy-var-2.5.4.tgz#1b4cafd7c7f15b1f99087a18619610ef81138349" + integrity sha512-U7lk5UWkAVnvfG7y578byFatVwFEugwD7Y1mHIih/7L1QkhTkEAwBvXER0SftI4UxTWZnNF0dA9mDJ4tycTZpg== + binary-extensions@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" From 49d14b208e1b09deaeab57495be98655c333e2f3 Mon Sep 17 00:00:00 2001 From: newhouse Date: Tue, 8 Sep 2020 12:13:10 -0400 Subject: [PATCH 17/18] added bdd and other paterns we follow in web app --- test/.eslintrc.js | 30 ++++++++++++++++++++++++++++++ test/mocha.js | 18 ++++++++++++++++++ test/setup.js | 5 +++++ 3 files changed, 53 insertions(+) create mode 100644 test/.eslintrc.js create mode 100644 test/mocha.js create mode 100644 test/setup.js diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 0000000..ba2619f --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + extends: '../.eslintrc.js', + env: { + mocha: true + }, + globals: { + expect: 'readonly', + should: 'readonly', + sinon : 'readonly', + mount : 'readonly', + render : 'readonly', + shallow : 'readonly', + //************************************************* + // bdd-lazy-var + // + // In order to get around eslint complaining for now: + // https://github.com/stalniy/bdd-lazy-var/issues/56#issuecomment-639248242 + $: 'readonly', + its: 'readonly', + def: 'readonly', + subject: 'readonly', + get: 'readonly', + sharedExamplesFor: 'readonly', + includeExamplesFor: 'readonly', + itBehavesLike: 'readonly', + is: 'readonly' + // + //************************************************* + } +} diff --git a/test/mocha.js b/test/mocha.js new file mode 100644 index 0000000..82521cd --- /dev/null +++ b/test/mocha.js @@ -0,0 +1,18 @@ +'use strict' + +module.exports = { + diff: true, + delay: false, + extension: ['js'], + package: './package.json', + reporter: 'spec', + slow: 75, + timeout: 2000, + spec: './test/**/*.test.js', + require: [ + './test/environment.js', + ], + file: './test/setup.js', + ui: 'bdd-lazy-var/getter', + exit: true, +} diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..c223a79 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,5 @@ +const { get } = require('bdd-lazy-var/getter') + +// In order to get around eslint complaining for now: +// https://github.com/stalniy/bdd-lazy-var/issues/56#issuecomment-639248242 +global.$ = get From 690bcd9ff91a196c87f6593388b871aa36138dae Mon Sep 17 00:00:00 2001 From: newhouse Date: Tue, 8 Sep 2020 15:04:28 -0400 Subject: [PATCH 18/18] added tests --- src/graphql/mutations/createEtchPacket.js | 10 +- test/index.test.js | 163 ++++++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/src/graphql/mutations/createEtchPacket.js b/src/graphql/mutations/createEtchPacket.js index 39089eb..51b1941 100644 --- a/src/graphql/mutations/createEtchPacket.js +++ b/src/graphql/mutations/createEtchPacket.js @@ -2,14 +2,16 @@ const defaultResponseQuery = `{ id eid - etchTemplate { + name + documentGroup { id eid - config - casts { + files + signers { id eid - config + name + email } } }` diff --git a/test/index.test.js b/test/index.test.js index bbf8800..3dae572 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,5 +1,9 @@ +const FormData = require('form-data') +const AbortSignal = require('abort-controller').AbortSignal const Anvil = require('../src/index') +const validationModule = require('../src/validation') + function mockNodeFetchResponse (options = {}) { const { status, @@ -208,6 +212,165 @@ describe('Anvil API Client', function () { }) describe('GraphQL', function () { + const client = new Anvil({ apiKey: 'abc123' }) + + describe('requestGraphQL', function () { + beforeEach(function () { + sinon.stub(client, '_wrapRequest') + client._wrapRequest.callsFake(async () => ({})) + sinon.stub(client, '_request') + }) + + afterEach(function () { + client._wrapRequest.restore() + client._request.restore() + }) + + describe('without files', function () { + it('stringifies query and variables', function () { + const query = { foo: 'bar' } + const variables = { baz: 'bop' } + const clientOptions = { yo: 'mtvRaps' } + + client.requestGraphQL({ query, variables }, clientOptions) + + expect(client._wrapRequest).to.have.been.calledOnce + + const [fn, clientOptionsReceived] = client._wrapRequest.lastCall.args + expect(clientOptions).to.eql(clientOptionsReceived) + + fn() + + expect(client._request).to.have.been.calledOnce + const [, options] = client._request.lastCall.args + const { + method, + headers, + body, + } = options + + expect(method).to.eql('POST') + expect(headers).to.eql({ 'Content-Type': 'application/json' }) + expect(body).to.eql(JSON.stringify({ query, variables })) + }) + }) + + describe('with files', function () { + beforeEach(function () { + sinon.spy(FormData.prototype, 'append') + }) + + afterEach(function () { + FormData.prototype.append.restore() + }) + + context('schema is good', function () { + const query = { foo: 'bar' } + const variables = { + aNested: { + name: 'aFileName', + mimetype: 'application/pdf', + file: Buffer.from(''), + }, + } + const clientOptions = { yo: 'mtvRaps' } + + it('creates a FormData and appends the files map', function () { + client.requestGraphQL({ query, variables }, clientOptions) + + expect(client._wrapRequest).to.have.been.calledOnce + + const [fn, clientOptionsReceived] = client._wrapRequest.lastCall.args + expect(clientOptions).to.eql(clientOptionsReceived) + + fn() + + expect(client._request).to.have.been.calledOnce + const [, options] = client._request.lastCall.args + + const { + method, + headers, + body, + signal, + } = options + + expect(method).to.eql('POST') + expect(headers).to.eql({}) // node-fetch will add appropriate header + expect(body).to.be.an.instanceof(FormData) + expect(signal).to.be.an.instanceof(AbortSignal) + expect( + FormData.prototype.append.withArgs( + 'map', + JSON.stringify({ 1: ['variables.aNested.file'] }), + ), + ).calledOnce + }) + }) + + context('schema is not good', function () { + it('throws error about the schema', async function () { + const query = { foo: 'bar' } + const variables = { + file: Buffer.from(''), + } + await expect(client.requestGraphQL({ query, variables })).to.eventually.be.rejectedWith('Invalid File schema detected') + }) + }) + }) + }) + + describe('createEtchPacket', function () { + beforeEach(function () { + sinon.stub(client, 'requestGraphQL') + }) + + afterEach(function () { + client.requestGraphQL.restore() + }) + + context('no responseQuery specified', function () { + it('calls requestGraphQL with default responseQuery', function () { + const variables = { foo: 'bar' } + + client.createEtchPacket({ variables }) + + expect(client.requestGraphQL).to.have.been.calledOnce + const [options, clientOptions] = client.requestGraphQL.lastCall.args + + const { + query, + variables: variablesReceived, + } = options + + expect(variables).to.eql(variablesReceived) + expect(query).to.include('documentGroup {') // "documentGroup" is in the default responseQuery + expect(clientOptions).to.eql({ dataType: 'json' }) + }) + }) + + context('responseQuery specified', function () { + it('calls requestGraphQL with overridden responseQuery', function () { + const variables = { foo: 'bar' } + + const responseQuery = 'onlyInATest {}' + + client.createEtchPacket({ variables, responseQuery }) + + expect(client.requestGraphQL).to.have.been.calledOnce + const [options, clientOptions] = client.requestGraphQL.lastCall.args + + const { + query, + variables: variablesReceived, + } = options + + expect(variables).to.eql(variablesReceived) + expect(query).to.include(responseQuery) + expect(clientOptions).to.eql({ dataType: 'json' }) + }) + }) + }) }) })