feat: extend conditional_get with Prism and ConditionalGet
This commit is contained in:
1 parent
7478aeadf2
commit
31799fc5af
2 files changed
+145
-6
No files matched your search
@@ -0,0 +1,75 @@
|
|||||||
|
module Prism
|
||||||
|
# HTTP conditional GET support (ETag / Last-Modified), mixed into
|
||||||
|
# Controller alongside Renderable. Requires the host to provide
|
||||||
|
# `headers`, `request` and `halt` (all supplied by Renderable).
|
||||||
|
module ConditionalGet
|
||||||
|
# Sets ETag/Last-Modified and halts with 304 if the client's cached
|
||||||
|
# copy is still fresh. Returns nil (via halt) when it short-circuits.
|
||||||
|
def fresh_when(etag: nil, last_modified: nil, weak: true)
|
||||||
|
set_conditional_headers(etag: etag, last_modified: last_modified, weak: weak)
|
||||||
|
halt(412, '') if precondition_failed?
|
||||||
|
halt(304, '') if request_fresh?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sets ETag/Last-Modified and returns whether the response is stale
|
||||||
|
# (i.e. whether the caller should still render). Halts with 304 itself
|
||||||
|
# when the client's copy is fresh, so callers can write
|
||||||
|
# `render(...) if stale?(etag: post)`.
|
||||||
|
def stale?(etag: nil, last_modified: nil, weak: true)
|
||||||
|
set_conditional_headers(etag: etag, last_modified: last_modified, weak: weak)
|
||||||
|
halt(412, '') if precondition_failed?
|
||||||
|
fresh = request_fresh?
|
||||||
|
halt(304, '') if fresh
|
||||||
|
!fresh
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# If-Match/If-Unmodified-Since guard writes against the lost-update
|
||||||
|
# problem: unlike If-None-Match/If-Modified-Since (which ask "may I
|
||||||
|
# skip re-sending this?"), these ask "does the client's copy still
|
||||||
|
# match what's on the server?" and abort the request with 412 when it
|
||||||
|
# does not, regardless of HTTP method.
|
||||||
|
def precondition_failed?
|
||||||
|
match = request.headers['If-Match']
|
||||||
|
unmodified_since = request.headers['If-Unmodified-Since']
|
||||||
|
return false if match.nil? && unmodified_since.nil?
|
||||||
|
|
||||||
|
match_ok = match.nil? || etag_matches?(match)
|
||||||
|
unmodified_ok = unmodified_since.nil? || not_modified_since?(unmodified_since)
|
||||||
|
!(match_ok && unmodified_ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_conditional_headers(etag:, last_modified:, weak:)
|
||||||
|
if etag
|
||||||
|
digest = Digest::SHA1.hexdigest(etag.to_s)
|
||||||
|
headers['ETag'] = weak ? %(W/"#{digest}") : %("#{digest}")
|
||||||
|
end
|
||||||
|
headers['Last-Modified'] = last_modified.utc.httpdate if last_modified
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_fresh?
|
||||||
|
none_match = request.headers['If-None-Match']
|
||||||
|
modified_since = request.headers['If-Modified-Since']
|
||||||
|
return false if none_match.nil? && modified_since.nil?
|
||||||
|
|
||||||
|
etag_fresh = none_match.nil? || etag_matches?(none_match)
|
||||||
|
time_fresh = modified_since.nil? || not_modified_since?(modified_since)
|
||||||
|
etag_fresh && time_fresh
|
||||||
|
end
|
||||||
|
|
||||||
|
def etag_matches?(header_value)
|
||||||
|
current = headers['ETag']
|
||||||
|
return false unless current
|
||||||
|
header_value.split(',').map(&:strip).any? { |candidate| candidate == '*' || candidate == current }
|
||||||
|
end
|
||||||
|
|
||||||
|
def not_modified_since?(header_value)
|
||||||
|
current = headers['Last-Modified']
|
||||||
|
return false unless current
|
||||||
|
Time.httpdate(header_value) >= Time.httpdate(current)
|
||||||
|
rescue ArgumentError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+70
-6
@@ -1,22 +1,48 @@
|
|||||||
module Prism
|
module Prism
|
||||||
class Controller
|
class Controller
|
||||||
include Renderable
|
include Renderable
|
||||||
|
include ConditionalGet
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
# Combines this controller's own filters with everything declared on
|
||||||
|
# its ancestors, ancestors first, so a shared ApplicationController can
|
||||||
|
# declare `protect_from_forgery`/`before_action :require_authentication`
|
||||||
|
# once and have every subclass run it - the same way `layout`/
|
||||||
|
# `views_root` already fall back to the superclass.
|
||||||
def before_actions
|
def before_actions
|
||||||
@before_actions ||= []
|
own = @before_actions ||= []
|
||||||
|
superclass.respond_to?(:before_actions) ? superclass.before_actions + own : own
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_actions
|
def after_actions
|
||||||
@after_actions ||= []
|
own = @after_actions ||= []
|
||||||
|
superclass.respond_to?(:after_actions) ? superclass.after_actions + own : own
|
||||||
|
end
|
||||||
|
|
||||||
|
# Own handlers first (most recently declared first, like Rails'
|
||||||
|
# reverse-declaration-order matching), then the superclass's - so an
|
||||||
|
# ApplicationController-wide `rescue_from StandardError` never shadows
|
||||||
|
# a more specific handler a subclass declares for itself.
|
||||||
|
def rescue_handlers
|
||||||
|
own = (@rescue_handlers ||= []).reverse
|
||||||
|
superclass.respond_to?(:rescue_handlers) ? own + superclass.rescue_handlers : own
|
||||||
|
end
|
||||||
|
|
||||||
|
def rescue_from(*exception_classes, with: nil, &block)
|
||||||
|
raise ArgumentError, 'rescue_from needs with: or a block' unless with || block
|
||||||
|
exception_classes.each { |klass| (@rescue_handlers ||= []) << { klass: klass, with: with, block: block } }
|
||||||
end
|
end
|
||||||
|
|
||||||
def before_action(*names, only: nil, except: nil, &block)
|
def before_action(*names, only: nil, except: nil, &block)
|
||||||
register_filter(before_actions, names, only, except, block)
|
register_filter(@before_actions ||= [], names, only, except, block)
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_action(*names, only: nil, except: nil, &block)
|
def after_action(*names, only: nil, except: nil, &block)
|
||||||
register_filter(after_actions, names, only, except, block)
|
register_filter(@after_actions ||= [], names, only, except, block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def protect_from_forgery(only: nil, except: nil)
|
||||||
|
before_action(:verify_authenticity_token!, only: only, except: except)
|
||||||
end
|
end
|
||||||
|
|
||||||
def layout(name = :missing)
|
def layout(name = :missing)
|
||||||
@@ -29,8 +55,11 @@ module Prism
|
|||||||
@views_root || superclass_views_root || 'app/views'
|
@views_root || superclass_views_root || 'app/views'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# "posts" for PostsController, "admin/posts" for Admin::PostsController
|
||||||
|
# (keeps the full module path so namespaced views live under
|
||||||
|
# app/views/admin/posts/, matching Rails' convention).
|
||||||
def controller_path
|
def controller_path
|
||||||
Inflector.underscore(name.to_s.split('::').last.sub(/Controller\z/, ''))
|
Inflector.underscore(name.to_s.sub(/Controller\z/, ''))
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -61,8 +90,12 @@ module Prism
|
|||||||
|
|
||||||
def process
|
def process
|
||||||
catch(:halt) do
|
catch(:halt) do
|
||||||
|
begin
|
||||||
run_before_actions
|
run_before_actions
|
||||||
finalize_body(send(@action_name))
|
finalize_body(send(@action_name))
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_exception(e)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
catch(:halt) { run_after_actions }
|
catch(:halt) { run_after_actions }
|
||||||
finalize_flash
|
finalize_flash
|
||||||
@@ -82,9 +115,40 @@ module Prism
|
|||||||
view_resolver.render_collection(partial_path(name), collection, locals: locals, as: as)
|
view_resolver.render_collection(partial_path(name), collection, locals: locals, as: as)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Rails-style content negotiation: `respond_to { |f| f.html { render }; f.json { json(x) } }`.
|
||||||
|
# Renders 406 when the negotiated format (see Request#format) has no matching block.
|
||||||
|
def respond_to
|
||||||
|
responder = Responder.new
|
||||||
|
yield responder
|
||||||
|
format = request.format
|
||||||
|
responder.respond(format)
|
||||||
|
rescue Responder::UnknownFormat
|
||||||
|
halt(406, "Not Acceptable: #{format}")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
UnsafeMethods = %w[POST PUT PATCH DELETE].freeze
|
||||||
|
|
||||||
|
def verify_authenticity_token!
|
||||||
|
return unless UnsafeMethods.include?(@request.request_method)
|
||||||
|
token = params['authenticity_token'] || @request.headers['X-Csrf-Token']
|
||||||
|
halt(422, 'invalid authenticity token') unless csrf.valid_token?(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds the most specific registered `rescue_from` handler (`is_a?`
|
||||||
|
# against the raised exception) and runs it - the handler is expected
|
||||||
|
# to finish the response itself (`halt`/`render`/`redirect`), the same
|
||||||
|
# contract Rails' rescue_from has. Re-raises when nothing matches, so
|
||||||
|
# an unhandled exception still reaches the application-level fallback.
|
||||||
|
def handle_exception(exception)
|
||||||
|
handler = self.class.rescue_handlers.find { |candidate| exception.is_a?(candidate[:klass]) }
|
||||||
|
raise exception unless handler
|
||||||
|
handler[:block] ? instance_exec(exception, &handler[:block]) : send(handler[:with], exception)
|
||||||
|
end
|
||||||
|
|
||||||
def finalize_body(result)
|
def finalize_body(result)
|
||||||
|
return if @response.streaming?
|
||||||
return unless @response.body.empty?
|
return unless @response.body.empty?
|
||||||
@response.body = [result.to_s] if result.is_a?(String)
|
@response.body = [result.to_s] if result.is_a?(String)
|
||||||
end
|
end
|
||||||
@@ -94,7 +158,7 @@ module Prism
|
|||||||
end
|
end
|
||||||
|
|
||||||
def view_resolver
|
def view_resolver
|
||||||
@view_resolver ||= View.new(self.class.views_root)
|
@view_resolver ||= View.new(self.class.views_root, helpers: self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def run_before_actions
|
def run_before_actions
|
||||||
|
|||||||
Reference in new issue
Block a user