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

Fix handling of falsy value with the json option #223

Merged
merged 12 commits into from
Jan 19, 2020
Merged

Fix handling of falsy value with the json option #223

merged 12 commits into from
Jan 19, 2020

Conversation

elijahsmith
Copy link
Contributor

@elijahsmith elijahsmith commented Jan 16, 2020

Currently, falsey json values will get mistaken for an empty json value -- this makes the comparison more specific, and will match any json value other than undefined

Fixes #222

currently, falsey json values will get mistaken for an empty json value -- this makes the comparison more specific, and will match any json value other than `undefined`
@sindresorhus
Copy link
Owner

Would you be able to add a test too? So we can be sure to not regress it in the future. https://github.com/sindresorhus/ky/blob/master/test/main.js

@sholladay sholladay changed the title make check for json content more specific Fix handling of falsey json option Jan 16, 2020
Copy link
Collaborator

@sholladay sholladay left a comment

Choose a reason for hiding this comment

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

If this is to be an officially supported use case, I think the documentation for the json option should be updated to clarify that any valid JSON serializable value is supported. Currently it implies that only objects are supported.

Both readme.md and index.d.ts should be updated.

@elijahsmith
Copy link
Contributor Author

I've changed the implementation just a bit -- now, setting json to anything is functionally equivalent to setting body to the JSON.serialized result of the same anything... including for edge cases like undefined!

I also updated docs to reflect this (set their types to any and updated the descriptions).

Thanks for the feedback!

index.js Outdated Show resolved Hide resolved
index.d.ts Outdated
*/
json?: unknown;
json?: any;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be correctly typed. The keys are strings and the values can be: string, number, boolean, null, object, array.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure what you mean by "the keys are strings". I've updated the type to explicitly list those types, and included undefined, which is technically allowed (though it serializes to null...)

Copy link
Collaborator

Choose a reason for hiding this comment

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

For TypeScript purposes, I think it should probably stay as unknown.

https://stackoverflow.com/questions/51439843/unknown-vs-any/51439876

index.d.ts Outdated
*/
json?: unknown;
json?: object | Array | string | number | boolean | null | undefined;
Copy link
Collaborator

Choose a reason for hiding this comment

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

undefined is unnecessary 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.

that's fine... but what's your reasoning? undefined is a valid value to pass the json prop here... that said, this is my first time working with ts so I'm not aware of the subtleties...

index.js Outdated
@@ -242,7 +242,7 @@ class Ky {
this.request = new globals.Request(new globals.Request(url, this.request), this._options);
}

if (this._options.json !== undefined) {
if (this._options.hasOwnProperty('json')) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This will make it hard to unset the json option. Also, it will try to stringify and send undefined which will cause JSON.parse() on the server to throw an error.

I think !== undefined is the way to go.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

to unset the prop, you'd delete it... the tricky thing here is that setting json is (described as) a "shortcut" for setting .body = JSON.stringify(...), so presumably anything you can pass to JSON.stringify should be able to be passed to json! and, undefined will be stringified to null so the server will be required to parse that (which should be fine).

Having the json prop act ever so slightly different than how it's described is IMHO a recipe for weird edge cases...

Copy link
Collaborator

Choose a reason for hiding this comment

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

undefined will be stringified to null

This is incorrect. JSON.stringify(undefined) returns undefined, which JSON.parse() fails to parse.

readme.md Outdated
@@ -139,9 +139,9 @@ Internally, the standard methods (`GET`, `POST`, `PUT`, `PATCH`, `HEAD` and `DEL

##### json

Type: `object`
Type: `any`
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe instead of an actual type (which would be very verbose), we should just say "Any valid JSON value", linking to a reference on MDN or elsewhere that specifies what that is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is where I can't contribute, as I have no ts experience. I'm happy to go with whatever y'all decide...

Copy link
Collaborator

@sholladay sholladay Jan 17, 2020

Choose a reason for hiding this comment

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

I was speaking only of the documentation for the API in the readme, my comment isn't TypeScript related. The TypeScript definitions in index.d.ts, on the other hand, obviously need to be valid TypeScript.

@elijahsmith
Copy link
Contributor Author

the build is now failing on the test headers › non-existent headers set to undefined are omitted, but I can't see why! it started failing when I changed to using 'json' in this._options. Changes to the docs or .ts files would not trigger tests failures, would they?

index.d.ts Outdated
*/
json?: unknown;
json?: object | Array | string | number | boolean | null;
Copy link
Collaborator

@sholladay sholladay Jan 18, 2020

Choose a reason for hiding this comment

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

I am going to undo this change because it is unrelated to supporting non-object JSON values and there is some history behind why the TypeScript type is unknown. @sindresorhus made this change and explained it in #133 (comment). As far as I know, there still isn't a great solution for that

We can talk about it in the comments here in this PR or you can open a new issue if you want the TypeScript types to be stricter. For now, I think unknown is sufficient to support the changes in this PR.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm 👎 on undoing this. I think Sindre was talking about response, not request. When it comes to parsing response, then yes, you have to cast it on your own because you don't know if it's a string, a number or whatever (one of the types mentioned later in this comment). On the other hand, we don't care about the JSON body as long as it is valid JSON. See, it's valid only when it is string | number | boolean | null | {[key: string]: ...} | Array<...>.

@sindresorhus Can you clarify please?

Copy link
Collaborator

@sholladay sholladay Jan 18, 2020

Choose a reason for hiding this comment

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

Apologies, I linked to the wrong comment. I edited and fixed the link.

This just made the option harder to use in practice. JSON.stringify() accepts unknown too. And it didn't account for when your have custom types with a toJSON() method. So all in all, the strict type had very little value.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Apologies, I linked to the wrong comment. I edited and fixed the link.

No problem.

And it didn't account for when your have custom types with a toJSON() method.

Well, what about this:

interface JSONable extends Object {
    toJSON(): string;
}

(not sure if it works, not at home atm)

Copy link
Owner

Choose a reason for hiding this comment

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

unknown is the only pragmatic type here. I've tried making it strict before and it just turned out to be an inconvenience to users without much value.

Copy link
Collaborator

@sholladay sholladay left a comment

Choose a reason for hiding this comment

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

LGTM as-is, but we can wait for Sindre's response in regards to the JSONAble interface (I think it's a good idea if it works as expected).

@sindresorhus sindresorhus changed the title Fix handling of falsey json option Fix handling of falsy value with the json option Jan 19, 2020
@sindresorhus sindresorhus merged commit b20779b into sindresorhus:master Jan 19, 2020
@sindresorhus
Copy link
Owner

Thanks for fixing this 👌

@elijahsmith
Copy link
Contributor Author

I can open a new ticket for this discussion if you'd rather, but, I'm not completely comfortable with the way this was finally resolved.

With the current fix, given

var foo;  // undefined

there is a functional difference between:

ky.put('/endpoint', {
    body: JSON.serialize(foo)
};

...which will send the body null to the server, and:

ky.put('/endpoint', {
    json: foo
};

...which will send no body at all.

I know this seems like a little thing but, especially with low-level libraries like this, small deviations are surprising and frustrating. Is there a reason not to use 'json' in this._options or Object.hasOwnProperty(this._options, 'json')?

@sholladay
Copy link
Collaborator

sholladay commented Jan 20, 2020

I can open a new ticket for this discussion if you'd rather, but, I'm not completely comfortable with the way this was finally resolved.

I am happy to discuss it further. Let's continue here for now.

there is a functional difference between ...
body: JSON.serialize(foo)
...
json: foo

No, there isn't. You have made some faulty assumptions.

You seem to believe that undefined serializes to null, but as I mentioned here, that is incorrect. In reality, undefined === JSON.stringify(undefined). Both are equivalent: they send no body and the Content-Length is 0, regardless of whether you use the json option or the body option.

Is there a reason not to use 'json' in this._options ... ?

Yes. As I mentioned here, this would make it hard to unset the json option, especially on extended instances. You are assuming that the user can delete options.json to unset it, but that is only true if the user uses ky() directly. Here is an example where that would fail, because the options merger used by ky.extend() wouldn't be able to detect that you delete'd a property, so it would simply think you aren't overriding json.

const myOptions = {
    json : { time : Date.now() }
};
const myKy1 = ky.extend(myOptions);
myKy1.post('/ping');  // sends json

delete myOptions.json;  // does not affect myKy1, as options are already set
const myKy2 = myKy1.extend(myOptions);
myKy2.post('/ping');  // bug: still sends json, even though we intended not to

The above code is not "fixed" by using this._options.json !== undefined, but it does mean you can unset the option by simply setting it to undefined, as shown below.

const myOptions = {
    json : { time : Date.now() }
};
const myKy1 = ky.extend(myOptions);
myKy1.post('/ping');  // sends json

myOptions.json = undefined;  // does not affect myKy1, as options are already set
const myKy2 = myKy1.extend(myOptions);
myKy2.post('/ping');  // does not send json (yay!)

Is there a reason not to use ... Object.hasOwnProperty(this._options, 'json')?

This has the same problems as 'json' in this._options, in that overrides won't work correctly. Beyond that, I don't see a need for a hasOwnProperty check. We spread the options argument into this._options. All properties of this._options (other than the language built-ins) are own properties, including json. Yet it's also not meaningful whether json is an own property or a prototype property.

@elijahsmith
Copy link
Contributor Author

thanks for the followup...

You seem to believe that undefined serializes to null, but as I mentioned here, that is incorrect. In reality, undefined === JSON.stringify(undefined)

this is correct, and I was wrong, I extrapolated incorrectly from JSON.stringify([undefined]) == "[null]", and should have investigated further! I appreciate the thorough response.

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.

falsey values in json prop are not sent to server
4 participants