Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stream support for FileUpload #471

Merged
merged 15 commits into from
Jul 18, 2018
Merged

Stream support for FileUpload #471

merged 15 commits into from
Jul 18, 2018

Conversation

jjsquillante
Copy link
Contributor

Hey all at Stripe!

First, I want to say: from documentation to code, Stripe communicates its design of the API libraries extremely well - thank you very much!

Ok down to the PR, I was implementing Stripe for a recent project, and figured in order to truly understand the Stripe-node library, I might as well dive in to an issue or feature request to get my hands dirty. I noticed issue #401 and decided to give it a go.

This Pull Request allows a user to upload a file by passing a stream. Within the StripeMethod, we now check for a stream, convert it to a buffer, and begin the _request process as normal.

I've added tests to confirm that the new feature is working as designed and did not break any existing functionality.

I'm certainly open to any feedback, so if there's anything you'd like to see different or that can be improved, let me know!

Please reach out if there's anything that I can help further clarify. Thanks!

@jjsquillante jjsquillante changed the title Stream support for FileUpload #401 Stream support for FileUpload Jun 24, 2018
@ob-stripe
Copy link
Contributor

Hi @jjsquillante, thanks for your kind words as well as the contribution! :)

This looks good to me, but going to ask some colleagues with better JS skills than mine to take a look as well.

r? @jlomas-stripe @rattrayalex-stripe

})
.on('end', function() {
var dataBuffered = Object.assign({}, data);
dataBuffered.file.data = Buffer.concat(bufferArray);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is fine, but would you mind adding var Buffer = require('safe-buffer').Buffer; at the top of the file? That way if we add more code that uses Buffer in this file it will use the safe version.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ob-stripe thanks for the quick response and catching my oversight on safe-buffer! I noticed safe-buffer in the MultipartDataGenerator.js file, but totally forgot about requiring it here. I'll update the code asap and keep a look out for any other feedback from @jlomas-stripe @rattrayalex-stripe .

@jlomas-stripe
Copy link
Contributor

jlomas-stripe commented Jun 25, 2018

Hey hey. Super-cool. :)

I think there should probably be a test in the flows.spec.js that actually hits the API to confirm this does what it says on the tin. Something like:

  describe('FileUpload', function() {
    it('Allows you to upload a file as a stream', function() {
      var testFilename = path.join(__dirname, 'resources/data/minimal.pdf');
      var f = fs.createReadStream(testFilename);

      return expect(
        stripe.fileUploads.create({
          purpose: 'dispute_evidence',
          file: {
            data: f,
            name: 'minimal.pdf',
            type: 'application/octet-stream',
          },
        }).then(null, function(error) {
          return error;
        })
      ).to.eventually.have.nested.property('size', 739);
    });
  });

More generally, we have a requestDataProcessor function function on the StripeResource - which is only currently used by FileUpload - that feels like a better place for this, but that function is unfortunately synchronous at the moment.

I feel like the right way to do this is to refactor the requestDataProcessor function to be async and then do this stuff right in the FileUpload.js's requestDataProcessor, mostly because it really feels like this is specific to FileUpload...?

@jjsquillante
Copy link
Contributor Author

jjsquillante commented Jun 25, 2018

@jlomas-stripe thanks for the response! totally agree - my initial strategy was to modify requestDataProcessor, but this was certainly the 'path of least resistance', so to speak, due to the synchronous nature you mentioned. It seems to make a lot more sense to confine this code to fileUpload.js. Might as well do it right and not compromise :) - I can look into refactoring the requestDataProcessor to async and everything else that goes along with it, if you'd confirm you'd like to move in that direction?

@jlomas-stripe
Copy link
Contributor

You're welcome! :)

cc @ob-stripe / @brandur-stripe / @rattrayalex-stripe on my above suggestion; does that make sense to y'all? What jjsquillante has done here definitely works just fine, but it feels a bit out of place in terms of where it lives, since it only affects one type of API request.

@brandur-stripe
Copy link
Contributor

Thanks for the effort here @jjsquillante!

cc @ob-stripe / @brandur-stripe / @rattrayalex-stripe on my above suggestion; does that make sense to y'all? What jjsquillante has done here definitely works just fine, but it feels a bit out of place in terms of where it lives, since it only affects one type of API request.

Yeah, agreed that FileUpload would be a better home for this — putting it in StripeMethod would be okay if we ever wanted to generalize this for some reason, but I don't ever see that happening. Very likely all files and file-like objects will always go the Stripe area specifically through FileUpload (or a direct successor to it).

lib/utils.js Outdated
*/
checkForStream: function (obj) {
if (obj.file && obj.file.data) {
return typeof obj.file.data.on === 'function' && typeof obj.file.data.emit === 'function';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not do an instanceof check? This is probably fine too, just curious.

Copy link
Contributor Author

@jjsquillante jjsquillante Jun 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rattrayalex-stripe good question! An instanceof check would have been more explicit, but I'm not aware of require('events').EventEmitter available in the utils file. In retrospect, I was trying to implement the feature with just minimal additions to the existing code. When it comes to collaboration, though, it makes sense to be more explicit - I should have probably just required the events module! Thanks for the feedback. I plan to refactor the requestDataProcessor function to async, and if I need this check, I'll implement via instanceof . Let me know if you have any other feedback - thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks! That reasoning makes sense. A duck-type check as you have here may make more sense; let's see how it looks after the refactor. Cheers

@rattrayalex-stripe
Copy link
Contributor

Nice, thanks @jjsquillante ! Other than a +1 to @jlomas-stripe 's suggestions, this looks good to me.

@jjsquillante
Copy link
Contributor Author

@ob-stripe @brandur-stripe @jlomas-stripe @rattrayalex-stripe thanks for all of the feedback. I appreciate you all being open to my contribution and I have enjoyed working within Stripe's library. Sounds like the best strategy is to close this PR and I'll resolve to refactor the requestDataProcessor function to be async. sound good?

@jlomas-stripe
Copy link
Contributor

@jjsquillante You're very welcome, and I'm glad you like the library!

Since this PR represents the reason/inspiration for the refactor, and since the tests for streaming FileUpload will exercise that refactor anyways, it seems reasonable to me to just stick with this PR.

@jjsquillante
Copy link
Contributor Author

@jlomas-stripe ok - sounds good. So to confirm, I'll leave this PR open and will update once the refactor of requestDataProcessor function to async is complete.

@jlomas-stripe
Copy link
Contributor

Sure, or just make all the changes in here and re-title - whatever you prefer!

@jjsquillante
Copy link
Contributor Author

Hey all - @jlomas-stripe @rattrayalex-stripe @ob-stripe
I've refactored the code and added the test @jlomas-stripe provided to flows.spec.js. Let me know if there's anything you'd like to see different. Thanks!

Copy link
Contributor

@jlomas-stripe jlomas-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. :) There are a couple of changes in the tests that need made and a few other comments. Hopefully @rattrayalex-stripe or @ob-stripe can take a peek as well and provide their thoughts!

var testFilename = path.join(__dirname, 'data/minimal.pdf');
var f = fs.createReadStream(testFilename);

stripe.fileUploads.create({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to return this so Mocha knows it's async otherwise it will always pass (because it won't wait for the result in the Promise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, my fault! nice catch @jlomas-stripe - I just ran it through the debugger with an intentional fail and noticed exactly what you were talking about. I'll update this asap.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries. This is a super-insidious mocha thing. Not including a done callback and then doing something async used to catch me out unfortunately often in the good ol' days. 😂

var testFilename = path.join(__dirname, 'data/minimal.pdf');
var f = fs.createReadStream(testFilename);

stripe.fileUploads.create({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: you need to return this so Mocha knows it's async otherwise it will always pass (because it won't wait for the result in the Promise.

var requestData;

if (self.requestDataProcessor) {
requestData = self.requestDataProcessor(method, data, options.headers);
requestProcessor = self.requestDataProcessor(method, data);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this now gets a function, rather than just returning the data, it might be clearer if it were called something like requestDataProcessorFn or getRequestDataProcessorFn I think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable also might be a bit clearer if it were named processRequestData since that's what it's going to to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the feedback. I'll update the variable names to be more clear.

@@ -39,7 +39,10 @@ function multipartDataGenerator(method, data, headers) {
}
push('--' + segno + '--');

return buffer;
if (!callback) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't have any async pieces; does it really need the callback in here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're correct - definitely does not need to have a callback in there. I'll update. thanks @jlomas-stripe

if (requestProcessor) {
requestProcessor(method, data, options.headers, function(err, buffer) {
if (err) {
var handleError = self._errorHandler({}, callback);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is probably better handled with a different kind of error, rather than overloading the connection error, since this isn't a connection (or other Stripe) error; (i presume) it'll be something related to the Buffer itself, so we should probably expose it as such.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, cool - makes sense. Looking back now, it was kind of a 'cop out' to overload the connection error. I'll resolve to update and expose the error as such.

} else {
return utils.stringifyRequestData(data);
}

function determineSource(data) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the approach you've got here, this function does more than just determineSource - it getProcessorForSourceType - so it probably should be renamed so it's clearer as to what you're getting back.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, i'll rename and update!

@@ -9,14 +10,45 @@ module.exports = StripeResource.extend({

overrideHost: 'uploads.stripe.com',

requestDataProcessor: function(method, data, headers) {
requestDataProcessor: function(method, data) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This got a bit more complicated than I expected - I imagined something very similar to what you'd done previously, except that the entire requestDataProcessor function would just get an additional callback parameter that was always used (either sync or async), and the main code flow would either hit some 'default' function with callback, or the one specified here.

Copy link
Contributor Author

@jjsquillante jjsquillante Jul 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jlomas-stripe Definitely a bit more complicated but I believe I vetted the scenario you've mentioned and the nature of streams posed a problem. If I'm understanding your comment (correct me if I'm wrong), the gist is: requestDataProcessor would send the buffered multipart/form-data back to _request via the additional callback - which ever way handled (ie. data processed via stream or already buffered data). From there _request can proceed as normal.

The trouble I encountered is that when the stream is set by attaching an event listener .on('data'), the code is handled via process.nextTick() which runs the callback after the rest of the code and before the event loop. If I remember correctly, this solution didn't wait for data to be passed back to the callback, but instead proceed with data as undefined and continue through _request without the buffered data. The way I figured, returns a function in both scenarios so we can wait for the stream to finish processing. This will guarantee the buffered data exists when the rest of _request is processed by invoking the setHeadersAndMakeRequest within the callback from either scenario. My apologies if that was long-winded! 🙂

If that logic may be flawed or there's a better way to go about this - I'm fine with scrapping this solution for the sake of clarity and going back to the drawing board. Let me know!

Copy link
Contributor

@jlomas-stripe jlomas-stripe Jul 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sort of what I was thinking from the _request perspective:

_request: function (method, path, data, auth, options, callback) {

  var requestData;

  function makeRequestWithData(error, data) {
    if (error) { /* something */ }

    requestData = data;

    /* some stuff */

    this._stripe.getClientUserAgent(function(cua) {
      /* some stuff */

      makeRequest();
    });
  }

  if (self.requestDataProcessor) {
    self.requestDataProcessor(method, data, options.headers, makeRequestWithData);
  } else {
    makeRequestWithData(null, utils.stringifyRequestData(data || {}));
  }
}

(Note, this is just what I was thinking; I haven't tested it so I may be out to lunch 😂)

I.e., just adding one more function call to 'asyncify' the flow from 'process the data' to 'make the request'. This doesn't even need to be done async (the else is a synchronous function call here), but it at least allows for it.

I think adding that one function call, plus adding a callback argument to requestDataProcessor, is enough to make this handle both cases.

And then rather than returning a function from requestDataProcessor, you'd just hit the callback that was provided to it when it's done, in stream.on('end'), as you're doing now.

If you were ending up with data being undefined, maybe the callback was firing early/more than once, or something was returning a value when you wanted it to be 'returned' via the callback instead...?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jlomas-stripe ohhh no I misunderstood! All good, yea that'll definitely work inside _request - I have some free time later and can put it to action. thanks for further explaining!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jlomas-stripe just tested it and worked perfectly. I appreciate the help simplifying. I'll update the PR with the changes requested in the review.

@rattrayalex-stripe
Copy link
Contributor

Thanks – I'll defer to @ob-stripe on this one

@jjsquillante
Copy link
Contributor Author

hey all - I've made the requested updates from the review. Let me know if there's any other changes required or something I may have forgot! Thanks

@ob-stripe
Copy link
Contributor

@jlomas-stripe Mind doing another review? Cheers :)

@jlomas-stripe
Copy link
Contributor

@ob-stripe / @jjsquillante Yup, for sure; sorry for the delay there. WIll peek it this morning. 👍

Copy link
Contributor

@jlomas-stripe jlomas-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks fantastic - thanks again for taking this on! A couple of comments inline, but this is nearly there. :)

data = data || {};

if (method === 'POST') {
return multipartDataGenerator(method, data, headers);
return getProcessorForSourceType(data);
} else {
return utils.stringifyRequestData(data);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to make use of the callback flow here, so it's probably return callback(null, utils.stringifyRequestData(data));.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh wow, great catch - my fault - I totally missed that! Thanks


var apiVersion = this._stripe.getApiField('version');
if (error) {
var handleError = self._errorHandlerStream(callback);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather see this FileUpload-specific error generation/handling pushed to FileUploads.js, and have this just be more generic here so it can handle future cases, i.e. just:

if (error) {
  return callback(error);
}

At this point in the code, we don't (or shouldn't) really care what kind of error this is, we should just expose it in the callback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

@@ -212,6 +212,20 @@ StripeResource.prototype = {
}
},

_errorHandlerStream: function(callback) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per comment further down, I think the StreamProcessingError should be created (and wrap the incoming error) in FileUploads.js, since it's so specific to FileUploads.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Apparently I meant 'further up' 😂)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point! - removed and updated StreamProcessingError to FileUploads.

var buffer = fn(method, bufferData, headers);
callback(null, buffer);
}).on('error', function(err) {
callback(err, null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per comments above, this is probably the right place to new up the StreamProcessorError.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated - thanks!

})
).to.eventually.have.nested.property('size', 739);
});
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't actually test that the error comes back as expected; this should do it:

    it('Surfaces stream errors correctly', function(done) {
      var mockedStream = new stream.Readable();
      mockedStream._read = function() {};

      var fakeError = new Error('I am a fake error');

      process.nextTick(function() {
        mockedStream.emit('error', fakeError);
      });

      stripe.fileUploads.create({
        purpose: 'dispute_evidence',
        file: {
          data: mockedStream,
          name: 'minimal.pdf',
          type: 'application/octet-stream',
        },
      }).catch(function(error) {
        expect(error.message).to.equal('An error occurred while attempting to process the file for upload.');
        expect(error.detail).to.equal(fakeError);

        done();
      });
    });

We should also test that you can still provide a (non-Stream) file, which is just the same as what you've already got except with fs.readFileSync() instead of fs.createReadStream().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I tried doing this in a promisey way but I don't trust promises in Mocha I couldn't get it to work. ¯\_(ツ)_/¯ :) )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added both tests (for synchronous file uploads and testing for the stream error to return properly) - thanks!

@jjsquillante
Copy link
Contributor Author

@jlomas-stripe awesome, thanks for reviewing again! I'll look over the changes requested and revise based on your comments.

@jlomas-stripe
Copy link
Contributor

Awesome - you're welcome! And I'll try to be more timely in my next review. :)

@jjsquillante
Copy link
Contributor Author

jjsquillante commented Jul 16, 2018

@jlomas-stripe updated the code and revised based on your comments - let me know if there's anything that needs to be changed! thanks

@jlomas-stripe
Copy link
Contributor

@jjsquillante Looks fantastic - thank you! :)

@ob-stripe / @rattrayalex-stripe Can one of y'all please take a rip through this and make sure it all makes sense to you as well? Thanks!

@jlomas-stripe
Copy link
Contributor

Hold on - I didn't actually approve this, just the changes - I'm not sure how that happened. Weird.

I'd really like @ob-stripe or @rattrayalex-stripe to just have a quick peek first.

Copy link
Contributor

@ob-stripe ob-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. @rattrayalex-stripe, mind taking a final 👀?

Copy link
Contributor

@rattrayalex-stripe rattrayalex-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks @jjsquillante

@jjsquillante
Copy link
Contributor Author

@jlomas-stripe @ob-stripe @rattrayalex-stripe @brandur-stripe
Thanks for taking the time to work with me on this PR - I've enjoyed collaborating with you all!

@lopesc
Copy link

lopesc commented Jul 17, 2018 via email

@jlomas-stripe
Copy link
Contributor

No problemo - thanks for taking this on! :)

@ob-stripe can you merge && release? Or ... can ... I? 😂

@ob-stripe
Copy link
Contributor

I'll take :)

@ob-stripe ob-stripe merged commit d597f14 into stripe:master Jul 18, 2018
@ob-stripe
Copy link
Contributor

Released as 6.3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants