From 2d3a6a0cb8df0360dd588a4d2fb260dd07cc9bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Strza=C5=82kowski?= Date: Tue, 3 Dec 2013 11:17:01 +0100 Subject: 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. --- actionpack/CHANGELOG.md | 29 ++++++++++++ actionpack/lib/abstract_controller/collector.rb | 10 +++- actionpack/lib/abstract_controller/rendering.rb | 2 + .../lib/action_controller/metal/mime_responds.rb | 50 ++++++++++++++++++-- .../lib/action_dispatch/http/mime_negotiation.rb | 14 ++++++ actionpack/test/abstract/collector_test.rb | 2 +- actionpack/test/controller/mime/respond_to_test.rb | 54 ++++++++++++++++++++++ actionpack/test/dispatch/request_test.rb | 13 ++++++ ...variant_with_implicit_rendering.html+mobile.erb | 1 + 9 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 actionpack/test/fixtures/respond_to/variant_with_implicit_rendering.html+mobile.erb (limited to 'actionpack') diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 8181b386be..112a787d3b 100644 --- a/actionpack/CHANGELOG.md +++ b/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: #` in localized template. When localized template has no format in the template name, diff --git a/actionpack/lib/abstract_controller/collector.rb b/actionpack/lib/abstract_controller/collector.rb index 09b9e7ddf0..f7a309c26c 100644 --- a/actionpack/lib/abstract_controller/collector.rb +++ b/actionpack/lib/abstract_controller/collector.rb @@ -23,7 +23,15 @@ module AbstractController 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) diff --git a/actionpack/lib/abstract_controller/rendering.rb b/actionpack/lib/abstract_controller/rendering.rb index fb8f40cb9b..ce3a0316c4 100644 --- a/actionpack/lib/abstract_controller/rendering.rb +++ b/actionpack/lib/abstract_controller/rendering.rb @@ -102,6 +102,8 @@ module AbstractController # :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 diff --git a/actionpack/lib/action_controller/metal/mime_responds.rb b/actionpack/lib/action_controller/metal/mime_responds.rb index 84ade41036..9a88a01233 100644 --- a/actionpack/lib/action_controller/metal/mime_responds.rb +++ b/actionpack/lib/action_controller/metal/mime_responds.rb @@ -181,13 +181,40 @@ module ActionController #:nodoc: # end # end # + # Formats can have different 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 + # # Be sure to check the documentation of +respond_with+ and # ActionController::MimeResponds.respond_to 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? 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 @@ module ActionController #:nodoc: 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 @@ module ActionController #:nodoc: @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 diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb index 40bb060d52..41e6727315 100644 --- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -10,6 +10,8 @@ module ActionDispatch 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 @@ module ActionDispatch 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 + # Sets the \format by string extension, which can be used to force custom formats # that are not controlled by the extension. # diff --git a/actionpack/test/abstract/collector_test.rb b/actionpack/test/abstract/collector_test.rb index 5709ad0378..b1a5044399 100644 --- a/actionpack/test/abstract/collector_test.rb +++ b/actionpack/test/abstract/collector_test.rb @@ -37,7 +37,7 @@ module AbstractController test "does not register unknown mime types" do collector = MyCollector.new - assert_raise NameError do + assert_raise NoMethodError do collector.unknown end end diff --git a/actionpack/test/controller/mime/respond_to_test.rb b/actionpack/test/controller/mime/respond_to_test.rb index 774dabe105..2b6c8739af 100644 --- a/actionpack/test/controller/mime/respond_to_test.rb +++ b/actionpack/test/controller/mime/respond_to_test.rb @@ -146,6 +146,26 @@ class RespondToController < ActionController::Base 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 + protected def set_layout case action_name @@ -490,4 +510,38 @@ class RespondToControllerTest < ActionController::TestCase 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 diff --git a/actionpack/test/dispatch/request_test.rb b/actionpack/test/dispatch/request_test.rb index 9c7789bcfb..b308c5749a 100644 --- a/actionpack/test/dispatch/request_test.rb +++ b/actionpack/test/dispatch/request_test.rb @@ -844,6 +844,19 @@ class RequestTest < ActiveSupport::TestCase 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 + protected def stub_request(env = {}) diff --git a/actionpack/test/fixtures/respond_to/variant_with_implicit_rendering.html+mobile.erb b/actionpack/test/fixtures/respond_to/variant_with_implicit_rendering.html+mobile.erb new file mode 100644 index 0000000000..317801ad30 --- /dev/null +++ b/actionpack/test/fixtures/respond_to/variant_with_implicit_rendering.html+mobile.erb @@ -0,0 +1 @@ +mobile \ No newline at end of file -- cgit v1.2.3