Skip to content

Commit ab2c0fd

Browse files
committed
Respect Accept header when there is no :format
Previously when making a request to a URL without a format and a non-HTML Accept header the content type would be returned as `text/html` even if it was meant to be another type like JSON. To fix this people had to manually set a route default to whatever was their desired content type was but this workaround couldn't handle routes with multiple content types. This commit fixes it by determining the extension from the request object if there is no `:format` parameter set in the params. For backwards compatibility if the Accept header indicates a HTML request then the fragment is saved without an extension - otherwise upgrading to this version of the gem would invalidate all existing cached actions and cause possible cache storms/thundering herds when deployed to production. Some consideration was given to expiring both extension-less paths and paths with `.html` when `expire_action` is called with a `"html"` format parameter but it's probably best left to the application developer as it could expire items from caches unexpectedly. Fixes #18.
1 parent 900e4ad commit ab2c0fd

File tree

3 files changed

+147
-11
lines changed

3 files changed

+147
-11
lines changed

README.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,12 @@ The following example depicts some of the points made above:
106106
If you pass `layout: false`, it will only cache your action
107107
content. That's useful when your layout has dynamic information.
108108

109-
Warning: If the format of the request is determined by the Accept HTTP
110-
header the Content-Type of the cached response could be wrong because
111-
no information about the MIME type is stored in the cache key. So, if
112-
you first ask for MIME type M in the Accept header, a cache entry is
113-
created, and then perform a second request to the same resource asking
114-
for a different MIME type, you'd get the content cached for M.
115-
116-
The `:format` parameter is taken into account though. The safest
117-
way to cache by MIME type is to pass the format in the route.
109+
Note: Both the `:format` param and the `Accept` header are taken
110+
into account when caching the fragment with the `:format` having
111+
precedence. For backwards compatibility when the `Accept` header
112+
indicates a HTML request the fragment is stored without the
113+
extension but if an explicit `"html"` is passed in `:format` then
114+
that _is_ used for storing the fragment.
118115

119116
Contributing
120117
------------

lib/action_controller/caching/actions.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,14 @@ class ActionCachePath
199199
# request format.
200200
def initialize(controller, options = {}, infer_extension = true)
201201
if infer_extension
202-
@extension = controller.params[:format]
202+
if controller.params.key?(:format)
203+
@extension = controller.params[:format]
204+
elsif !controller.request.format.html?
205+
@extension = controller.request.format.to_sym
206+
else
207+
@extension = nil
208+
end
209+
203210
options.reverse_merge!(format: @extension) if options.is_a?(Hash)
204211
end
205212

test/caching_test.rb

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class ActionCachingTestController < CachingController
4848
caches_action :record_not_found, :four_oh_four, :simple_runtime_error
4949
caches_action :streaming
5050
caches_action :invalid
51+
caches_action :accept
5152

5253
layout "talk_from_action"
5354

@@ -132,6 +133,27 @@ def invalid
132133
end
133134
end
134135

136+
def accept
137+
@cache_this = MockTime.now.to_f.to_s
138+
139+
respond_to do |format|
140+
format.html { render html: @cache_this }
141+
format.json { render json: @cache_this }
142+
end
143+
end
144+
145+
def expire_accept
146+
if params.key?(:format)
147+
expire_action action: "accept", format: params[:format]
148+
elsif !request.format.html?
149+
expire_action action: "accept", format: request.format.to_sym
150+
else
151+
expire_action action: "accept"
152+
end
153+
154+
head :ok
155+
end
156+
135157
protected
136158
def cache_path_protected_method
137159
["controller", params[:id]].compact.join("-")
@@ -141,6 +163,10 @@ def cache_path_protected_method
141163
def render(options)
142164
if options.key?(:plain)
143165
super({ text: options.delete(:plain) }.merge(options))
166+
response.content_type = "text/plain"
167+
elsif options.key?(:html)
168+
super({ text: options.delete(:html) }.merge(options))
169+
response.content_type = "text/html"
144170
else
145171
super
146172
end
@@ -321,7 +347,7 @@ def test_action_cache_conditional_options
321347
get "/action_caching_test", to: "action_caching_test#index"
322348
end
323349

324-
@request.env["HTTP_ACCEPT"] = "application/json"
350+
@request.accept = "application/json"
325351
get :index
326352
assert_response :success
327353
assert !fragment_exist?("hostname.com/action_caching_test")
@@ -700,7 +726,113 @@ def test_invalid_format_returns_not_acceptable
700726
assert_response :not_acceptable
701727
end
702728

729+
def test_format_from_accept_header
730+
draw do
731+
get "/action_caching_test/accept", to: "action_caching_test#accept"
732+
get "/action_caching_test/accept/expire", to: "action_caching_test#expire_accept"
733+
end
734+
735+
# Cache the JSON format
736+
get_json :accept
737+
json_cached_time = content_to_cache
738+
assert_cached json_cached_time, "application/json"
739+
740+
# Check that the JSON format is cached
741+
get_json :accept
742+
assert_cached json_cached_time, "application/json"
743+
744+
# Cache the HTML format
745+
get_html :accept
746+
html_cached_time = content_to_cache
747+
assert_cached html_cached_time
748+
749+
# Check that it's not the JSON format
750+
assert_not_equal json_cached_time, @response.body
751+
752+
# Check that the HTML format is cached
753+
get_html :accept
754+
assert_cached html_cached_time
755+
756+
# Check that the JSON format is still cached
757+
get_json :accept
758+
assert_cached json_cached_time, "application/json"
759+
760+
# Expire the JSON format
761+
get_json :expire_accept
762+
assert_response :success
763+
764+
# Check that the HTML format is still cached
765+
get_html :accept
766+
assert_cached html_cached_time
767+
768+
# Check the JSON format was expired
769+
get_json :accept
770+
new_json_cached_time = content_to_cache
771+
assert_cached new_json_cached_time, "application/json"
772+
assert_not_equal json_cached_time, @response.body
773+
774+
# Expire the HTML format
775+
get_html :expire_accept
776+
assert_response :success
777+
778+
# Check that the JSON format is still cached
779+
get_json :accept
780+
assert_cached new_json_cached_time, "application/json"
781+
782+
# Check the HTML format was expired
783+
get_html :accept
784+
new_html_cached_time = content_to_cache
785+
assert_cached new_html_cached_time
786+
assert_not_equal html_cached_time, @response.body
787+
end
788+
789+
def test_explicit_html_format_is_used_for_fragment_path
790+
draw do
791+
get "/action_caching_test/accept", to: "action_caching_test#accept"
792+
get "/action_caching_test/accept/expire", to: "action_caching_test#expire_accept"
793+
end
794+
795+
get :accept, format: "html"
796+
cached_time = content_to_cache
797+
assert_cached cached_time
798+
799+
assert fragment_exist?("hostname.com/action_caching_test/accept.html")
800+
801+
get :accept, format: "html"
802+
cached_time = content_to_cache
803+
assert_cached cached_time
804+
805+
get :expire_accept, format: "html"
806+
assert_response :success
807+
808+
assert !fragment_exist?("hostname.com/action_caching_test/accept.html")
809+
810+
get :accept, format: "html"
811+
assert_not_cached cached_time
812+
end
813+
703814
private
815+
def get_html(*args)
816+
@request.accept = "text/html"
817+
get(*args)
818+
end
819+
820+
def get_json(*args)
821+
@request.accept = "application/json"
822+
get(*args)
823+
end
824+
825+
def assert_cached(cache_time, content_type = "text/html")
826+
assert_response :success
827+
assert_equal cache_time, @response.body
828+
assert_equal content_type, @response.content_type
829+
end
830+
831+
def assert_not_cached(cache_time, content_type = "text/html")
832+
assert_response :success
833+
assert_not_equal cache_time, @response.body
834+
assert_equal content_type, @response.content_type
835+
end
704836

705837
def content_to_cache
706838
@controller.instance_variable_get(:@cache_this)

0 commit comments

Comments
 (0)