Skip to content

Action Pack Variants #12977

Merged
merged 2 commits into from Dec 3, 2013

9 participants

@lukaszx0
Ruby on Rails member

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

@dhh dhh and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
dhh added a note Nov 21, 2013

This seems to be a debug statement left in?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@dhh dhh commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
dhh added a note Nov 21, 2013

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
@jeremy jeremy and 2 others commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
jeremy added a note Nov 21, 2013

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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

👍

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@rafaelfranca rafaelfranca and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

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

@rafaelfranca
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

Line 422 is only using response, not request

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

Yeah, ignore my comment 😴

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca and 1 other commented on an outdated diff Nov 21, 2013
actionpack/lib/action_controller/metal/mime_responds.rb
end
def negotiate_format(request)
@format = request.negotiate_mime(@responses.keys)
end
+
+ class VariantFilter
+ attr_accessor :variant
@rafaelfranca
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

Do we need the accessor?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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 Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

Documentation of this module need to be updated.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeremy jeremy and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
jeremy added a note Nov 21, 2013

attr_reader :variant - ?

@jeremy
Ruby on Rails member
jeremy added a note Nov 21, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

👍

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@rafaelfranca rafaelfranca and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

Why do we need to check if request is defined?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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

@rafaelfranca
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

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.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@rafaelfranca rafaelfranca commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

attr_reader

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca and 2 others commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

we can avoid the repeated to_sym call here

@jeremy
Ruby on Rails member
jeremy added a note Nov 21, 2013

What motivated this change? It appears to be unrelated.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@jeremy jeremy and 1 other commented on an outdated diff Nov 21, 2013
actionpack/test/controller/mime/respond_to_test.rb
+ 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
Ruby on Rails member
jeremy added a note Nov 21, 2013

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.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@jeremy jeremy and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
jeremy added a note Nov 21, 2013

ArgumentError is appropriate, rather than a specialized exception.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeremy jeremy and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
jeremy added a note Nov 21, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@jeremy jeremy and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
jeremy added a note Nov 21, 2013

Finds and renders or Find and render

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca and 2 others commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

This method should not be here 😄

@josevalim
Ruby on Rails member
josevalim added a note Nov 21, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

Yeah, messed this up while rebasing 😵

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca commented on the diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

maybe is good the extract a method to this

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
Ruby on Rails member
rafaelfranca added a note Nov 26, 2013

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca and 1 other commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 21, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeremy jeremy and 2 others commented on an outdated diff Nov 21, 2013
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
Ruby on Rails member
jeremy added a note Nov 21, 2013

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
Ruby on Rails member
josevalim added a note Nov 21, 2013
@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 21, 2013

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
@carlosantoniodasilva carlosantoniodasilva commented on an outdated diff Nov 21, 2013
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
@carlosantoniodasilva
Ruby on Rails member

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
@lukaszx0
Ruby on Rails member

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!

@lukaszx0 lukaszx0 was assigned Nov 21, 2013
@lukaszx0
Ruby on Rails member

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
Ruby on Rails member
dhh commented Nov 25, 2013
@jeremy
Ruby on Rails member
jeremy commented Nov 25, 2013

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.

@lukaszx0
Ruby on Rails member

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: lukaszx0@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 😉 ?

@jeremy
Ruby on Rails member
jeremy commented Nov 25, 2013

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

@lukaszx0
Ruby on Rails member

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

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

@lukaszx0
Ruby on Rails member

@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 🍻

@rafaelfranca rafaelfranca and 1 other commented on an outdated diff Nov 26, 2013
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
Ruby on Rails member
rafaelfranca added a note Nov 26, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 26, 2013

Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca commented on an outdated diff Nov 26, 2013
actionpack/lib/action_controller/metal/mime_responds.rb
@@ -181,13 +181,59 @@ def clear_respond_to
# end
# end
#
+ # Formats can have different variants
@rafaelfranca
Ruby on Rails member
rafaelfranca added a note Nov 26, 2013

missing .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca rafaelfranca and 1 other commented on an outdated diff Nov 26, 2013
actionpack/lib/action_controller/metal/mime_responds.rb
+ # 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
Ruby on Rails member
rafaelfranca added a note Nov 26, 2013

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 26, 2013

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

@rafaelfranca
Ruby on Rails member
rafaelfranca added a note Nov 26, 2013

I prefer to remove the whole paragraph.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 26, 2013

Me too 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@lukaszx0
Ruby on Rails member

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

@lukaszx0
Ruby on Rails member

:shipit:

@jeremy jeremy commented on an outdated diff Nov 27, 2013
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
Ruby on Rails member
jeremy added a note Nov 27, 2013

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
@jeremy jeremy commented on an outdated diff Nov 27, 2013
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
Ruby on Rails member
jeremy added a note Nov 27, 2013

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
@jeremy jeremy commented on an outdated diff Nov 27, 2013
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
Ruby on Rails member
jeremy added a note Nov 27, 2013

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
@jeremy jeremy commented on an outdated diff Nov 27, 2013
actionpack/lib/action_controller/metal/mime_responds.rb
+ # 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
Ruby on Rails member
jeremy added a note Nov 27, 2013

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
@jeremy jeremy commented on an outdated diff Nov 27, 2013
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
Ruby on Rails member
jeremy added a note Nov 27, 2013
"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 Nov 27, 2013
actionpack/test/controller/mime/respond_to_test.rb
+ 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
Ruby on Rails member
jeremy added a note Nov 27, 2013

What happens when someone does

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

by accident, not understanding the respond_to nesting?

@jeremy
Ruby on Rails member
jeremy added a note Nov 27, 2013

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
@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 27, 2013

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
Ruby on Rails member
jeremy added a note Nov 27, 2013
  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.
@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 27, 2013

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

@jeremy
Ruby on Rails member
jeremy added a note Nov 27, 2013
  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 { ..."
@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 27, 2013
@jeremy
Ruby on Rails member
jeremy added a note Dec 2, 2013

@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 Nov 27, 2013
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
Ruby on Rails member
jeremy added a note Nov 27, 2013

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.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 27, 2013

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
Ruby on Rails member
tenderlove added a note Mar 11, 2014

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
@carlosantoniodasilva carlosantoniodasilva and 1 other commented on an outdated diff Nov 27, 2013
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, _|
@carlosantoniodasilva
Ruby on Rails member

You can use #each_key.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Nov 27, 2013

Thanks! 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@lukaszx0
Ruby on Rails member
@carlosantoniodasilva carlosantoniodasilva and 1 other commented on an outdated diff Dec 3, 2013
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?
@carlosantoniodasilva
Ruby on Rails member

Shouldn't need this defined? check right?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013

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

@carlosantoniodasilva
Ruby on Rails member

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.

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@carlosantoniodasilva carlosantoniodasilva and 1 other commented on an outdated diff Dec 3, 2013
actionpack/lib/action_controller/metal/mime_responds.rb
+ # 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
@carlosantoniodasilva
Ruby on Rails member

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013

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 Dec 3, 2013
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
@carlosantoniodasilva
Ruby on Rails member

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

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013

I would stay with symbols.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@carlosantoniodasilva carlosantoniodasilva and 1 other commented on an outdated diff Dec 3, 2013
actionpack/lib/action_dispatch/http/request.rb
@@ -51,6 +51,7 @@ def initialize(env)
@fullpath = nil
@ip = nil
@uuid = nil
+ @variant = nil
@carlosantoniodasilva
Ruby on Rails member

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?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013

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
@carlosantoniodasilva carlosantoniodasilva and 1 other commented on an outdated diff Dec 3, 2013
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]
@carlosantoniodasilva
Ruby on Rails member

format &&=?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@lukaszx0
Ruby on Rails member
lukaszx0 commented Dec 3, 2013

@dhh @jeremy I've just pushed updates 🚢

@carlosantoniodasilva carlosantoniodasilva commented on an outdated diff Dec 3, 2013
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/
@carlosantoniodasilva
Ruby on Rails member

Can be 2 spaces here too :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@carlosantoniodasilva carlosantoniodasilva and 1 other commented on an outdated diff Dec 3, 2013
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:
@carlosantoniodasilva
Ruby on Rails member

+before_action+?

@lukaszx0
Ruby on Rails member
lukaszx0 added a note Dec 3, 2013

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

@carlosantoniodasilva
Ruby on Rails member

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
lukaszx0 added some commits Dec 3, 2013
@lukaszx0 lukaszx0 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
@lukaszx0 lukaszx0 Add variants to release notes eb0402d
@jeremy jeremy merged commit 501acab into rails:master Dec 3, 2013
@jeremy
Ruby on Rails member
jeremy commented Dec 3, 2013

❤️

@aag1091
aag1091 commented Dec 24, 2013

<3 thank you @strzalek

@lukaszx0 lukaszx0 deleted the lukaszx0:action-pack-variants branch Feb 12, 2014
@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?

Ruby on Rails member

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

Ruby on Rails member

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

@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>
Ruby on Rails member

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?

Ruby on Rails member
Ruby on Rails member

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

Ruby on Rails member

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.

Ruby on Rails member

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

Ruby on Rails member

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
Something went wrong with that request. Please try again.