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

Respond_with uri extensions path.[json|xml|text|txt|html] #367

Merged
merged 14 commits into from Nov 8, 2017

Conversation

elorest
Copy link
Member

@elorest elorest commented Nov 6, 2017

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

Copy link
Contributor

@marksiemers marksiemers left a 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])
Copy link
Contributor

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" ?

if result.found?
result
else
@routes.find build_node(http_verb, resource.sub(/\.[^$\/]+$/, ""))
Copy link
Contributor

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.

@marksiemers
Copy link
Contributor

I know this isn't part of this change, but https://github.com/amberframework/amber/pull/367/files#diff-e43f8575da93c21cf741fdd422c2b60cR78
Should be changed from "Response unexceptable." to "Response Not Acceptable."

Copy link
Contributor

@eliasjpr eliasjpr left a 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]))
Copy link
Contributor

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?

Copy link
Member Author

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.

Copy link
Contributor

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/))
Copy link
Contributor

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

Copy link
Member Author

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.

@@ -83,6 +83,39 @@ module Amber::Controller
context.request.headers["Accept"] = "text/plain"
ResponsesController.new(context).index.should eq expected_result
end

Copy link
Contributor

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?

Copy link
Member Author

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

@@ -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
Copy link
Contributor

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

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 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.

Copy link
Member Author

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.

@marksiemers
Copy link
Contributor

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.

Copy link
Contributor

@eliasjpr eliasjpr left a 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

@@ -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))
Copy link
Contributor

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
Copy link
Contributor

Choose a reason for hiding this comment

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

you can use _paramsor route_params instead of rparams, the latter can be confusing.

Thanks for splitting it into 2 separate methods.

Copy link
Member Author

@elorest elorest Nov 7, 2017

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])
Copy link
Contributor

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

Copy link
Member Author

@elorest elorest Nov 7, 2017

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.

Copy link
Contributor

@marksiemers marksiemers left a 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.

end
route.params
end
Copy link
Contributor

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?

end

it "responds with 406 for path.text when text hasn't been defined" do
expected_result = "Response unexceptable."
Copy link
Contributor

Choose a reason for hiding this comment

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

"Response Not Acceptable"

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

context.request.path = "/response/1.texas"
context.request.headers["Accept"] = "text/html"
ResponsesController.new(context).index.should eq expected_result
end
Copy link
Contributor

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?

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 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.

Copy link
Member Author

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.

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 can set it though.

Copy link
Contributor

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 :)

Copy link
Contributor

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

Copy link
Member Author

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.

Copy link
Contributor

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
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 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.

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 would have done that but I needed to return @type.to_s so that @type wouldn't have the possibility of being Nil.

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 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
Copy link
Contributor

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?

Copy link
Member Author

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/
Copy link
Contributor

Choose a reason for hiding this comment

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

ACCEPT_SEPARATOR_REGEX

Copy link
Member Author

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("|")})$/
Copy link
Contributor

Choose a reason for hiding this comment

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

CONTENT_TYPE_REGEX

Copy link
Member Author

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))
Copy link
Contributor

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

Copy link
Member Author

@elorest elorest Nov 7, 2017

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.

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 guess the main thing for me here is avoiding as many branches and memory allocations as I can.

Copy link
Contributor

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

Copy link
Member

@drujensen drujensen left a comment

Choose a reason for hiding this comment

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

Lgtm

Copy link
Contributor

@marksiemers marksiemers left a 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.

@elorest elorest merged commit 5dded7e into master Nov 8, 2017
@eliasjpr eliasjpr deleted the is/path_extensions branch November 8, 2017 00:34
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])
Copy link
Contributor

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?

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 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.

@eliasjpr eliasjpr added this to the 0.3.5 milestone Nov 14, 2017
elorest added a commit that referenced this pull request Nov 17, 2017
* 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
elorest added a commit that referenced this pull request Nov 17, 2017
* 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
@faustinoaq faustinoaq added this to Done in Framework 2018 May 5, 2018
@faustinoaq faustinoaq removed this from Done in Framework 2018 Jun 12, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
No open projects
Development

Successfully merging this pull request may close these issues.

None yet

5 participants