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

Add custom response renderer options. #67

Closed
wants to merge 1 commit into from

Conversation

charlie-s
Copy link
Contributor

@charlie-s charlie-s commented Jan 4, 2018

Description

Adds functionality to overwrite existing renderer functions (sendHTML, sendJSON, sendXML) and adds a new sendText option for text/plain.

One issue that I saw while writing tests is that all JSON tests are running with .set('Accept', 'text/plain'). Why is this not application/json? Will this break expectations? See this comment for similar concern.

I'm not a fan of having to have test/custom-renderers/send-*.js files, but since they're being included with require() I don't know the best way to handle.

Note that https://loopback.io/doc/ja/lb3/Using-strong-error-handler.html would need updating if this PR eventually happens.

Related issues

Checklist

  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style
    guide

@bajtos bajtos self-assigned this Jan 5, 2018
@bajtos
Copy link
Member

bajtos commented Jan 5, 2018

Hello @charlie-s, thank you for the pull request. Your proposal looks reasonably, I'll take a look at the code changes next week.

@bajtos
Copy link
Member

bajtos commented Jan 12, 2018

I am afraid I did not manage to find time to review this patch yet. Sorry for letting your patch open for so long 😢

@charlie-s
Copy link
Contributor Author

No worries @bajtos, whenever you have the time.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

Thank you for the work you put into this pull request! Most of the code itself looks reasonable, however I have different opinions about the high-level design, which I would like to discuss with you, so that we can find the best solution both for our users and us, the module maintainers. See my comments below.

@@ -84,12 +84,16 @@ The content type of the response depends on the request's `Accepts` header.
| safeFields | [String] | `[]` | Specifies property names on errors that are allowed to be passed through in 4xx and 5xx responses. See [Safe error fields](#safe-error-fields) below. |
| defaultType | String | `"json"` | Specify the default response content type to use when the client does not provide any Accepts header.
| negotiateContentType | Boolean | true | Negotiate the response content type via Accepts request header. When disabled, strong-error-handler will always use the default content type when producing responses. Disabling content type negotiation is useful if you want to see JSON-formatted error responses in browsers, because browsers usually prefer HTML and XML over other content types.
| htmlRenderer | String | 'send-html' | Operation function to render HTML with signature `fn(res, data)`. Defaults to `./send-html` (`lib/send-html.js`). |
Copy link
Member

Choose a reason for hiding this comment

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

I like the idea of customizable renderer functions 👍

I am concerned about the design you are proposing though. Passing in a string path may seem like a convenient solution in the context of LoopBack's middleware.json, but there are drawbacks too:

  1. When using strong-error-handler in vanilla Express applications, configuring it directly from JavaScript code, the usual convention is to pass functions, not paths to source code files.

    const app = express();
    app.use(strongErrorHandler({
      log: true,
      debug: false,
      htmlRenderer: function(res, data) {
        // my code
      },
    });

    This can be addressed by modifying the implementation to recognize both String and Function values.

  2. Using a path that's directly passed to require can lead to situation difficult to debug. Consider what happens when you forget to include $! prefix in your middleware.json entry: the path will be resolved relatively to strong-error-handler. Either a wrong file will be loaded, or what's more likely, "module not found" error will be printed.

    I am proposing to address this usability issue by checking whether the user-supplied path is absolute, and throwing an error with a helpful error message otherwise.

Thoughts?

Also please change the description of the default value. Consumers of this module should not need to understand our internal implementation. IMO, it's better to tell them that by default the built-in renderer is used. In fact, I think the rendered options should not have any default value per se, instead we should interpret a missing value as "use the built-in renderer".

| htmlRenderer | String | | An optional function to render HTML, the function should have signature `fn(res, data)`. Use this option to override the built-in HTML renderer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Absolutely agree, would prefer it be definable as a function as well.

  2. If a path is supplied, are you suggesting it would be required to start with $! or else we'd throw a helpful error? I was thinking it would behave however core LoopBack behaves in this regard, where I thought you could do it with or without the $!.

  3. Good point.

Copy link
Member

Choose a reason for hiding this comment

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

If a path is supplied, are you suggesting it would be required to start with $! or else we'd throw a helpful error? I was thinking it would behave however core LoopBack behaves in this regard, where I thought you could do it with or without the $!.

The prefix $! is something that loopback-boot recognizes and expands into an absolute path. LoopBack runtime itself (loopback, strong-remoting, express) never see that value!

  • Here in strong-error-handler, we should enforce absolute paths to prevent user confusion.
  • The documentation for LoopBack users should use $! in the examples, that's how this new feature should be used in LoopBack apps
  • loopback-boot will take care of the rest

Thoughts?

}
```

The `$!` characters indicate that the path is relative to the location of `middleware.json`.
Copy link
Member

Choose a reason for hiding this comment

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

Please add a note that $! prefix is a feature provided by LoopBack. When using strong-error-handler directly from JavaScript code, users should provide the actual function. (Please include an example code for that too.)

@@ -6,11 +6,13 @@
'use strict';
var accepts = require('accepts');
var debug = require('debug')('strong-error-handler:http-response');
var sendJson = require('./send-json');
var sendHtml = require('./send-html');
var sendXml = require('./send-xml');
Copy link
Member

Choose a reason for hiding this comment

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

We prefer to keep all require calls at the top level. Perhaps rename sendJson to defaultSendJson (etc.)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Your suggestion would make me happier than this current method.

@@ -43,6 +46,12 @@ function negotiateContentProducer(req, logWarning, options) {
}
}

// resolve renderer functions
sendHtml = require(options.htmlRenderer || './send-html');
Copy link
Member

Choose a reason for hiding this comment

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

Overriding sendHtml that's shared in a global scope can create subtle bugs when negotiateContentProducer is called multiple times in parallel, each time with different options object.

I am proposing a different approach here:

  1. Instead of storing renderer functions in variables, store them in the options object itself.

  2. Modify resolveOperation to accept the options object, so that it can read renderer functions from there.

  3. Modifying properties of an object passed in as an argument is considered as anti-pattern, it often surprises users. Please shallow-clone the options object before making any changes:

    options = Object.assign({}, options);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Very good. I'll combine this with the note above about top-level requires and figure it out.


'use strict';

var format = require('util').format;
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Happy to do so. I think this is the case in quite a few places. I only saw var in the current codebase and so stuck with that.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, we are conservative when it comes to code style changes and haven't upgrade the existing codebase for our new ES6 conventions. I know it can be confusing for new contributors.


module.exports = function sendText(res, data) {
var content = format('%s: %s [%s]', data.name, data.message,
data.statusCode);
Copy link
Member

Choose a reason for hiding this comment

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

This is a good start 👍

I am concerned that this format is missing important additional information, e.g. err.details from validation errors (in both production and debug model), additional fields like stack in debug mode.

Please check how the built-in html renderer is iterating over error properties and implement a similar algorithm 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.

👍

'use strict';

module.exports = function sendHtml(res, data) {
var content = '<html><body>' + data.message + '</body></html>';
Copy link
Member

Choose a reason for hiding this comment

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

Use const.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

@@ -0,0 +1,12 @@
// Copyright IBM Corp. 2016. All Rights Reserved.
Copy link
Member

Choose a reason for hiding this comment

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

The copyright should say 2018 in all newly added files.
Please move custom renderers to a different location - it's our convention to put test fixtures in test/fixtures directory:

test/fixtures/custom-renderers

Please use file names that are different from files containing the built-in renderers, for example:

custom-send-html.js
send-html.custom.js

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

module.exports = function sendHtml(res, data) {
var content = '<html><body>' + data.message + '</body></html>';
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(content, 'utf-8');
Copy link
Member

Choose a reason for hiding this comment

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

Interesting. You are proposing that custom renderer functions are not only responsible for rendering the error data object into a representation to be sent back to the client, but you are also asking the rendered to actually send the HTTP response.

I think that's adding unnecessary complexity to custom renderer functions and also allow our users to shoot their own feet by setting wrong content-type (e.g. setting content-type to text/plain in htmlRenderer).

I am proposing to simplify the design of custom renderers, so that these functions only render the response body (return back the string to be sent).

I understand there may be situations where the user wants to configure the HTTP response headers too, but I think we should find a different solution for that, and only after there is user demand for this feature.

Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For what it's worth, I was trying to copy the current implementation of sending the response from https://github.com/strongloop/strong-error-handler/blob/master/lib/send-html.js.

I think I would prefer to allow users to have the full res Express object, which would allow for async operations and other custom logic as needed.

But, thinking through it as you've suggested, it does seem like bad design that a consumer would have to know to res.json(obj); (or do it by hand via headers and JSON.stringify). I suppose it would be documented that the expectation of a custom renderer is to set the correct response type. I'm open to your thoughts of course, I just don't want to complicate it too much.

Copy link
Member

Choose a reason for hiding this comment

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

For what it's worth, I was trying to copy the current implementation of sending the response from https://github.com/strongloop/strong-error-handler/blob/master/lib/send-html.js.

That was a sensible thing to do! It's just that the existing design was not meant for custom user-supplied renderers.

But, thinking through it as you've suggested, it does seem like bad design that a consumer would have to know to res.json(obj) (or do it by hand via headers and JSON.stringify). I suppose it would be documented that the expectation of a custom renderer is to set the correct response type. I'm open to your thoughts of course, I just don't want to complicate it too much.

Here is what I am thinking:

  • IMO, most users will want to change the response body only and won't want to deal with HTTP headers etc. We should shield them from the additional complexity of producing a valid HTTP response. In other words, I am advocating for htmlRenderer that accepts the error data object and returns a string.

  • If we want to support async scenarios, we can allow these rendered functions to return either a value (a string) or a promise (that will eventually be resolved to a string).

  • For users looking for more advanced customization, I think it may be better if we allow them full control over both content-type negotiation and response serialization, e.g. replace the following block of code with their custom function passed in the options. Unless you need this feature, I prefer to leave this out of scope of this pull request.

    https://github.com/strongloop/strong-error-handler/blob/4c69370751755c0eb1a96e30a7f62182704839ba/lib/handler.js#L51-L55

@bajtos
Copy link
Member

bajtos commented Feb 2, 2018

@charlie-s ping, what's the status of this pull request? Are you still keen to get it finished?

@charlie-s
Copy link
Contributor Author

@bajtos Still keen to get it finished. Apologies on the delay, I quite appreciate your responses and feedback on this. I should be able to get back on this within the next 2 weeks.

@bajtos
Copy link
Member

bajtos commented Feb 6, 2018

Sounds great! Please leave a comment when you have new code to review, I am not receiving notifications about pushed commits.

@iamsenorespana
Copy link

@charlie-s I can help finish this. Where are we with this? I would love to see this done and implemeted. Let me know.

@charlie-s
Copy link
Contributor Author

@jamilhassanspain Some help would be great! I've been busier than expected and haven't been able to jump back on this. All of the hunks above that bajtos pointed out need to be addressed, either by following his recommendations or suggesting alternatives. Most are straightforward, only a couple need some back-and-forth to finalize the code. I can give you write access to the fork and you can help on whichever items you'd like – sound good?

@bajtos
Copy link
Member

bajtos commented Jul 31, 2018

I am closing this pull request as abandoned. Feel free to re-open (or submit a new one) if you get time to work on this again and address my review comments above.

@bajtos bajtos closed this Jul 31, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants