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
Respond_with uri extensions path.[json|xml|text|txt|html] #367
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a couple small changes requested.
I do have a larger question though. Does this eagerly render all response types?
If the controller action provides 5 different response types, will it render them all and just return the correct one?
If that is the case, I think it should be designed where the methods html
, xml
, etc. accept a block or proc and only invoke when needed for the response.
if content.body | ||
set_response(body: content.body, status_code: 200, content_type: content.type) | ||
else | ||
set_response(body: "Response unexceptable.", status_code: 406, content_type: Content::TYPE[:text]) | ||
set_response(body: "Response unexceptable.", status_code: 406, content_type: Response::TYPE[:text]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be - "Response Not Acceptable" ?
src/amber/router/router.cr
Outdated
if result.found? | ||
result | ||
else | ||
@routes.find build_node(http_verb, resource.sub(/\.[^$\/]+$/, "")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this regex be named with a constant to indicate what it is for? It is meant to chop off the extension, right? .json or .text, etc.
I know this isn't part of this change, but https://github.com/amberframework/amber/pull/367/files#diff-e43f8575da93c21cf741fdd422c2b60cR78 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left some comments. Forgot to mention that theres is an existing format method in the context.cr that you can reuse/re-purpose https://github.com/amberframework/amber/blob/master/src/amber/router/context.cr#L72
Nice and clean PR!
@@ -51,8 +61,9 @@ module Amber::Controller::Helpers | |||
|
|||
private def requested_responses | |||
req_responses = Array(String).new | |||
|
|||
if (accept = context.request.headers["Accept"]?) && !accept.empty? | |||
if (ext = request.path.match(Content::REGEX).try(&.[1])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about using .last
instead of &.[1]
since is more readable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A regex match returns a hash instead of an array, 1 is just a key for the first capture group. Similar to $1 in perl.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does it break with request.path.match(Content::REGEX)[1]?
?
if (accept = context.request.headers["Accept"]?) && !accept.empty? | ||
if (ext = request.path.match(Content::REGEX).try(&.[1])) | ||
req_responses << Content::TYPE[ext] | ||
elsif (accept = context.request.headers["Accept"]?) && !accept.empty? | ||
accepts = accept.split(";").first?.try(&.split(/,|,\s/)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make this regex /,|,\s/
a constant that shows intent
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To me 4 regex characters are easier to read than tracking down the definition of a constant, but sure.
spec/amber/controller/base_spec.cr
Outdated
@@ -83,6 +83,39 @@ module Amber::Controller | |||
context.request.headers["Accept"] = "text/plain" | |||
ResponsesController.new(context).index.should eq expected_result | |||
end | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a test for an invalid (unsupported) extension?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. I'll commit that in a second. I already have a test for a supported but undefined extension.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
src/amber/router/params_parser.cr
Outdated
@@ -78,7 +78,13 @@ module Amber::Router | |||
end | |||
|
|||
def merge_route_params | |||
route_params.each { |k, v| params[k] = v } | |||
route_params.each do |k, v| | |||
if k == route_params.keys.last |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This 2 lines should probably be in its own method, called format
. I forgot to tell you in the previous PR that we have already define a similar method in the context https://github.com/amberframework/amber/blob/master/src/amber/router/context.cr#L72
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I format
currently being used for something? My need is just to remove 5 ext's from the end of the last path_param. I could extract it to a method but I don't think moving it to context makes sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it don't remove these then paths like /pets/1.json
try looking up an id of 1.json
in Granite which fails. This isn't where I'm detecting the format, since it will only sometimes be on a param I have to parse it from the path, for responses.
Per discussion with @elorest - invoking the methods without blocks is fast. If it becomes a problem, we can revisit using blocks/procs. For this PR, eager loaded strings are good. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very small suggestions and 1 non blocking comment
src/amber/router/params_parser.cr
Outdated
@@ -29,7 +29,7 @@ module Amber::Router | |||
def parse_params | |||
parse_part(request.query) | |||
if content_type = request.headers["Content-Type"]? | |||
parse_multipart if content_type.try(&.starts_with?(MULTIPART_FORM)) | |||
parse_multipart if content_type.try(&.starts_with?(MULTIPART_FORM)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove extra identation
end | ||
|
||
def route_params_without_ext | ||
rparams = route.params |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can use _params
or route_params
instead of rparams
, the latter can be confusing.
Thanks for splitting it into 2 separate methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just wanted something short and temporary what wouldn't overwrite anything else. To me rparams looked better than _params since it was still more descriptive. WDYT?
@@ -61,10 +62,11 @@ module Amber::Controller::Helpers | |||
|
|||
private def requested_responses | |||
req_responses = Array(String).new | |||
if (ext = request.path.match(Content::REGEX).try(&.[1])) | |||
req_responses << Content::TYPE[ext] | |||
path_ext = request.path.match(Content::REGEX).try(&.[1]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non blocking comment: This would probably read better in a private method called format.
def requested_format
request.path.match(Content::REGEX).try(&.[1])
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly but it's only one source of format. The private method that it's in is already tasked with finding the requested response types (formats) from currently 2 locations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some naming, style, and syntax changes requested.
Also, if we can check for the correct response content-type
header in the specs.
src/amber/router/params_parser.cr
Outdated
end | ||
route.params | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For line 90, rparams[key] = r.params[key] ...
Is the thing on the right supposed to be rparams
(sans dot)? If not, r
seems like too short of a method name.
And changing the value at rparams[key]
also changes it in route.params
? If acting destructively on route.params
and returning it, why the need for rparams
?
spec/amber/controller/base_spec.cr
Outdated
end | ||
|
||
it "responds with 406 for path.text when text hasn't been defined" do | ||
expected_result = "Response unexceptable." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Response Not Acceptable"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
spec/amber/controller/base_spec.cr
Outdated
context.request.path = "/response/1.texas" | ||
context.request.headers["Accept"] = "text/html" | ||
ResponsesController.new(context).index.should eq expected_result | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a way to check the content-type
header in these tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think so. I'll check it I guess. Since they're all set at the some time I didn't think it was important to set that since the response was right.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For some reason I'm getting an error that content_type
doesn't exist on response. When I try to test it's value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can set it though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In base_spec we should be verifying the context context.response
as well. This is ResponsesController.new(context).index.should eq expected_result
totally valid, at least confirm the content-type and status code are correct. For instance
context.response.body.should
context.response.status_code.should
context.response.headers["Content-Type"].should
I think it is important that is done this way because something else could be changing the response. This is how I found the issue with the redirect
:)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unless you are covering testing status code and content-type in the unit test for Responders::Content context.response.status_code.should
context.response.headers["Content-Type"].should
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I was put off by the fact that there was no getter for content_type. Makes sense that it's getting written to the header though. I'll test that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
context.response.headers["Content-Type"]?
will report a failure with helpful info rather than an error when nil
@@ -35,13 +45,14 @@ module Amber::Controller::Helpers | |||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we move @type ||=
into this method? rather than needing the begin statement down below.
def type
@type ||= select_type.to_s
end
I like the convention of
def property_name
@property_name ||= calculate_property_name
end
Of course, 'calculate' can be 'select' or 'generate' - what ever verb makes sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would have done that but I needed to return @type.to_s so that @type wouldn't have the possibility of being Nil.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did come up with a solution here that I think we'll both like.
end | ||
|
||
private def select_type | ||
@type ||= begin | ||
raise "You must define at least one response_type." if @available_responses.empty? | ||
@requested_responses << @available_responses.keys.first | ||
# NOTE: If only one response is requested don't return something else. | ||
@requested_responses << @available_responses.keys.first if @requested_responses.size != 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, this is "non-strict" mode - where even if they didn't explicitly request the type we return the first available response (if they had more than one thing in the 'accept' header), correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While I feel that if json is the only thing available it should be return even if the browser is asking for html, it seems like a different case if someone asks for domain.com/cats.json
. In that case it will return 406 if json doesn't exist.
} | ||
|
||
REGEX = /\.(#{TYPE.keys.join("|")})$/ | ||
COMMA_OR_COMMASPACE_REGEX = /,|,\s/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ACCEPT_SEPARATOR_REGEX
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
xml: "application/xml", | ||
} | ||
|
||
REGEX = /\.(#{TYPE.keys.join("|")})$/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CONTENT_TYPE_REGEX
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
if (path_ext) | ||
req_responses << Content::TYPE[path_ext] | ||
elsif (accept = context.request.headers["Accept"]?) && !accept.empty? | ||
accepts = accept.split(";").first?.try(&.split(Content::COMMA_OR_COMMASPACE_REGEX)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is my inner ruby-ist thinking out loud.
Take it or leave it.
private def type_from_path_extension : String | Nil
return @type_from_path_extension if @type_from_path_extension_checked
@type_from_path_extension_checked = true
@type_from_path_extension = request.path.match(Content::REGEX).try(&.[1])
end
private def accepts_from_headers : Array(String)
@accepts_from_headers ||= parse_accepts_from_headers
end
private def parse_accepts_from_headers : Array(String)
accept_header = context.request.headers["Accept"]?
if accept_header
accept_header.split(";").first?.try(&.split(Content::COMMA_OR_COMMASPACE_REGEX))
else
[] of String
end
end
private def requested_responses : Array(String)
return [type_from_path_extension] unless type_from_path_extension.nil?
accepts_from_headers
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do like the idea of not defining the Array in the beginning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the main thing for me here is avoiding as many branches and memory allocations as I can.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this:
private def type_from_path_extension : String | Nil
request.path.match(Content::REGEX).try(&.[1])
end
private def parse_accepts_from_headers : Array(String)
accept_header = context.request.headers["Accept"]?
if accept_header
accept_header.split(";").first?.try(&.split(Content::COMMA_OR_COMMASPACE_REGEX))
else
[] of String
end
end
private def requested_responses : Array(String)
if (type_from_path = type_from_path_extension)
[type_from_path]
else
parse_accepts_from_headers
end
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lgtm
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If testing for content-type isn't a huge uphill and is possible, add that, otherwise good to go.
end | ||
|
||
protected def respond_with(&block) | ||
content = with Content.new(requested_responses) yield | ||
if content.body | ||
set_response(body: content.body, status_code: 200, content_type: content.type) | ||
else | ||
set_response(body: "Response unexceptable.", status_code: 406, content_type: Content::TYPE[:text]) | ||
set_response(body: "Response Not Acceptable.", status_code: 406, content_type: Content::TYPE[:text]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @elorest I didn't see it 😅
Why not raise Amber::Exception::NoAcceptable
so we can use Amber::Pipe::Error
here 👉 https://github.com/amberframework/amber/blob/master/src/amber/router/pipe/error.cr#L19 and be hable to customize the 406 reponse. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was under the impression that we didn't want to raise exceptions because it was slower or something. I remember @eliasjpr saying that was the reason he made halt instead of just raising and exception to handle later. Also since I'm setting a response anyone it sort of seems natural to set the other response and message.
* works but reveals another issue with params... :{ * handled extensions for respond_with * added tests and made video demo * changed from response back to content * modified some code as requested * accidentally undid something before my last commit * fixed requested changes * changed unacceptable response * refactored a bit more * done * added content-type and status_code to tests
* works but reveals another issue with params... :{ * handled extensions for respond_with * added tests and made video demo * changed from response back to content * modified some code as requested * accidentally undid something before my last commit * fixed requested changes * changed unacceptable response * refactored a bit more * done * added content-type and status_code to tests Former-commit-id: 8362a62
Description of the Change
A few small changes to allow extensions to work for choosing content type. I've also modified the response priority so that it will return a 406 if you only ask for one content type and it's not defined. If you ask for more it will return the first match or the first defined response.
Youtube Example
https://www.youtube.com/watch?v=6KNjWDRUo_c