zapier-platform-core 9.5.0 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapier-platform-core",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "The core SDK for CLI apps in the Zapier Developer Platform.",
5
5
  "repository": "zapier/zapier-platform-core",
6
6
  "homepage": "https://zapier.com/",
@@ -41,16 +41,18 @@
41
41
  "bluebird": "3.5.5",
42
42
  "content-disposition": "0.5.3",
43
43
  "dotenv": "8.1.0",
44
- "form-data": "2.5.0",
44
+ "form-data": "4.0.0",
45
45
  "lodash": "4.17.15",
46
- "node-fetch": "2.6.0",
46
+ "mime-types": "2.1.34",
47
+ "node-fetch": "2.6.6",
47
48
  "oauth-sign": "0.9.0",
48
49
  "semver": "5.6.0",
49
- "zapier-platform-schema": "9.5.0"
50
+ "zapier-platform-schema": "9.6.0"
50
51
  },
51
52
  "devDependencies": {
52
53
  "adm-zip": "0.4.13",
53
54
  "aws-sdk": "2.238.1",
55
+ "dicer": "0.3.0",
54
56
  "fs-extra": "8.1.0",
55
57
  "mock-fs": "4.10.1"
56
58
  },
@@ -1,14 +1,18 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('lodash');
3
+ const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
- const FormData = require('form-data');
6
+ const { pipeline } = require('stream');
7
+ const { promisify } = require('util');
8
+ const { randomBytes } = require('crypto');
9
+
10
+ const _ = require('lodash');
6
11
  const contentDisposition = require('content-disposition');
12
+ const FormData = require('form-data');
13
+ const mime = require('mime-types');
7
14
 
8
15
  const request = require('./request-client-internal');
9
- const ZapierPromise = require('./promise');
10
-
11
- const isPromise = obj => obj && typeof obj.then === 'function';
12
16
 
13
17
  const UPLOAD_MAX_SIZE = 1000 * 1000 * 150; // 150mb, in zapier backend too
14
18
 
@@ -19,33 +23,187 @@ const LENGTH_ERR_MESSAGE =
19
23
  const DEFAULT_FILE_NAME = 'unnamedfile';
20
24
  const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
21
25
 
22
- const uploader = (
26
+ const streamPipeline = promisify(pipeline);
27
+
28
+ const filenameFromURL = (url) => {
29
+ try {
30
+ return decodeURIComponent(path.posix.basename(new URL(url).pathname));
31
+ } catch (error) {
32
+ return null;
33
+ }
34
+ };
35
+
36
+ const filenameFromHeader = (response) => {
37
+ const cd = response.headers.get('content-disposition');
38
+ let filename;
39
+ if (cd) {
40
+ try {
41
+ filename = contentDisposition.parse(cd).parameters.filename;
42
+ } catch (error) {
43
+ return null;
44
+ }
45
+ }
46
+ return filename || null;
47
+ };
48
+
49
+ const resolveRemoteStream = async (stream) => {
50
+ // Download to a temp file, get the file size, and create a readable stream
51
+ // from the temp file.
52
+ //
53
+ // The streamPipeline usage is taken from
54
+ // https://github.com/node-fetch/node-fetch#streams
55
+ const tmpFilePath = path.join(
56
+ os.tmpdir(),
57
+ 'stash-' + randomBytes(16).toString('hex')
58
+ );
59
+
60
+ try {
61
+ await streamPipeline(stream, fs.createWriteStream(tmpFilePath));
62
+ } catch (error) {
63
+ try {
64
+ fs.unlinkSync(tmpFilePath);
65
+ } catch (e) {
66
+ // File doesn't exist? Probably okay
67
+ }
68
+ throw error;
69
+ }
70
+
71
+ const length = fs.statSync(tmpFilePath).size;
72
+ const readStream = fs.createReadStream(tmpFilePath);
73
+
74
+ readStream.on('end', () => {
75
+ // Burn after reading
76
+ try {
77
+ fs.unlinkSync(tmpFilePath);
78
+ } catch (e) {
79
+ // TODO: We probably want to log warning here
80
+ }
81
+ });
82
+
83
+ return {
84
+ streamOrData: readStream,
85
+ length,
86
+ };
87
+ };
88
+
89
+ const resolveResponseToStream = async (response) => {
90
+ // Get filename from content-disposition header or URL
91
+ let filename =
92
+ filenameFromHeader(response) ||
93
+ filenameFromURL(response.url || _.get(response, ['request', 'url'])) ||
94
+ DEFAULT_FILE_NAME;
95
+
96
+ const contentType = response.headers.get('content-type');
97
+ if (contentType && !path.extname(filename)) {
98
+ const ext = mime.extension(contentType);
99
+ if (ext && ext !== 'bin') {
100
+ filename += '.' + ext;
101
+ }
102
+ }
103
+
104
+ if (response.body && typeof response.body.pipe === 'function') {
105
+ // streamable response created by z.request({ raw: true })
106
+ return {
107
+ ...(await resolveRemoteStream(response.body)),
108
+ contentType: contentType || DEFAULT_CONTENT_TYPE,
109
+ filename,
110
+ };
111
+ }
112
+
113
+ // regular response created by z.request({ raw: false })
114
+ return {
115
+ streamOrData: response.content,
116
+ length: Buffer.byteLength(response.content),
117
+ contentType: contentType || DEFAULT_CONTENT_TYPE,
118
+ filename,
119
+ };
120
+ };
121
+
122
+ const resolveStreamWithMeta = async (stream) => {
123
+ const isLocalFile = stream.path && fs.existsSync(stream.path);
124
+ if (isLocalFile) {
125
+ const filename = path.basename(stream.path);
126
+ return {
127
+ streamOrData: stream,
128
+ length: fs.statSync(stream.path).size,
129
+ contentType: mime.lookup(filename) || DEFAULT_CONTENT_TYPE,
130
+ filename,
131
+ };
132
+ }
133
+
134
+ return {
135
+ ...(await resolveRemoteStream(stream)),
136
+ contentType: DEFAULT_CONTENT_TYPE,
137
+ filename: DEFAULT_FILE_NAME,
138
+ };
139
+ };
140
+
141
+ // Returns an object with fields:
142
+ // * streamOrData: a readable stream, a string, or a Buffer
143
+ // * length: content length in bytes
144
+ // * contentType
145
+ // * filename
146
+ const resolveToBufferStringStream = async (responseOrData) => {
147
+ if (typeof responseOrData === 'string' || responseOrData instanceof String) {
148
+ // The .toString() call only makes a difference for the String object case.
149
+ // It converts a String object to a regular string.
150
+ const str = responseOrData.toString();
151
+ return {
152
+ streamOrData: str,
153
+ length: Buffer.byteLength(str),
154
+ contentType: 'text/plain',
155
+ filename: `${DEFAULT_FILE_NAME}.txt`,
156
+ };
157
+ } else if (Buffer.isBuffer(responseOrData)) {
158
+ return {
159
+ streamOrData: responseOrData,
160
+ length: responseOrData.length,
161
+ contentType: DEFAULT_CONTENT_TYPE,
162
+ filename: DEFAULT_FILE_NAME,
163
+ };
164
+ } else if (
165
+ (responseOrData.body && typeof responseOrData.body.pipe === 'function') ||
166
+ typeof responseOrData.content === 'string'
167
+ ) {
168
+ return resolveResponseToStream(responseOrData);
169
+ } else if (typeof responseOrData.pipe === 'function') {
170
+ return resolveStreamWithMeta(responseOrData);
171
+ }
172
+
173
+ throw new TypeError(
174
+ `z.stashFile() cannot stash type '${typeof responseOrData}'. ` +
175
+ 'Pass it a request, readable stream, string, or Buffer.'
176
+ );
177
+ };
178
+
179
+ const uploader = async (
23
180
  signedPostData,
24
181
  bufferStringStream,
25
182
  knownLength,
26
183
  filename,
27
184
  contentType
28
185
  ) => {
29
- const form = new FormData();
30
-
31
186
  if (knownLength && knownLength > UPLOAD_MAX_SIZE) {
32
- return ZapierPromise.reject(
33
- new Error(`${knownLength} is too big, ${UPLOAD_MAX_SIZE} is the max`)
34
- );
187
+ throw new Error(`${knownLength} is too big, ${UPLOAD_MAX_SIZE} is the max`);
35
188
  }
189
+ filename = path.basename(filename).replace('"', '');
36
190
 
37
- _.each(signedPostData.fields, (value, key) => {
38
- form.append(key, value);
39
- });
191
+ const fields = {
192
+ ...signedPostData.fields,
193
+ 'Content-Disposition': contentDisposition(filename),
194
+ 'Content-Type': contentType,
195
+ };
40
196
 
41
- filename = path.basename(filename || DEFAULT_FILE_NAME).replace('"', '');
197
+ const form = new FormData();
42
198
 
43
- form.append('Content-Disposition', contentDisposition(filename));
199
+ Object.entries(fields).forEach(([key, value]) => {
200
+ form.append(key, value);
201
+ });
44
202
 
45
203
  form.append('file', bufferStringStream, {
46
- contentType,
47
204
  knownLength,
48
- filename
205
+ contentType,
206
+ filename,
49
207
  });
50
208
 
51
209
  // Try to catch the missing length early, before upload to S3 fails.
@@ -56,120 +214,93 @@ const uploader = (
56
214
  }
57
215
 
58
216
  // Send to S3 with presigned request.
59
- return request({
217
+ const response = await request({
60
218
  url: signedPostData.url,
61
219
  method: 'POST',
62
- body: form
63
- }).then(res => {
64
- if (res.status === 204) {
65
- return `${signedPostData.url}${signedPostData.fields.key}`;
66
- }
67
- if (
68
- res.content.indexOf(
69
- 'You must provide the Content-Length HTTP header.'
70
- ) !== -1
71
- ) {
72
- throw new Error(LENGTH_ERR_MESSAGE);
73
- }
74
- throw new Error(`Got ${res.status} - ${res.content}`);
220
+ body: form,
75
221
  });
222
+
223
+ if (response.status === 204) {
224
+ return new URL(signedPostData.fields.key, signedPostData.url).href;
225
+ }
226
+
227
+ if (
228
+ response.content &&
229
+ response.content.includes &&
230
+ response.content.includes(
231
+ 'You must provide the Content-Length HTTP header.'
232
+ )
233
+ ) {
234
+ throw new Error(LENGTH_ERR_MESSAGE);
235
+ }
236
+
237
+ throw new Error(`Got ${response.status} - ${response.content}`);
76
238
  };
77
239
 
78
240
  // Designed to be some user provided function/api.
79
- const createFileStasher = input => {
241
+ const createFileStasher = (input) => {
80
242
  const rpc = _.get(input, '_zapier.rpc');
81
243
 
82
- return (bufferStringStream, knownLength, filename, contentType) => {
244
+ return async (requestOrData, knownLength, filename, contentType) => {
83
245
  // TODO: maybe this could be smart?
84
246
  // if it is already a public url, do we pass through? or upload?
85
247
  if (!rpc) {
86
- return ZapierPromise.reject(new Error('rpc is not available'));
248
+ throw new Error('rpc is not available');
87
249
  }
88
250
 
89
- const isRunningOnHydrator =
90
- _.get(input, '_zapier.event.method', '').indexOf('hydrators.') === 0;
91
- const isRunningOnCreate =
92
- _.get(input, '_zapier.event.method', '').indexOf('creates.') === 0;
251
+ const isRunningOnHydrator = _.get(
252
+ input,
253
+ '_zapier.event.method',
254
+ ''
255
+ ).startsWith('hydrators.');
256
+ const isRunningOnCreate = _.get(
257
+ input,
258
+ '_zapier.event.method',
259
+ ''
260
+ ).startsWith('creates.');
93
261
 
94
262
  if (!isRunningOnHydrator && !isRunningOnCreate) {
95
- return ZapierPromise.reject(
96
- new Error(
97
- 'Files can only be stashed within a create or hydration function/method.'
98
- )
263
+ throw new Error(
264
+ 'Files can only be stashed within a create or hydration function/method.'
99
265
  );
100
266
  }
101
267
 
102
- const fileContentType = contentType || DEFAULT_CONTENT_TYPE;
103
-
104
- return rpc('get_presigned_upload_post_data', fileContentType).then(
105
- result => {
106
- if (isPromise(bufferStringStream)) {
107
- return bufferStringStream.then(maybeResponse => {
108
- const isStreamed = _.get(maybeResponse, 'request.raw', false);
109
-
110
- const parseFinalResponse = response => {
111
- let newBufferStringStream = response;
112
- if (_.isString(response)) {
113
- newBufferStringStream = response;
114
- } else if (response) {
115
- if (Buffer.isBuffer(response)) {
116
- newBufferStringStream = response;
117
- } else if (Buffer.isBuffer(response.dataBuffer)) {
118
- newBufferStringStream = response.dataBuffer;
119
- } else if (
120
- response.body &&
121
- typeof response.body.pipe === 'function'
122
- ) {
123
- newBufferStringStream = response.body;
124
- } else {
125
- newBufferStringStream = response.content;
126
- }
127
-
128
- if (response.headers) {
129
- knownLength =
130
- knownLength || response.getHeader('content-length');
131
- const cd = response.getHeader('content-disposition');
132
- if (cd) {
133
- filename =
134
- filename ||
135
- contentDisposition.parse(cd).parameters.filename;
136
- }
137
- }
138
- } else {
139
- throw new Error(
140
- 'Cannot stash a Promise wrapped file of unknown type.'
141
- );
142
- }
143
-
144
- return uploader(
145
- result,
146
- newBufferStringStream,
147
- knownLength,
148
- filename,
149
- fileContentType
150
- );
151
- };
152
-
153
- if (isStreamed) {
154
- maybeResponse.throwForStatus();
155
- return maybeResponse.buffer().then(buffer => {
156
- maybeResponse.dataBuffer = buffer;
157
- return parseFinalResponse(maybeResponse);
158
- });
159
- } else {
160
- return parseFinalResponse(maybeResponse);
161
- }
162
- });
163
- } else {
164
- return uploader(
165
- result,
166
- bufferStringStream,
167
- knownLength,
168
- filename,
169
- fileContentType
170
- );
171
- }
172
- }
268
+ // requestOrData can be one of these:
269
+ // * string
270
+ // * Buffer
271
+ // * z.request() - a Promise of a regular response
272
+ // * z.request({ raw: true }) - a Promise of a "streamable" response
273
+ // * await z.request() - a regular response
274
+ // * await z.request({ raw: true }) - a streamable response
275
+ //
276
+ // After the following, requestOrData is resolved to responseOrData, which
277
+ // is either:
278
+ // - string
279
+ // - Buffer
280
+ // - a regular response
281
+ // - a streamable response
282
+ const [signedPostData, responseOrData] = await Promise.all([
283
+ rpc('get_presigned_upload_post_data'),
284
+ requestOrData,
285
+ ]);
286
+
287
+ if (responseOrData.throwForStatus) {
288
+ responseOrData.throwForStatus();
289
+ }
290
+
291
+ const {
292
+ streamOrData,
293
+ length,
294
+ contentType: _contentType,
295
+ filename: _filename,
296
+ } = await resolveToBufferStringStream(responseOrData);
297
+
298
+ return uploader(
299
+ signedPostData,
300
+ streamOrData,
301
+ knownLength || length,
302
+ filename || _filename,
303
+ contentType || _contentType
173
304
  );
174
305
  };
175
306
  };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { Writable } = require('stream');
4
+
3
5
  const fetch = require('node-fetch');
4
6
 
5
7
  // XXX: PatchedRequest is to get past node-fetch's check that forbids GET requests
@@ -7,10 +9,10 @@ const fetch = require('node-fetch');
7
9
  // https://github.com/node-fetch/node-fetch/blob/v2.6.0/src/request.js#L75-L78
8
10
  class PatchedRequest extends fetch.Request {
9
11
  constructor(url, opts) {
10
- const origMethod = (opts.method || 'GET').toUpperCase();
12
+ const origMethod = ((opts && opts.method) || 'GET').toUpperCase();
11
13
 
12
14
  const isGetWithBody =
13
- (origMethod === 'GET' || origMethod === 'HEAD') && opts.body;
15
+ (origMethod === 'GET' || origMethod === 'HEAD') && opts && opts.body;
14
16
  let newOpts = opts;
15
17
  if (isGetWithBody) {
16
18
  // Temporary remove body to fool fetch.Request constructor
@@ -50,9 +52,31 @@ class PatchedRequest extends fetch.Request {
50
52
 
51
53
  const newFetch = (url, opts) => {
52
54
  const request = new PatchedRequest(url, opts);
55
+
53
56
  // fetch actually accepts a Request object as an argument. It'll clone the
54
57
  // request internally, that's why the PatchedRequest.body hack works.
55
- return fetch(request);
58
+ const responsePromise = fetch(request);
59
+
60
+ // node-fetch clones request.body and use the cloned body internally. We need
61
+ // to make sure to consume the original body stream so its internal buffer is
62
+ // not filled up, which causes it to pause.
63
+ // See https://github.com/node-fetch/node-fetch/issues/151
64
+ //
65
+ // Exclude form-data object to be consistent with
66
+ // https://github.com/node-fetch/node-fetch/blob/v2.6.6/src/body.js#L403-L412
67
+ if (
68
+ request.body &&
69
+ typeof request.body.pipe === 'function' &&
70
+ typeof request.body.getBoundary !== 'function'
71
+ ) {
72
+ const nullStream = new Writable();
73
+ nullStream._write = function (chunk, encoding, done) {
74
+ done();
75
+ };
76
+ request.body.pipe(nullStream);
77
+ }
78
+
79
+ return responsePromise;
56
80
  };
57
81
 
58
82
  newFetch.Promise = require('./promise');
package/src/.DS_Store DELETED
Binary file
Binary file