Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Action Pack Variants #12977

Merged
merged 2 commits into from

9 participants

@strzalek
Collaborator

By default, variants in the templates will be picked up if a variant is set and
there's a match. The format will be:

  app/views/projects/show.html.erb
  app/views/projects/show.html+tablet.erb
  app/views/projects/show.html+phone.erb

If request.variant = :tablet is set, we'll automatically be rendering the html+tablet template.

In the controller, we can also tailer to the variants with this syntax:

  class ProjectsController < ActionController::Base
    def show
      respond_to do |format|
        format.html do |html|
          @stars = @project.stars

          html.tablet { @notifications = @project.notifications }
          html.phone  { @chat_heads    = @project.chat_heads }
        end

        format.js
        format.atom
      end
    end
  end

The variant itself is nil by default, but can be set in before filters, like so:

  class ApplicationController < ActionController::Base
    before_action do
      if request.user_agent =~ /iPad/
        request.variant = :tablet
      end
    end
  end

This is modeled loosely on custom mime types, but it's specifically not intended
to be used together. If you're going to make a custom mime type, you don't need
a variant. Variants are for variations on a single mime types.

/cc @dhh @josevalim

actionpack/lib/action_controller/metal/rendering.rb
@@ -57,6 +57,10 @@ def _normalize_options(options) #:nodoc:
if options[:status]
options[:status] = Rack::Utils.status_code(options[:status])
end
+ require 'pry';binding.pry
@dhh Owner
dhh added a note

This seems to be a debug statement left in?

@strzalek Collaborator

Shit, I knew I've missed something. Thanks! ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_dispatch/http/request.rb
@@ -269,6 +270,15 @@ def session_options=(options)
Session::Options.set @env, options
end
+ def variant=(variant)
+ raise ActionController::VariantTypeMismatch, "request.variant value should be symbol (got: #{variant.class})" unless variant.is_a?(Symbol)
@dhh Owner
dhh added a note

I'd break this into:

if variant.is_a? Symbol
  @variant = variant
else
  raise ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_dispatch/http/request.rb
@@ -269,6 +270,18 @@ def session_options=(options)
Session::Options.set @env, options
end
+ def variant=(variant)
+ if variant.is_a? Symbol
+ @variant = variant
+ else
+ raise ActionController::VariantTypeMismatch, "request.variant value should be symbol (got: #{variant.class})"
@jeremy Owner
jeremy added a note

Good place to advise against request.variant = params[:something].to_sym which will be a natural response to this error, introducing a denial-of-service vulnerability. Always sanitize or whitelist user-provided data.

@rafaelfranca Owner

:+1:

@strzalek Collaborator

Any suggestion how it should look like?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
@@ -417,13 +417,30 @@ def custom(mime_type, &block)
@responses[mime_type] ||= block
end
- def response
- @responses.fetch(format, @responses[Mime::ALL])
+ def response(request)
@rafaelfranca Owner

Do we need to receive the request if we only use variant?

@rafaelfranca Owner

Line 422 is only using response, not request

@strzalek Collaborator

Yeah, ignore my comment :sleeping:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
((13 lines not shown))
end
def negotiate_format(request)
@format = request.negotiate_mime(@responses.keys)
end
+
+ class VariantFilter
+ attr_accessor :variant
@rafaelfranca Owner

Do we need the accessor?

@strzalek Collaborator

No we don't.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca commented on the diff
actionpack/lib/action_controller/metal/mime_responds.rb
@@ -187,7 +187,7 @@ def respond_to(*mimes, &block)
raise ArgumentError, "respond_to takes either types or a block, never both" if mimes.any? && block_given?
@rafaelfranca Owner

Documentation of this module need to be updated.

@strzalek Collaborator

Well that behavior actually didn't change. This message is still valid as you still need to pass either types or block however it is extended and inside "types block" you can pass variant now, but it's optional. I think changing that message to mention variant will only introduce confusion. What do you think? How do you suggest to change it?

@rafaelfranca Owner

I'm pointing the wrong line. I was talking about the respond_to Rdoc that needs to mention the variants now.

@strzalek Collaborator
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_dispatch/http/request.rb
@@ -269,6 +270,18 @@ def session_options=(options)
Session::Options.set @env, options
end
+ def variant=(variant)
+ if variant.is_a? Symbol
+ @variant = variant
+ else
+ raise ActionController::VariantTypeMismatch, "request.variant value should be symbol (got: #{variant.class})"
+ end
+ end
+
+ def variant
+ @variant
+ end
@jeremy Owner
jeremy added a note

attr_reader :variant - ?

@jeremy Owner
jeremy added a note

variant belongs in action_dispatch/http/mime_negotiation.rb along with format

@strzalek Collaborator

:+1:

@strzalek Collaborator

Absolutely, haven't thought of that, just blindly putted it into request. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/rendering.rb
@@ -58,6 +58,10 @@ def _normalize_options(options) #:nodoc:
options[:status] = Rack::Utils.status_code(options[:status])
end
+ if defined?(request) && request.variant.present?
@rafaelfranca Owner

Why do we need to check if request is defined?

@strzalek Collaborator

We need, otherwise ActionView will complain as it don't have request object set unless it's in actionpack company.

@rafaelfranca Owner

I see. It is weird since this method is inside Action Pack and I don't see why Action View would call it without Action Pack.

@strzalek Collaborator

Yes, that's right, however integration tests were failing because of this if I remember correctly. I can double check this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_dispatch/http/request.rb
@@ -269,6 +270,18 @@ def session_options=(options)
Session::Options.set @env, options
end
+ def variant=(variant)
+ if variant.is_a? Symbol
+ @variant = variant
+ else
+ raise ActionController::VariantTypeMismatch, "request.variant value should be symbol (got: #{variant.class})"
+ end
+ end
+
+ def variant
@rafaelfranca Owner

attr_reader

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionview/lib/action_view/rendering.rb
@@ -99,7 +108,7 @@ def _render_template(options) #:nodoc:
def _process_format(format) #:nodoc:
super
lookup_context.formats = [format.to_sym]
- lookup_context.rendered_format = lookup_context.formats.first
+ lookup_context.rendered_format = format.to_sym
@rafaelfranca Owner

we can avoid the repeated to_sym call here

@jeremy Owner
jeremy added a note

What motivated this change? It appears to be unrelated.

@strzalek Collaborator

Unrelated indeed. Small refactoring, passing format seemed more natural and shorter however as @rafaelfranca noted I'm doing to_sym twice now. I'll revert this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/test/controller/mime/respond_to_test.rb
((8 lines not shown))
+ assert_equal "mobile", @response.body
+ end
+
+ def test_variant_set_in_request_with_respond_to
+ @request.variant = :phone
+ get :variant_set_in_request_with_respond_to
+ assert_equal "text/html", @response.content_type
+ assert_equal "mobile", @response.body
+ end
+
+ def test_variant_set_in_respond_to_inside_format
+ @request.variant = :tablet
+ get :variant_set_in_respond_to_inside_format
+ assert_equal "text/html", @response.content_type
+ assert_equal "tablet", @response.body
+ end
@jeremy Owner
jeremy added a note

The naming of these tests and the controller actions makes it look like they're testing where request.variant is set.

They're really testing how implicit rendering and respond_to deal with the variant.

Also needs test coverage demonstrating what happens when the variant is not set or when it's set to a variant that is not available.

@strzalek Collaborator

I'll change the naming and add test for missing variant.

Scenarios where variant is not set are all other tests in this file where and I don't feel need to add additional to test that. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/test/dispatch/request_test.rb
@@ -836,6 +836,20 @@ def url_for(options = {})
end
end
+ test "setting variant" do
+ request = stub_request
+ request.variant = :mobile
+ assert_equal :mobile, request.variant
+ end
+
+ test "setting variant with non symbol value" do
+ request = stub_request
+ assert_raise(ActionController::VariantTypeMismatch) do
@jeremy Owner
jeremy added a note

ArgumentError is appropriate, rather than a specialized exception.

@strzalek Collaborator

:+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/test/dispatch/request_test.rb
@@ -836,6 +836,20 @@ def url_for(options = {})
end
end
+ test "setting variant" do
+ request = stub_request
+ request.variant = :mobile
+ assert_equal :mobile, request.variant
+ end
+
+ test "setting variant with non symbol value" do
+ request = stub_request
+ assert_raise(ActionController::VariantTypeMismatch) do
+ request.variant = "mobile"
+ assert_equal :mobile, request.variant
@jeremy Owner
jeremy added a note

This assertion will never be reached. It should be omitted for clarity.

@strzalek Collaborator

Of course it won't. Too much copy&paste :/ Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionview/lib/action_view/rendering.rb
@@ -77,6 +77,14 @@ def view_renderer
@_view_renderer ||= ActionView::Renderer.new(lookup_context)
end
+ # Find and renders a template based on the options given.
@jeremy Owner
jeremy added a note

Finds and renders or Find and render

@strzalek Collaborator

:+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionview/lib/action_view/rendering.rb
@@ -77,6 +77,14 @@ def view_renderer
@_view_renderer ||= ActionView::Renderer.new(lookup_context)
end
+ # Find and renders a template based on the options given.
+ # :api: private
+ def _render_template(options) #:nodoc:
@rafaelfranca Owner

This method should not be here :smile:

@josevalim Owner

Yup, I was trying to understand why it is here and why we need it in the first place. :)

@strzalek Collaborator

Yeah, messed this up while rebasing :dizzy_face:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca commented on the diff
actionview/lib/action_view/template/resolver.rb
@@ -240,7 +240,9 @@ def extract_handler_and_format(path, default_formats)
end
handler = Template.handler_for_extension(extension)
- format = pieces.last && Template::Types[pieces.last]
+ format = pieces.last && pieces.last.split(EXTENSIONS[:variants], 2).first # remove variant from format
@rafaelfranca Owner

maybe is good the extract a method to this

@strzalek Collaborator

Is it? Well that method is is already an extraction - https://github.com/strzalek/rails/blob/a5c609f1290f2ca071f8980eb08f3529513bbdee/actionview/lib/action_view/template/resolver.rb#L231

Extracting this small pice of code won't be beneficial in any way in my opinion. What do you think?

@rafaelfranca Owner

:+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionview/lib/action_view/rendering.rb
@@ -92,6 +100,7 @@ def rendered_format
# :api: private
def _render_template(options) #:nodoc:
lookup_context.rendered_format = nil if options[:formats]
+ lookup_context.variants = [options[:variant]] if options[:variant]
@rafaelfranca Owner

we can avoid an extra hash lookup storing the value in a local variable. WDYT?

@strzalek Collaborator

:+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/rendering.rb
@@ -58,6 +58,10 @@ def _normalize_options(options) #:nodoc:
options[:status] = Rack::Utils.status_code(options[:status])
end
+ if defined?(request) && request.variant.present?
+ options[:variant] = request.variant
+ end
@jeremy Owner
jeremy added a note

This method is an awkward spot to set the variant. _normalize_options is used to normalize other render options, not seed values from the request. See how the format is passed from request to controller to view for inspiration.

@josevalim Owner
@strzalek Collaborator

I must say that I felt the same with it and to be honest didn't know where would be right place to put it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/CHANGELOG.md
@@ -1,3 +1,51 @@
+* Action Pack Variants
+
+ By default, variants in the templates will be picked up if a variant is set
+ and there's a match. The format will be:
+
+ app/views/projects/show.html.erb
+ app/views/projects/show.html+tablet.erb
+ app/views/projects/show.html+phone.erb

Please indent code with 4 spaces so that it highlights properly on changelog markdown.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@strzalek
Collaborator

Thanks for the review, it's very valuable for me! <3

I've just pushed commit with fixes addressing your comments (separate commit to have nice diff, i'll squash it before merging). I've left some open questions questions and didn't moved setting variant from _normalize_options as I don't know yet how to do it nicely and don't have time right now to continue working on this. Will be back working on this later today!

@strzalek strzalek was assigned
@strzalek
Collaborator

While polishing this PR I've stuck with the very last thing - nice a logical place to set variant in lookup context (#12977 (comment)). Suprisingly for various reasons it's not that straight forward as one would expect. I got suggestion by @josevalim to change implementation a bit and instead of setting it in request object (request.variant) move this to controller itself and simply delegate this to lookup context (in action: self.variant). The only drawback here is that we won't be able to set variant in middleware easily, however we can overcome this issues coping request.variant value to self.variant at the very beginning.

@dhh @jeremy What do you think?

@dhh
Owner
@jeremy
Owner

Ditto. The request format has a well traveled path from Rack env to middleware to controller action to view resolver. The request variant walks the same path. It the variant diverges and needs some special handling, that's a strong sign that we should double-check why and adjust course so format & variant continue to behave alike.

From the implementation so far, it looks like your sticking point may be due to seeing that request formats are set on the lookup context before processing an action: https://github.com/rails/rails/blob/master/actionpack/lib/action_controller/metal/rendering.rb#L5-L9 If the variant is set before processing, then you can't set the request.variant within an action. No good.

Trouble is, the way we manage the format today is pretty awkward. We set it on the lookup context (aka, the controller) because we don't want the view to "know about" requests. Fine, ok. To fix, set the format and variant on the lookup context just before rendering. The likely candidate for that is _normalize_render.

@strzalek
Collaborator

Thanks guys. I agree with you, it was Jose's suggestion to overcome problem with finding nice place to inject this functionality.

@jeremy nicely described what's going on and suggested _normalize_render. So if the _normalize_render is a blessed place, it means that we've made a circle and end up where we begun - the initial commit, where variant was set in _normalize_options (which is called by _normalize_render: https://github.com/rails/rails/blob/master/actionpack/lib/abstract_controller/rendering.rb#L103-L107).

The best place for this to happen, in my opinion would be _process_format, done in here: strzalek@630e853#diff-7f9aef6572a262683ec9c1fda47118ebL37. However with this change, implicit rendering with variant set (possibly the most common use case) won't work, this needs to be set before this line: https://github.com/rails/rails/blob/master/actionpack/lib/abstract_controller/rendering.rb#L22 (like _normalize_render), not after.

Can it stay the way it was initially proposed then :wink: ?

@jeremy
Owner

_process_format is called after rendering, no?

_normalize_options is specifically purposed to taking an incoming options has and normalize it into an outgoing one. Introducing new options into the normalized hash is out of scope. It is in scope for _normalize_render though.

@strzalek
Collaborator

I've rebased and force pushed commit with all comments addressed.

If there's no other issues, I'll happily merge this :shipit:

@strzalek
Collaborator

@jeremy regarding defined?(request). I'm afraid we need to keep it. We're using request in AbstractController which, as name suggest, is abstract thus in rails world it's either base of ActionController which provides request or ActionMailer which does not. I agree it doesn't look very nice, but I think it needs to stay.
Here's diff with attempt to remove this check which made me realize that it should stay - https://gist.github.com/strzalek/68b407edc52b608fb797 What do you think?

@rafaelfranca removed additional hash lookup and added docs :beers:

actionpack/lib/action_controller/metal/mime_responds.rb
@@ -181,13 +181,59 @@ def clear_respond_to
# end
# end
#
+ # Formats can have different variants
+ #
+ # By default, variants in the templates will be picked up if a variant is set
+ # and there's a match. The format will be:
+ #
+ # app/views/projects/show.html.erb
+ # app/views/projects/show.html+tablet.erb
+ # app/views/projects/show.html+phone.erb
+ #
+ # If `request.variant = :tablet` is set, we'll automatically be rendering the
@rafaelfranca Owner

This is rdoc so will not work, it should berequest.variant = :tablet`

@strzalek Collaborator

Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
@@ -181,13 +181,59 @@ def clear_respond_to
# end
# end
#
+ # Formats can have different variants
@rafaelfranca Owner

missing .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
((30 lines not shown))
+ # end
+ # end
+ # end
+ #
+ # The variant itself is nil by default, but can be set in before filters, like
+ # so:
+ #
+ # class ApplicationController < ActionController::Base
+ # before_action do
+ # if request.user_agent =~ /iPad/
+ # request.variant = :tablet
+ # end
+ # end
+ # end
+ #
+ # This is modeled loosely on custom mime types, but it's specifically not
@rafaelfranca Owner

This seems an implementation detail, maybe we should omit this first sentence and keep others.

@strzalek Collaborator

You're right, I actually think we should either leave it or remove whole paragraph. WDYT?

@rafaelfranca Owner

I prefer to remove the whole paragraph.

@strzalek Collaborator

Me too :+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@strzalek
Collaborator

Thanks @rafaelfranca, I've just pushed fixes to docs.

@strzalek
Collaborator

:shipit:

actionpack/CHANGELOG.md
@@ -1,3 +1,51 @@
+* Action Pack Variants
+
+ By default, variants in the templates will be picked up if a variant is set
+ and there's a match. The format will be:
+
+ app/views/projects/show.html.erb
+ app/views/projects/show.html+tablet.erb
+ app/views/projects/show.html+phone.erb
+
+ If request.variant = :tablet is set, we'll automatically be rendering the
+ html+tablet template.
+
+ In the controller, we can also tailer to the variants with this syntax:
@jeremy Owner
jeremy added a note

In the controller, can we respond to variants just like we respond to formats:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/CHANGELOG.md
@@ -1,3 +1,51 @@
+* Action Pack Variants
+
+ By default, variants in the templates will be picked up if a variant is set
+ and there's a match. The format will be:
+
+ app/views/projects/show.html.erb
+ app/views/projects/show.html+tablet.erb
+ app/views/projects/show.html+phone.erb
+
+ If request.variant = :tablet is set, we'll automatically be rendering the
+ html+tablet template.
@jeremy Owner
jeremy added a note

If request.variant = :tablet, we'll render app/views/projects/show.html+tablet.erb.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/CHANGELOG.md
@@ -1,3 +1,51 @@
+* Action Pack Variants
+
+ By default, variants in the templates will be picked up if a variant is set
+ and there's a match. The format will be:
@jeremy Owner
jeremy added a note

Changelog could use some motivation for the idea of variants and why they're used:

Introducing Variants

The request variant is a specialization of the request format, like :tablet, :phone, or :desktop.

We often want to render different html/json/xml templates for phones, tablets, and desktop browsers. Variants make it easy.

You can set the variant in a before_action:
    request.variant = :tablet if request.user_agent =~ /iPad/

Respond to variants in the action just like you respond to formats:
    respond_to do |format|
      format.html do |html|
        html.tablet # renders app/views/projects/show.html+tablet.erb
        html.phone { extra_setup; render ... }
      end
    end

Provide separate templates for each format and variant:
    app/views/projects/show.html.erb
    app/views/projects/show.html+tablet.erb
    app/views/projects/show.html+phone.erb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
((29 lines not shown))
+ # format.atom
+ # end
+ # end
+ # end
+ #
+ # The variant itself is nil by default, but can be set in before filters, like
+ # so:
+ #
+ # class ApplicationController < ActionController::Base
+ # before_action do
+ # if request.user_agent =~ /iPad/
+ # request.variant = :tablet
+ # end
+ # end
+ # end
+ #
@jeremy Owner
jeremy added a note

Would edit these docs similarly to the suggestion for the Changelog.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_dispatch/http/mime_negotiation.rb
@@ -64,6 +66,15 @@ def formats
end
end
+ # Sets the \variant for template
+ def variant=(variant)
+ if variant.is_a? Symbol
+ @variant = variant
+ else
+ raise ArgumentError, "request.variant value should be symbol (got: #{variant.class})"
@jeremy Owner
jeremy added a note
"request.variant must be set to a Symbol, not a #{variant.class}. For security reasons,
never directly set the variant to a user-provided value, like params[:variant].to_sym.
Check user-provided value against a whitelist first, then set the variant:
  request.variant = :tablet if params[:some_param] == 'tablet'"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeremy jeremy commented on the diff
actionpack/test/controller/mime/respond_to_test.rb
((7 lines not shown))
+ def variant_with_format_and_custom_render
+ request.variant = :mobile
+
+ respond_to do |type|
+ type.html { render text: "mobile" }
+ end
+ end
+
+ def multiple_variants_for_format
+ respond_to do |type|
+ type.html do |html|
+ html.tablet { render text: "tablet" }
+ html.phone { render text: "phone" }
+ end
+ end
+ end
@jeremy Owner
jeremy added a note

What happens when someone does

respond_to do |variant|
  variant.tablet do
    ...

by accident, not understanding the respond_to nesting?

@jeremy Owner
jeremy added a note

Responder blocks can be used to handle multiple formats. What about multiple variants?

respond_to do |format|
  format.html do |html|
    html.tablet
    html.any(:ipod, :iphone) { ... }
    html.all { ... }
  end
end
@strzalek Collaborator

1) I'm afraid we can't do anything here. There's no way to distinguish and guess user behavior. User will get error with hash with tablet in formats array which should give him idea what's wrong (something like that {:locale => [], :formats => [:tablet], :variants => [], :handlers => [:erb]}

2) We don't support any/all for variants, this should be easy to add.

@jeremy Owner
jeremy added a note
  1. What does the error look like if someone does this now?
  2. It's fine to not support that for the first version. However, when someone tries html.all, we will interpret that as an html+all variant. So it'd be a good idea to disallow any and all variant names, at least.
@strzalek Collaborator

1) NameError: uninitialized constant Mime::TABLET
2) :+1:

@jeremy Owner
jeremy added a note
  1. can definitely be improved. We can test whether the MIME type is registered. If it isn't, raise a NoMethodError with a helpful message rather than bubbling up an internal NameError. For example: "To respond to a custom format, register it as a MIME type first: http://guides.rubyonrails.org/action_controller_overview.html#restful-downloads. If you meant to respond to a variant like :tablet or :phone, not a custom format, be sure to nest your variant response within a format response: format.html { |html| html.tablet { ..."
@strzalek Collaborator
@jeremy Owner
jeremy added a note

@strzalek ping - how's the error handling going?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeremy jeremy commented on the diff
actionpack/test/dispatch/request_test.rb
@@ -836,6 +836,19 @@ def url_for(options = {})
end
end
+ test "setting variant" do
+ request = stub_request
+ request.variant = :mobile
+ assert_equal :mobile, request.variant
+ end
+
+ test "setting variant with non symbol value" do
+ request = stub_request
+ assert_raise ArgumentError do
+ request.variant = "mobile"
+ end
+ end
@jeremy Owner
jeremy added a note

Why is the variant required to be a Symbol?

When we're providing an API to set a specialized format based on a user request, we're just begging for developers to set the variant to user-provided data. With security in mind, working with Strings is the API I'd expect if we're assigning data from the outside world.

Alternatively, declaring a whitelist of allowed variants would allow us to safely call .to_sym on any String assigned to request.variant as long as it's whitelisted.

@strzalek Collaborator

We want to avoid situation in which users will blindly or mistakenly pass hashes or arrays as variants (request.variant = params[:users]). Converting this to string if it's hash/array can only cause problems. We need to have type check here and can't rely on user and do typecast on our side.

I don't know if we should whitelist variants though. First thought is no, but I can imagine that we can come up with list of ~5 (?) commonly used variants, which will be used in 90% cases (I can guess that mobile, tablet will land on that list for sure). Don't have opinion here right now.

@tenderlove Owner

Converting this to string if it's hash/array can only cause problems.

Can you describe the specific problems? I have doubts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionview/lib/action_view/testing/resolvers.rb
@@ -21,7 +21,7 @@ def to_s
def query(path, exts, formats)
query = ""
- EXTENSIONS.each do |ext|
+ EXTENSIONS.each do |ext, _|

You can use #each_key.

@strzalek Collaborator

Thanks! :+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@strzalek
Collaborator
actionpack/lib/abstract_controller/rendering.rb
@@ -102,6 +102,7 @@ def _process_format(format)
# :api: private
def _normalize_render(*args, &block)
options = _normalize_args(*args, &block)
+ options[:variant] = request.variant if defined?(request) && request.variant.present?

Shouldn't need this defined? check right?

@strzalek Collaborator
strzalek added a note

I'm afraid we need: #12977 (comment)

If we are leaving this here, I'd leave a comment/todo do remove it in the future, as @rafaelfranca commented, it seems kinda weird having to do this check if we are inside action pack.

@strzalek Collaborator
strzalek added a note
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
((14 lines not shown))
+ # request.variant = :tablet if request.user_agent =~ /iPad/
+ #
+ # Respond to variants in the action just like you respond to formats:
+ #
+ # respond_to do |format|
+ # format.html do |html|
+ # html.tablet # renders app/views/projects/show.html+tablet.erb
+ # html.phone { extra_setup; render ... }
+ # end
+ # end
+ #
+ # Provide separate templates for each format and variant:
+ #
+ # app/views/projects/show.html.erb
+ # app/views/projects/show.html+tablet.erb
+ # app/views/projects/show.html+phone.erb

You can use 2 spaces for the examples in rdoc here.

@strzalek Collaborator
strzalek added a note

Yep. Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@carlosantoniodasilva carlosantoniodasilva commented on the diff
actionpack/lib/action_dispatch/http/mime_negotiation.rb
@@ -64,6 +66,18 @@ def formats
end
end
+ # Sets the \variant for template
+ def variant=(variant)
+ if variant.is_a? Symbol
+ @variant = variant
+ else
+ raise ArgumentError, "request.variant must be set to a Symbol, not a #{variant.class}. For security reasons," +
+ "never directly set the variant to a user-provided value, like params[:variant].to_sym." +
+ "Check user-provided value against a whitelist first, then set the variant:"+
+ "request.variant = :tablet if params[:some_param] == 'tablet'"
+ end
+ end

Are we going to stick with symbols or make these strings internally?

@strzalek Collaborator
strzalek added a note

I would stay with symbols.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_dispatch/http/request.rb
@@ -51,6 +51,7 @@ def initialize(env)
@fullpath = nil
@ip = nil
@uuid = nil
+ @variant = nil

It seems a little awkward having the variable initialized in one place and the reader / writer in another. Can we define the initialize there maybe?

@strzalek Collaborator
strzalek added a note

Yeah, it's leftover from earlier implementations. Good catch. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionview/lib/action_view/template/resolver.rb
@@ -240,7 +240,9 @@ def extract_handler_and_format(path, default_formats)
end
handler = Template.handler_for_extension(extension)
- format = pieces.last && Template::Types[pieces.last]
+ format = pieces.last && pieces.last.split(EXTENSIONS[:variants], 2).first # remove variant from format
+ format = format && Template::Types[format]

format &&=?

@strzalek Collaborator
strzalek added a note

:+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@strzalek
Collaborator

@dhh @jeremy I've just pushed updates :ship:

actionpack/lib/action_controller/metal/mime_responds.rb
@@ -181,13 +181,40 @@ def clear_respond_to
# end
# end
#
+ # Formats can have different variants.
+ #
+ # The request variant is a specialization of the request format, like <tt>:tablet</tt>,
+ # <tt>:phone</tt>, or <tt>:desktop<tt>.
+ #
+ # We often want to render different html/json/xml templates for phones,
+ # tablets, and desktop browsers. Variants make it easy.
+ #
+ # You can set the variant in a before_action:
+ #
+ # request.variant = :tablet if request.user_agent =~ /iPad/

Can be 2 spaces here too :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
actionpack/lib/action_controller/metal/mime_responds.rb
@@ -181,13 +181,40 @@ def clear_respond_to
# end
# end
#
+ # Formats can have different variants.
+ #
+ # The request variant is a specialization of the request format, like <tt>:tablet</tt>,
+ # <tt>:phone</tt>, or <tt>:desktop<tt>.
+ #
+ # We often want to render different html/json/xml templates for phones,
+ # tablets, and desktop browsers. Variants make it easy.
+ #
+ # You can set the variant in a before_action:

+before_action+?

@strzalek Collaborator
strzalek added a note

Fixed. Sorry, I promise to learn more about rdoc syntax and pay more attention to it in the future.

Don't worry, the docs are great, I just noticed this spot that could be highlighted :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
strzalek added some commits
@strzalek strzalek Action Pack Variants
By default, variants in the templates will be picked up if a variant is set
and there's a match. The format will be:

  app/views/projects/show.html.erb
  app/views/projects/show.html+tablet.erb
  app/views/projects/show.html+phone.erb

If request.variant = :tablet is set, we'll automatically be rendering the
html+tablet template.

In the controller, we can also tailer to the variants with this syntax:

  class ProjectsController < ActionController::Base
    def show
      respond_to do |format|
        format.html do |html|
          @stars = @project.stars

          html.tablet { @notifications = @project.notifications }
          html.phone  { @chat_heads    = @project.chat_heads }
        end

        format.js
        format.atom
      end
    end
  end

The variant itself is nil by default, but can be set in before filters, like
so:

  class ApplicationController < ActionController::Base
    before_action do
      if request.user_agent =~ /iPad/
        request.variant = :tablet
      end
    end
  end

This is modeled loosely on custom mime types, but it's specifically not
intended to be used together. If you're going to make a custom mime type,
you don't need a variant. Variants are for variations on a single mime
types.
2d3a6a0
@strzalek strzalek Add variants to release notes eb0402d
@jeremy jeremy merged commit 501acab into rails:master
@jeremy
Owner

:heart:

@aag1091

<3 thank you @strzalek

@strzalek strzalek deleted the strzalek:action-pack-variants branch
@tenderlove

wtf?

Edit: Sorry, I'll be more specific than "wtf". Why doesn't this call super if name != @variant? Is there a way we can implement this without using method_missing?

Collaborator

It was changed and modified already a few times (because we were adding new features). Right now we don't have VariantFilter and it now we have VariantsCollector and whole thing works a bit differently: https://github.com/rails/rails/blob/master/actionpack/lib/action_controller/metal/mime_responds.rb#L512-L544

@strzalek awesome. Thanks for the explanation! I'll poke at the new code for a bit. :heart:

@tenderlove

Is this something we're really really really really sure isn't going to see user input? I mean really really really sure? Note how symbol count increases when we call upcase:

irb(main):003:0> Symbol.all_symbols.count
=> 3167
irb(main):004:0> :himom.upcase
=> :HIMOM
irb(main):005:0> Symbol.all_symbols.count
=> 3169
irb(main):006:0>
Collaborator

I think we can cast it to string here to avoid this problem.

great to see and learn. I recently saw one of my rails consoles as

Loading development environment (Rails 3.1.10)

Frame number: 0/4
[1] pry(main)> Symbol.all_symbols.count
=> 47415
[2] pry(main)> 

And I was simply thinking, can rails completely avoid symbols?

@tenderlove

What technical reason requires that we store the variant as a symbol? Can't we just call to_s on this an deal with strings internally?

Collaborator

I would love to keep it consistent. We keep format as symbol and it would be good to have variant as symbol as well.

I prefer we keep neither as symbols. Working with symbols inside the framework is a huge pain from a security perspective. Also, inside the framework it doesn't matter if we use strings.

@strzalek I read both comments. Neither of them provided technical reasons for the requirement that this be a symbol internally. Please describe exactly why it needs to be a symbol, otherwise I want to cast these to strings and remove the exception.

Collaborator

I have no problem casting it to string as long as we'll do it for both format and variant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Dec 3, 2013
  1. @strzalek

    Action Pack Variants

    strzalek authored
    By default, variants in the templates will be picked up if a variant is set
    and there's a match. The format will be:
    
      app/views/projects/show.html.erb
      app/views/projects/show.html+tablet.erb
      app/views/projects/show.html+phone.erb
    
    If request.variant = :tablet is set, we'll automatically be rendering the
    html+tablet template.
    
    In the controller, we can also tailer to the variants with this syntax:
    
      class ProjectsController < ActionController::Base
        def show
          respond_to do |format|
            format.html do |html|
              @stars = @project.stars
    
              html.tablet { @notifications = @project.notifications }
              html.phone  { @chat_heads    = @project.chat_heads }
            end
    
            format.js
            format.atom
          end
        end
      end
    
    The variant itself is nil by default, but can be set in before filters, like
    so:
    
      class ApplicationController < ActionController::Base
        before_action do
          if request.user_agent =~ /iPad/
            request.variant = :tablet
          end
        end
      end
    
    This is modeled loosely on custom mime types, but it's specifically not
    intended to be used together. If you're going to make a custom mime type,
    you don't need a variant. Variants are for variations on a single mime
    types.
  2. @strzalek
This page is out of date. Refresh to see the latest.
View
29 actionpack/CHANGELOG.md
@@ -1,3 +1,32 @@
+* Introducing Variants
+
+ We often want to render different html/json/xml templates for phones,
+ tablets, and desktop browsers. Variants make it easy.
+
+ The request variant is a specialization of the request format, like :tablet,
+ :phone, or :desktop.
+
+ You can set the variant in a before_action:
+
+ request.variant = :tablet if request.user_agent =~ /iPad/
+
+ Respond to variants in the action just like you respond to formats:
+
+ respond_to do |format|
+ format.html do |html|
+ html.tablet # renders app/views/projects/show.html+tablet.erb
+ html.phone { extra_setup; render ... }
+ end
+ end
+
+ Provide separate templates for each format and variant:
+
+ app/views/projects/show.html.erb
+ app/views/projects/show.html+tablet.erb
+ app/views/projects/show.html+phone.erb
+
+ *Łukasz Strzałkowski*
+
* Fix header `Content-Type: #<Mime::NullType:...>` in localized template.
When localized template has no format in the template name,
View
10 actionpack/lib/abstract_controller/collector.rb
@@ -23,7 +23,15 @@ def #{sym}(*args, &block) # def html(*args, &block)
protected
def method_missing(symbol, &block)
- mime_constant = Mime.const_get(symbol.upcase)
+ mime_const = symbol.upcase
+
+ raise NoMethodError, "To respond to a custom format, register it as a MIME type first:" +
+ "http://guides.rubyonrails.org/action_controller_overview.html#restful-downloads." +
+ "If you meant to respond to a variant like :tablet or :phone, not a custom format," +
+ "be sure to nest your variant response within a format response: format.html" +
+ "{ |html| html.tablet { ..." unless Mime.const_defined?(mime_const)
+
+ mime_constant = Mime.const_get(mime_const)
if Mime::SET.include?(mime_constant)
AbstractController::Collector.generate_method_for_mime(mime_constant)
View
2  actionpack/lib/abstract_controller/rendering.rb
@@ -102,6 +102,8 @@ def _process_format(format)
# :api: private
def _normalize_render(*args, &block)
options = _normalize_args(*args, &block)
+ #TODO: remove defined? when we restore AP <=> AV dependency
+ options[:variant] = request.variant if defined?(request) && request.variant.present?
_normalize_options(options)
options
end
View
50 actionpack/lib/action_controller/metal/mime_responds.rb
@@ -181,13 +181,40 @@ def clear_respond_to
# end
# end
#
+ # Formats can have different variants.
+ #
+ # The request variant is a specialization of the request format, like <tt>:tablet</tt>,
+ # <tt>:phone</tt>, or <tt>:desktop<tt>.
+ #
+ # We often want to render different html/json/xml templates for phones,
+ # tablets, and desktop browsers. Variants make it easy.
+ #
+ # You can set the variant in a +before_action+:
+ #
+ # request.variant = :tablet if request.user_agent =~ /iPad/
+ #
+ # Respond to variants in the action just like you respond to formats:
+ #
+ # respond_to do |format|
+ # format.html do |html|
+ # html.tablet # renders app/views/projects/show.html+tablet.erb
+ # html.phone { extra_setup; render ... }
+ # end
+ # end
+ #
+ # Provide separate templates for each format and variant:
+ #
+ # app/views/projects/show.html.erb
+ # app/views/projects/show.html+tablet.erb
+ # app/views/projects/show.html+phone.erb
+ #
# Be sure to check the documentation of +respond_with+ and
# <tt>ActionController::MimeResponds.respond_to</tt> for more examples.
def respond_to(*mimes, &block)
raise ArgumentError, "respond_to takes either types or a block, never both" if mimes.any? && block_given?
@rafaelfranca Owner

Documentation of this module need to be updated.

@strzalek Collaborator

Well that behavior actually didn't change. This message is still valid as you still need to pass either types or block however it is extended and inside "types block" you can pass variant now, but it's optional. I think changing that message to mention variant will only introduce confusion. What do you think? How do you suggest to change it?

@rafaelfranca Owner

I'm pointing the wrong line. I was talking about the respond_to Rdoc that needs to mention the variants now.

@strzalek Collaborator
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
if collector = retrieve_collector_from_mimes(mimes, &block)
- response = collector.response
+ response = collector.response(request.variant)
response ? response.call : render({})
end
end
@@ -327,7 +354,7 @@ def respond_with(*resources, &block)
if collector = retrieve_collector_from_mimes(&block)
options = resources.size == 1 ? {} : resources.extract_options!
options = options.clone
- options[:default_response] = collector.response
+ options[:default_response] = collector.response(request.variant)
(options.delete(:responder) || self.class.responder).call(self, resources, options)
end
end
@@ -417,13 +444,28 @@ def custom(mime_type, &block)
@responses[mime_type] ||= block
end
- def response
- @responses.fetch(format, @responses[Mime::ALL])
+ def response(variant)
+ response = @responses.fetch(format, @responses[Mime::ALL])
+ if response.nil? || response.arity == 0
+ response
+ else
+ lambda { response.call VariantFilter.new(variant) }
+ end
end
def negotiate_format(request)
@format = request.negotiate_mime(@responses.keys)
end
+
+ class VariantFilter
+ def initialize(variant)
+ @variant = variant
+ end
+
+ def method_missing(name)
+ yield if name == @variant
+ end
+ end
end
end
end
View
14 actionpack/lib/action_dispatch/http/mime_negotiation.rb
@@ -10,6 +10,8 @@ module MimeNegotiation
self.ignore_accept_header = false
end
+ attr_reader :variant
+
# The MIME type of the HTTP request, such as Mime::XML.
#
# For backward compatibility, the post \format is extracted from the
@@ -64,6 +66,18 @@ def formats
end
end
+ # Sets the \variant for template
+ def variant=(variant)
+ if variant.is_a? Symbol
+ @variant = variant
+ else
+ raise ArgumentError, "request.variant must be set to a Symbol, not a #{variant.class}. For security reasons," +
+ "never directly set the variant to a user-provided value, like params[:variant].to_sym." +
+ "Check user-provided value against a whitelist first, then set the variant:"+
+ "request.variant = :tablet if params[:some_param] == 'tablet'"
+ end
+ end

Are we going to stick with symbols or make these strings internally?

@strzalek Collaborator
strzalek added a note

I would stay with symbols.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
# Sets the \format by string extension, which can be used to force custom formats
# that are not controlled by the extension.
#
View
2  actionpack/test/abstract/collector_test.rb
@@ -37,7 +37,7 @@ class TestCollector < ActiveSupport::TestCase
test "does not register unknown mime types" do
collector = MyCollector.new
- assert_raise NameError do
+ assert_raise NoMethodError do
collector.unknown
end
end
View
54 actionpack/test/controller/mime/respond_to_test.rb
@@ -146,6 +146,26 @@ def iphone_with_html_response_type_without_layout
end
end
+ def variant_with_implicit_rendering
+ end
+
+ def variant_with_format_and_custom_render
+ request.variant = :mobile
+
+ respond_to do |type|
+ type.html { render text: "mobile" }
+ end
+ end
+
+ def multiple_variants_for_format
+ respond_to do |type|
+ type.html do |html|
+ html.tablet { render text: "tablet" }
+ html.phone { render text: "phone" }
+ end
+ end
+ end
@jeremy Owner
jeremy added a note

What happens when someone does

respond_to do |variant|
  variant.tablet do
    ...

by accident, not understanding the respond_to nesting?

@jeremy Owner
jeremy added a note

Responder blocks can be used to handle multiple formats. What about multiple variants?

respond_to do |format|
  format.html do |html|
    html.tablet
    html.any(:ipod, :iphone) { ... }
    html.all { ... }
  end
end
@strzalek Collaborator

1) I'm afraid we can't do anything here. There's no way to distinguish and guess user behavior. User will get error with hash with tablet in formats array which should give him idea what's wrong (something like that {:locale => [], :formats => [:tablet], :variants => [], :handlers => [:erb]}

2) We don't support any/all for variants, this should be easy to add.

@jeremy Owner
jeremy added a note
  1. What does the error look like if someone does this now?
  2. It's fine to not support that for the first version. However, when someone tries html.all, we will interpret that as an html+all variant. So it'd be a good idea to disallow any and all variant names, at least.
@strzalek Collaborator

1) NameError: uninitialized constant Mime::TABLET
2) :+1:

@jeremy Owner
jeremy added a note
  1. can definitely be improved. We can test whether the MIME type is registered. If it isn't, raise a NoMethodError with a helpful message rather than bubbling up an internal NameError. For example: "To respond to a custom format, register it as a MIME type first: http://guides.rubyonrails.org/action_controller_overview.html#restful-downloads. If you meant to respond to a variant like :tablet or :phone, not a custom format, be sure to nest your variant response within a format response: format.html { |html| html.tablet { ..."
@strzalek Collaborator
@jeremy Owner
jeremy added a note

@strzalek ping - how's the error handling going?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
protected
def set_layout
case action_name
@@ -490,4 +510,38 @@ def test_invalid_format
get :using_defaults, :format => "invalidformat"
end
end
+
+ def test_invalid_variant
+ @request.variant = :invalid
+ assert_raises(ActionView::MissingTemplate) do
+ get :variant_with_implicit_rendering
+ end
+ end
+
+ def test_variant_not_set_regular_template_missing
+ assert_raises(ActionView::MissingTemplate) do
+ get :variant_with_implicit_rendering
+ end
+ end
+
+ def test_variant_with_implicit_rendering
+ @request.variant = :mobile
+ get :variant_with_implicit_rendering
+ assert_equal "text/html", @response.content_type
+ assert_equal "mobile", @response.body
+ end
+
+ def test_variant_with_format_and_custom_render
+ @request.variant = :phone
+ get :variant_with_format_and_custom_render
+ assert_equal "text/html", @response.content_type
+ assert_equal "mobile", @response.body
+ end
+
+ def test_multiple_variants_for_format
+ @request.variant = :tablet
+ get :multiple_variants_for_format
+ assert_equal "text/html", @response.content_type
+ assert_equal "tablet", @response.body
+ end
end
View
13 actionpack/test/dispatch/request_test.rb
@@ -844,6 +844,19 @@ def url_for(options = {})
end
end
+ test "setting variant" do
+ request = stub_request
+ request.variant = :mobile
+ assert_equal :mobile, request.variant
+ end
+
+ test "setting variant with non symbol value" do
+ request = stub_request
+ assert_raise ArgumentError do
+ request.variant = "mobile"
+ end
+ end
@jeremy Owner
jeremy added a note

Why is the variant required to be a Symbol?

When we're providing an API to set a specialized format based on a user request, we're just begging for developers to set the variant to user-provided data. With security in mind, working with Strings is the API I'd expect if we're assigning data from the outside world.

Alternatively, declaring a whitelist of allowed variants would allow us to safely call .to_sym on any String assigned to request.variant as long as it's whitelisted.

@strzalek Collaborator

We want to avoid situation in which users will blindly or mistakenly pass hashes or arrays as variants (request.variant = params[:users]). Converting this to string if it's hash/array can only cause problems. We need to have type check here and can't rely on user and do typecast on our side.

I don't know if we should whitelist variants though. First thought is no, but I can imagine that we can come up with list of ~5 (?) commonly used variants, which will be used in 90% cases (I can guess that mobile, tablet will land on that list for sure). Don't have opinion here right now.

@tenderlove Owner

Converting this to string if it's hash/array can only cause problems.

Can you describe the specific problems? I have doubts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
protected
def stub_request(env = {})
View
1  actionpack/test/fixtures/respond_to/variant_with_implicit_rendering.html+mobile.erb
@@ -0,0 +1 @@
+mobile
View
1  actionview/lib/action_view/lookup_context.rb
@@ -52,6 +52,7 @@ module Accessors #:nodoc:
locales
end
register_detail(:formats) { ActionView::Base.default_formats || [:html, :text, :js, :css, :xml, :json] }
+ register_detail(:variants) { [] }
register_detail(:handlers){ Template::Handlers.extensions }
class DetailsKey #:nodoc:
View
6 actionview/lib/action_view/rendering.rb
@@ -88,10 +88,14 @@ def rendered_format
private
- # Find and renders a template based on the options given.
+ # Find and render a template based on the options given.
# :api: private
def _render_template(options) #:nodoc:
+ variant = options[:variant]
+
lookup_context.rendered_format = nil if options[:formats]
+ lookup_context.variants = [variant] if variant
+
view_renderer.render(view_context, options)
end
View
17 actionview/lib/action_view/template/resolver.rb
@@ -162,8 +162,8 @@ def decorate(templates, path_info, details, locals) #:nodoc:
# An abstract class that implements a Resolver with path semantics.
class PathResolver < Resolver #:nodoc:
- EXTENSIONS = [:locale, :formats, :handlers]
- DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{.:handlers,}"
+ EXTENSIONS = { :locale => ".", :formats => ".", :variants => "+", :handlers => "." }
+ DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}"
def initialize(pattern=nil)
@pattern = pattern || DEFAULT_PATTERN
@@ -240,7 +240,9 @@ def extract_handler_and_format(path, default_formats)
end
handler = Template.handler_for_extension(extension)
- format = pieces.last && Template::Types[pieces.last]
+ format = pieces.last && pieces.last.split(EXTENSIONS[:variants], 2).first # remove variant from format
@rafaelfranca Owner

maybe is good the extract a method to this

@strzalek Collaborator

Is it? Well that method is is already an extraction - https://github.com/strzalek/rails/blob/a5c609f1290f2ca071f8980eb08f3529513bbdee/actionview/lib/action_view/template/resolver.rb#L231

Extracting this small pice of code won't be beneficial in any way in my opinion. What do you think?

@rafaelfranca Owner

:+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ format &&= Template::Types[format]
+
[handler, format]
end
end
@@ -303,12 +305,13 @@ def eql?(resolver)
# An Optimized resolver for Rails' most common case.
class OptimizedFileSystemResolver < FileSystemResolver #:nodoc:
def build_query(path, details)
- exts = EXTENSIONS.map { |ext| details[ext] }
query = escape_entry(File.join(@path, path))
- query + exts.map { |ext|
- "{#{ext.compact.uniq.map { |e| ".#{e}," }.join}}"
- }.join
+ exts = EXTENSIONS.map do |ext, prefix|
+ "{#{details[ext].compact.uniq.map { |e| "#{prefix}#{e}," }.join}}"
+ end.join
+
+ query + exts
end
end
View
2  actionview/lib/action_view/testing/resolvers.rb
@@ -21,7 +21,7 @@ def to_s
def query(path, exts, formats)
query = ""
- EXTENSIONS.each do |ext|
+ EXTENSIONS.each_key do |ext|
query << '(' << exts[ext].map {|e| e && Regexp.escape(".#{e}") }.join('|') << '|)'
end
query = /^(#{Regexp.escape(path)})#{query}$/
View
7 actionview/test/template/lookup_context_test.rb
@@ -36,6 +36,11 @@ def teardown
assert @lookup_context.formats.frozen?
end
+ test "provides getters and setters for variants" do
+ @lookup_context.variants = [:mobile]
+ assert_equal [:mobile], @lookup_context.variants
+ end
+
test "provides getters and setters for formats" do
@lookup_context.formats = [:html]
assert_equal [:html], @lookup_context.formats
@@ -254,7 +259,7 @@ def setup
test "if a single prefix is passed as a string and the lookup fails, MissingTemplate accepts it" do
e = assert_raise ActionView::MissingTemplate do
- details = {:handlers=>[], :formats=>[], :locale=>[]}
+ details = {:handlers=>[], :formats=>[], :variants=>[], :locale=>[]}
@lookup_context.view_paths.find("foo", "parent", true, details)
end
assert_match %r{Missing partial parent/_foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message
View
4 actionview/test/template/testing/fixture_resolver_test.rb
@@ -3,13 +3,13 @@
class FixtureResolverTest < ActiveSupport::TestCase
def test_should_return_empty_list_for_unknown_path
resolver = ActionView::FixtureResolver.new()
- templates = resolver.find_all("path", "arbitrary", false, {:locale => [], :formats => [:html], :handlers => []})
+ templates = resolver.find_all("path", "arbitrary", false, {:locale => [], :formats => [:html], :variants => [], :handlers => []})
assert_equal [], templates, "expected an empty list of templates"
end
def test_should_return_template_for_declared_path
resolver = ActionView::FixtureResolver.new("arbitrary/path.erb" => "this text")
- templates = resolver.find_all("path", "arbitrary", false, {:locale => [], :formats => [:html], :handlers => [:erb]})
+ templates = resolver.find_all("path", "arbitrary", false, {:locale => [], :formats => [:html], :variants => [], :handlers => [:erb]})
assert_equal 1, templates.size, "expected one template"
assert_equal "this text", templates.first.source
assert_equal "arbitrary/path", templates.first.virtual_path
View
33 guides/source/4_1_release_notes.md
@@ -3,6 +3,7 @@ Ruby on Rails 4.1 Release Notes
Highlights in Rails 4.1:
+* Variants
* Action View extracted from Action Pack
These release notes cover only the major changes. To know about various bug
@@ -27,6 +28,38 @@ guide.
Major Features
--------------
+* Variants
+
+ We often want to render different html/json/xml templates for phones,
+ tablets, and desktop browsers. Variants make it easy.
+
+ The request variant is a specialization of the request format, like :tablet,
+ :phone, or :desktop.
+
+ You can set the variant in a before_action:
+
+ ```ruby
+ request.variant = :tablet if request.user_agent =~ /iPad/
+ ```
+
+ Respond to variants in the action just like you respond to formats:
+
+ ```ruby
+ respond_to do |format|
+ format.html do |html|
+ html.tablet # renders app/views/projects/show.html+tablet.erb
+ html.phone { extra_setup; render ... }
+ end
+ end
+ ```
+
+ Provide separate templates for each format and variant:
+
+ ```
+ app/views/projects/show.html.erb
+ app/views/projects/show.html+tablet.erb
+ app/views/projects/show.html+phone.erb
+ ```
Documentation
-------------
Something went wrong with that request. Please try again.