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

[SEMVER-MAJOR] Rework coercion of input arguments #343

Merged
merged 1 commit into from
Sep 9, 2016

Conversation

bajtos
Copy link
Member

@bajtos bajtos commented Aug 31, 2016

Simplify the design of input argument handling.

  • It's up to the Context and/or the Adapter to convert or coerce
    input arguments as needed.
  • The only responsibility of SharedMethod is to validate input
    arguments (check that they match the contract in "accepts").
  • Define two types of conversion/coercion:
    a) When the input argument value comes from a source that preserves
    type information (typically JSON), then the argument should be
    converted with minimal coercion.
    b) When the input argument value comes from a "sloppy" source
    (typically string-encoded source like a query string), then
    coercion rules should be applied.

Replace "Dynamic" API, which was relying on global singleton registry,
with a new concept of a "TypeRegistry" and a type "Converter".
The new registry is local to each RemoteObjects instance.

Each type converter provides three methods:

  • fromTypedValue to handle values coming from JSON
  • fromSloppyValue to handle values coming e.g. from query string
  • validate which is called by SharedMethod to validate argument
    values

To prevent unnecessary try/catch blocks with the associated performance
costs, validation/conversion errors are not thrown but returned via
function return value instead.

The feature where one could encode all input arguments in a single
(query string) parameter "args" is removed.

This patch fixes many subtle bugs and changes the way how edge case
values are treated. The general approach is to make both conversion and
coercion more strict. When we are not sure how to treat an input value,
we rather return HTTP 400 Bad Request than coerce the value incorrectly.

Most notable breaking changes:

  • null value is accepted only for "object", "array" and "any"
  • Empty string is coerced to undefined to support ES6 default arguments
  • JSON requests providing scalar values for array-typed argument are
    rejected
  • Empty value is not converted to an empty array
  • Array values containing items of wrong type are rejected. For
    example, an array containing a string value is rejected when
    the input argument expects an array of numbers.
  • Array items from JSON requests are not coerced. For example,
    [true] is no longer coerced to [1] for number arrays,
    and the request is subsequently rejected.
  • Deep members of object arguments are no longer coerced. For example,
    a query like ?arg[count]=42 produces { count: '42' } now.
  • "any" coercion preserves too large numbers as a string, to prevent
    losing precision.
  • Boolean types accepts only four string values:
    'true', 'false', '0' and '1'
    Values are case-insensitive, i.e. 'TRUE' and 'FaLsE' are work too.
  • Date type detects "Invalid Date" and rejects such requests.

Last but not least, conversion/validation error messages no longer
include input argument values, in order to prevent reflection-based
attacks.

@gunjpan @richardpringle @deepakrkris Please review.
@ritch @STRML PTAL too, I would like to hear your opinion on this change.
@0candy @davidcheung FYI

This patch is loosely inspired by #264.

The change-set is quite large and it may be too difficult to review all details. Here are the most important parts to check:

  • the overal design - converters, type registry, what work is done by Context vs. SharedMethod
  • changes in rest-coercion test suite - is there anything surprising?
  • changes in seemingly unrelated files (lib/context-base, lib/http-context, lib/http-invocation)

Connect to #312
Connect to strongloop-internal/scrum-loopback#974
Connect to strongloop-internal/scrum-loopback#885

TODO for @bajtos:

@bajtos
Copy link
Member Author

bajtos commented Aug 31, 2016

Cross-posting @STRML comment from #312 (comment)

If you're accepting an array input for a field, I don't see any semantic difference between [] (no items) and null (no items). But [] is much more convenient as you can simply iterate.

Here is an example where the difference is important: let's say we have a method find accepting an argument called extraFields. When extraFields is present, the method adds the given fields to the response data. When extraFields is not present, all extra fields are included.

Having said that, I see your point of view @STRML. I think we can achieve what you are asking for by supporting a default value in argument definition:

accepts: {
  arg: 'numbers', type: ['number'], default: []
}

Let's leave the support for default values out of scope of this patch though.

@bajtos
Copy link
Member Author

bajtos commented Aug 31, 2016

Also: for long time, I though we can land (some of) these changes to 2.x in such way that backwards compatibility is preserved. I changed my mind after I checked the effect of #264 on the rest-coercion test suite, there are too many changes where I am concerned about the impact on existing applications. Our current plan is to release loopback@3 by the end of September, therefore I think it will be easier for all of us if this patch is landed on 3.0 only.

@bajtos
Copy link
Member Author

bajtos commented Aug 31, 2016

I think the CI results cannot be trusted very much. First of all, this patch is targeting 3.0 release line, which is used in very few downstream projects. Secondly, loopback requires changes in strongloop/loopback#2696 to pass the CI, I am not sure if CIS will pick the right branch in loopback when running downstream tests of this pull request.

@richardpringle
Copy link
Contributor

@bajtos,
The strong-remoting build itself was not passing before, that's why I re-triggered the test. It seems to be passing now.

losing precision.
- Boolean types accepts only four string values:
'true', 'false', '0' and '1'
Values are case-insensitive, i.e. 'TRUE' and 'FaLsE' are work too.
Copy link
Contributor

Choose a reason for hiding this comment

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

are work too

@richardpringle
Copy link
Contributor

The review process could be much faster if this spread out into multiple commits.

* return error ? { error: error } : { value: value };
* },
*
* fromSloppyValue: function(ctx, value) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add a definition of a "sloppy value"?

Copy link
Member Author

@bajtos bajtos Sep 2, 2016

Choose a reason for hiding this comment

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

Good point. I took the concept of "sloppy" values from #264 but on the second thought the name is not descriptive enough.

The sloppy conversion/coercion is invoked when the value comes from a string-only source like HTTP header or more importantly from a query string parsed via qs.

A sloppy value is one of:

  • a string
  • an array containing sloppy values
  • an object where property values are sloppy

I am tempted to rename fromSloppyValue to fromStringValue, but that would hide the fact that an array or an object can be passed in too.

@ritch @richardpringle Any suggestions for a better name?

I reworked the comments in 0b7df4f to make this matter more clear, PTAL.

@ritch
Copy link
Member

ritch commented Sep 1, 2016

Removing Dynamic will break a lot of things in LoopBack core.

* });
* ```
*
* **Note: the alias `remotes.convert()` is deprecated.**
* Under the hood, a converter is created that ensures the input data
* is an object (or sloppy value is coerced to an object) and calls
Copy link
Member

Choose a reason for hiding this comment

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

sloppy value
I see this referenced in various places, and I am not sure what it means.

Does this mean the raw value provided from req.param() or equiv?

Copy link
Member Author

Choose a reason for hiding this comment

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

See #343 (comment)

The sloppy conversion/coercion is invoked when the value comes from a string-only source like HTTP header or more importantly from a query string parsed via qs.

Should I expand this comment to make it more clear what is meant by sloppy values?

@bajtos
Copy link
Member Author

bajtos commented Sep 2, 2016

@ritch

Removing Dynamic will break a lot of things in LoopBack core.

Not really. We use Dynamic in one place only and the upgrade is trivial - see strongloop/loopback@8262059.

@bajtos bajtos mentioned this pull request Sep 6, 2016
assert(method && typeof method === 'object',
'method must be a SharedClass instance');
assert(typeRegistry && typeof typeRegistry === 'object',
'typeRegistry must be a TypeRegistry instance');
Copy link
Contributor

@Amir-61 Amir-61 Sep 7, 2016

Choose a reason for hiding this comment

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

We have used assert(...) a lot in dao.js as well; they are so neat; however I just wonder if the assertion fails, does it dump the stack trace?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, failed assertions dump the stack trace. That way it's easy to find the place in user code that's calling ContextBase with incorrect arguments.

if (this._options.warnWhenOverridingType !== false)
g.warn('Warning: overriding remoting type %s', typeName);
else
debug('Warning: overriding remoting type %s', typeName);
Copy link
Contributor

@Amir-61 Amir-61 Sep 7, 2016

Choose a reason for hiding this comment

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

Actually I love this ^ syntax; I mean skipping curly braces for one line if/else, but is this what team follows? It reminds me of coffee script too :-)

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think we have a rule for that (yet). Since eslint checks are passing, the style should be good.

TBH, I don't really mind. If you think I should add curly braces, then I'll do so. Please let me know.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thats fine; TBH I like it too 👍 IIRC eslint used to be complaining about that.

@Amir-61
Copy link
Contributor

Amir-61 commented Sep 7, 2016

@bajtos I reviewed almost one thirds of your pull request tonight; I added a bunch of comments, mostly questions and some suggestions. I'll try to review the rest tomorrow to unblock you :-)

},

fromSloppyValue: function(ctx, value) {
if (value === 'null' || value === null)
Copy link
Contributor

@Amir-61 Amir-61 Sep 7, 2016

Choose a reason for hiding this comment

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

Question: If value is a string of null; is it always lowercase null and not like NULL?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, only lowercase null is recognized right now. Do you think this should be changed?

@bajtos
Copy link
Member Author

bajtos commented Sep 7, 2016

I reviewed almost one thirds of your pull request tonight; I added a bunch of comments, mostly questions and some suggestions. I'll try to review the rest tomorrow to unblock you :-)

@Amir-61 you are awesome, thank you!

assert(typeof typeName === 'string' && typeName,
'typeName must be a non-empty string');
assert(typeof converter === 'object' && converter,
'converter must be an object');
Copy link
Contributor

Choose a reason for hiding this comment

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

Are you just checking the type of converter. In this case shouldn't be like the following?

  assert(typeof converter === 'object',
     'converter must be an object');

Copy link
Member Author

Choose a reason for hiding this comment

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

Unfortunately, typeof null is also object:

> var converter = null
undefined
> typeof converter === 'object'
true

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh right...I missed that! 👍

@Amir-61
Copy link
Contributor

Amir-61 commented Sep 7, 2016

Hey @bajtos,
I went through all changes and changes LGTM; I'm just not 100% sure if the changes break things in LoopBack core module which can be hard to detect. However, all I can say is tests for dependants (including Loopback) are all passing.

Please squash the commits and rebase them on top of master.

Simplify the design of input argument handling.

 - It's up to the Context and/or the Adapter to convert or coerce
   input arguments as needed.

 - The only responsibility of SharedMethod is to validate input
   arguments (check that they match the contract in "accepts").

 - Define two types of conversion/coercion:
   a) When the input argument value comes from a source that preserves
      type information (typically JSON), then the argument should be
      converted with minimal coercion.
   b) When the input argument value comes from a "sloppy" source
      (typically string-encoded source like a query string), then
      coercion rules should be applied.

Replace "Dynamic" API, which was relying on global singleton registry,
with a new concept of a "TypeRegistry" and a type "Converter".
The new registry is local to each RemoteObjects instance.

Each type converter provides three methods:
  - `fromTypedValue` to handle values coming from JSON
  - `fromSloppyValue` to handle values coming e.g. from query string
  - `validate` which is called by SharedMethod to validate argument
    values

To prevent unnecessary try/catch blocks with the associated performance
costs, validation/conversion errors are not thrown but returned via
function return value instead.

The feature where one could encode all input arguments in a single
(query string) parameter "args" is removed.

This patch fixes many subtle bugs and changes the way how edge case
values are treated. The general approach is to make both conversion and
coercion more strict. When we are not sure how to treat an input value,
we rather return HTTP 400 Bad Request than coerce the value incorrectly.

Most notable breaking changes:

 - `null` value is accepted only for "object", "array" and "any"
 - Empty string is coerced to undefined to support ES6 default arguments
 - JSON requests providing scalar values for array-typed argument are
   rejected
 - Empty value is not converted to an empty array
 - Array values containing items of wrong type are rejected. For
   example, an array containing a string value is rejected when
   the input argument expects an array of numbers.
 - Array items from JSON requests are not coerced. For example,
   `[true]` is no longer coerced to `[1]` for number arrays,
   and the request is subsequently rejected.
 - Deep members of object arguments are no longer coerced. For example,
   a query like `?arg[count]=42` produces `{ count: '42' }` now.
 - "any" coercion preserves too large numbers as a string, to prevent
   losing precision.
 - Boolean types accepts only four string values:
    'true', 'false', '0' and '1'
   Values are case-insensitive, i.e. 'TRUE' and 'FaLsE' are work too.
 - Date type detects "Invalid Date" and rejects such requests.

Last but not least, conversion/validation error messages no longer
include input argument values, in order to prevent reflection-based
attacks.
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.

6 participants