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

DELETE requests should return specified Content-Type #85

Merged
merged 9 commits into from May 8, 2019

Conversation

joeb1415
Copy link
Contributor

@joeb1415 joeb1415 commented May 3, 2019

DELETE requests are returning Content-Type: text/html, this is breaking some clients that expect application/json, even though there is no actual content, simply the type in the header broke a lookup. Looks like this was introduced here, as text/html is the Flask response default mimetype.

Rolling that back here and adding a test.

@joeb1415 joeb1415 requested a review from a team as a code owner May 3, 2019 08:55
@ghost ghost assigned airstandley May 3, 2019
@Sytten
Copy link

Sytten commented May 3, 2019

Currently I am returning an empty string if the response is 204, can we write a test to make sure this behaviour still works. It was the only way to make it set the application/json. If the schema is none, we should probably not set the data in the returned response?

@@ -248,6 +248,46 @@ def get_me():
)
self.assertEqual(resp.status_code, 400)

def test_validate_headers_delete(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

if you add a docstring comment the testrunner output is going to improve in readability.
Not a blocking issue though

Copy link
Contributor

@kazamatzuri kazamatzuri left a comment

Choose a reason for hiding this comment

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

lgtm

@kazamatzuri
Copy link
Contributor

oh well, I guess my review doesn't count;)

Copy link
Contributor

@airstandley airstandley left a comment

Choose a reason for hiding this comment

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

I am not sure this is correct.
If no schema is defined, then we should not be assuming the structure of the response. It seems like it could be a legitimate use case to return a string that is "text/html" or any other content type.
This would be a breaking change for such a case.

The entire rebar system builds off the definition of explicit schemas for every potential response type.
I would question why the "bad" DELETE responses are not correctly defining a response schema, as that should address this.
Unless I am misunderstanding the issue?

Edit: I should clarify. I don't actually think returning anything other than a JSON from a API wrapped with rebar is a legitimate case we should support, but it is a case we currently support (to the best of my knowledge). I think it should be a very clear breaking change if we start enforcing JSON format returns rather than quietly attempting to dump them to a json.

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 3, 2019

My thinking is that our response header should honor what our registry produces states. But what is happening now is we are letting the flask response default text/html get in there before we define what we intend for that header, so our intended application/json (or any provided produces value, is not used.

@airstandley
Copy link
Contributor

My thinking is that our response header should honor what our registry produces states.

@joeb1415 That would make sense to me, but (as far as I can see) produces is only used by the swagger file generator; it is not part of the HandlerRegistry definition.
I'd be a fan of either:

  • Pulling the produces value from the swagger_generator
  • Adding the ability to define what content-type the HandlerRegistry produces via an init argument
  • Creating a V2 HandlerRegistry that enforces the production of a 'application/json' content type and defines a default schema to use when no schema is specified so we remove the ambiguous case of no/None schema.

I think that it should then be safe to either set the 'Content-Type' header based on what the registry 'produces', or at least error out when it does not match. That should solve what seems to be the root of your issue which is that we are relying on Flask's default mime type tools to set a header whose value our clients rely on being a certain value.

@airstandley
Copy link
Contributor

@joeb1415 Looking into it a bit more, you could also modify your flask app to resolve this by changing the response_class for your application to one whose default_mimetype is "application/json".

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 5, 2019

Is it not fair to standardize all responses as Content-Type JSON? This was the case prior to the commit I linked above, I feel like it's more of an accident that some return HTML now.

FWIW, v3 doesn't accept a produces at all, it just specifies application/json in all cases:
https://github.com/plangrid/flask-rebar/blob/master/flask_rebar/swagger_generation/swagger_generator_v3.py#L255

I'll take a quick look at adding an explicit control of this but I'm curious if it's actually a regression to change the current implementation back to JSON.

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 5, 2019

I tried adding a produces attribute to the handler. LMK what folks think.

@Sytten
Copy link

Sytten commented May 5, 2019

I like it, it's clean. Maybe rebar could provide an enum or something similar with all the content types?

@airstandley
Copy link
Contributor

Is it not fair to standardize all responses as Content-Type JSON? This was the case prior to the commit I linked above, I feel like it's more of an accident that some return HTML now.

Yes totally fair, but then I think we also should be standardizing on all responses defining a Schema. I just don't agree with the middle ground of not enforcing a schema but enforcing a content type.

@@ -102,6 +103,7 @@ def _wrap_handler(
request_body_schema=None,
headers_schema=None,
marshal_schema=None,
produces=None,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not a fan of this interface.

Its conflated with "produces" from OpenAPI 2.0.

Also, this doesn't actually guarantee that the handler actually returns the content-type specified here. It just patches the headers.

So maybe this should be a "headers" parameter, with extra headers to set on the response? That seems more flexible and more clear.

def delete_me():
return None, 204

# Test DELETE with non-None return schema
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we make this a separate test method?

self.assertEqual(resp.status_code, 204)
self.assertEqual(resp.data.decode("utf-8"), "")
self.assertEqual(
next(header[1] for header in resp.headers.to_list() if header[0] == "Content-Type"),
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like it should be possible to do resp.headers['Content-Type'], or something similar?

@barakalon
Copy link
Contributor

Chiming in a little late here:

There should be no "content-type" header when there is no content. However, its customary to add this header regardless to support clients that incorrectly attempt to parse this header.

Given that flask-rebar generally returns JSON, it seems totally reasonable to just make it default to "application/json" when there is no content.

With that - I think this is more reason to add actual content negotiation.

self.assertEqual(resp.status_code, 204)
self.assertEqual(resp.data.decode("utf-8"), "")
self.assertEqual(
next(header[1] for header in resp.headers.to_list() if header[0] == "Content-Type"),
Copy link
Collaborator

Choose a reason for hiding this comment

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

As above. e.g. https://werkzeug.palletsprojects.com/en/0.14.x/quickstart/ includes the example

>>> response.headers['content-type']
'text/plain; charset=utf-8'

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 6, 2019

Looks like werkzeug will always pass a Content-Type header. I agree it makes sense to not specify a content type when there is no content, and we could take care of removing it on our end when the response schema is None, but perhaps this suggests that it is a standard header to always include.
https://github.com/pallets/werkzeug/blob/0.15.2/src/werkzeug/wrappers/base_response.py#L189-L191

Good thoughts on the more flexible headers param, I'll try that out.

@barakalon
Copy link
Contributor

@joeb1415 FWIW, its also possible to set the headers with the return value: https://github.com/plangrid/flask-rebar/blob/master/flask_rebar/rebar.py#L66

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 7, 2019

@barakalon mind taking another look to let me know if you still have changes or if we're ✅ ?

@RookieRick
Copy link
Contributor

Gonna go ahead and merge this one and push out a patch version to close out incident - Opening a new issue #91 to hash out some of the finer details of content-type negotiation

@RookieRick RookieRick merged commit 8d1bca2 into master May 8, 2019
@@ -59,8 +65,17 @@ def response(data, status_code=200, headers=None):
:rtype: flask.Response
"""
resp = jsonify(data)

if not get_json_from_resp(resp=resp):
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like a lot of unnecessary processing (i.e. we're loading all responses into memory, even potentially very large ones). It also is strange behavior, in that it seems to be catering to another part of our code that isn't clear from just the context of this function.

What's the reasoning here?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, what if a developer actually wants to return an empty object? This will lead to very confusing behavior - it will replace their empty object with an empty string.

Copy link
Contributor

Choose a reason for hiding this comment

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

Or possibly even more possible, what if a developer wants to return an empty list?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Again, matching existing behavior:
https://github.com/plangrid/flask-rebar/blob/master/tests/test_rebar.py#L319

Would make sense to change that, but that would be a change from what we had previously.

Could save the processing here by just checking for other various almost-falsey cases. b"" or b"{}" don't evaluate to false, but do when decoded.


schema = marshal_schema[status_code] # May raise KeyError.

# The schema may be declared as None to bypass marshaling (e.g. for 204 responses).
if schema is None:
return make_response((data or "", status_code, headers))
return response(data=data or {}, status_code=status_code, headers=headers)
Copy link
Contributor

Choose a reason for hiding this comment

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

I see that we later replace {} with an empty string... but I'm not sure why?

An empty object is not empty content. I.e. if I have a marshal schema of {204: None}, this looks like it adds '{}' to the response content. Which wouldn't be right.

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 replaced with an empty string to match the existing unit test:
https://github.com/plangrid/flask-rebar/blob/master/tests/test_rebar.py#L319

@barakalon
Copy link
Contributor

Sorry for the delay @joeb1415

I think this introduced some subtle bugs in the way it handles "falsey" responses.

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 8, 2019

@barakalon no worries! I think these changes were consistent, but let me know if you think otherwise. Also if we want to actually change what we return in the None schema case, I'm open to that too.

@joeb1415 joeb1415 deleted the DELETE-response-content-type branch May 8, 2019 18:54
@barakalon
Copy link
Contributor

@joeb1415 sorry, I'm going to push back again

By moving the logic into the underlying response function, which is used to marshal all responses, even when the schema is None, I think we've introduced a bug. And I think that's just not tested yet.

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 8, 2019

@barakalon hmm, what do you make of the test I linked to there? We had already been returning an empty string when None response schema is specified (that test is unchanged). I'm not sure then in what case this could be a regression?

@barakalon
Copy link
Contributor

Try adding a test where the schema is not None, but an object with no required fields, or a schema that is a list at the root level (i.e. initialize it with many=True), and then return {} or [] (respectively).

I would expect the response body to be {} or [], not empty.

@joeb1415
Copy link
Contributor Author

joeb1415 commented May 9, 2019

Gotcha. I added this test here, give it a check!
#93

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

7 participants