rocket_pants 1.0.0.rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/lib/rocket_pants.rb +41 -0
- data/lib/rocket_pants/base.rb +60 -0
- data/lib/rocket_pants/cache_middleware.rb +59 -0
- data/lib/rocket_pants/cacheable.rb +19 -0
- data/lib/rocket_pants/client.rb +135 -0
- data/lib/rocket_pants/controller/caching.rb +135 -0
- data/lib/rocket_pants/controller/error_handling.rb +108 -0
- data/lib/rocket_pants/controller/instrumentation.rb +30 -0
- data/lib/rocket_pants/controller/rescuable.rb +66 -0
- data/lib/rocket_pants/controller/respondable.rb +122 -0
- data/lib/rocket_pants/controller/url_for.rb +11 -0
- data/lib/rocket_pants/controller/versioning.rb +39 -0
- data/lib/rocket_pants/exceptions.rb +112 -0
- data/lib/rocket_pants/locale/en.yml +8 -0
- data/lib/rocket_pants/railtie.rb +44 -0
- data/lib/rocket_pants/routing.rb +26 -0
- data/lib/rocket_pants/rspec_matchers.rb +117 -0
- data/lib/rocket_pants/tasks/rocket_pants.rake +32 -0
- data/lib/rocket_pants/test_helper.rb +94 -0
- metadata +186 -0
data/lib/rocket_pants.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'active_support/all'
|
2
|
+
require 'action_dispatch'
|
3
|
+
require 'action_dispatch/routing'
|
4
|
+
require 'action_controller'
|
5
|
+
|
6
|
+
require 'moneta'
|
7
|
+
require 'moneta/memory'
|
8
|
+
|
9
|
+
module RocketPants
|
10
|
+
require 'rocket_pants/exceptions'
|
11
|
+
|
12
|
+
# Set up the routing in advance.
|
13
|
+
require 'rocket_pants/routing'
|
14
|
+
ActionDispatch::Routing::Mapper.send :include, RocketPants::Routing
|
15
|
+
|
16
|
+
require 'rocket_pants/railtie' if defined?(Rails::Railtie)
|
17
|
+
|
18
|
+
# Extra parts of RocketPants.
|
19
|
+
autoload :Base, 'rocket_pants/base'
|
20
|
+
autoload :Client, 'rocket_pants/client'
|
21
|
+
autoload :Cacheable, 'rocket_pants/cacheable'
|
22
|
+
autoload :CacheMiddleware, 'rocket_pants/cache_middleware'
|
23
|
+
|
24
|
+
# Helpers for various testing frameworks.
|
25
|
+
autoload :TestHelper, 'rocket_pants/test_helper'
|
26
|
+
autoload :RSpecMatchers, 'rocket_pants/rspec_matchers'
|
27
|
+
|
28
|
+
mattr_accessor :caching_enabled
|
29
|
+
self.caching_enabled = false
|
30
|
+
|
31
|
+
mattr_writer :cache
|
32
|
+
|
33
|
+
class << self
|
34
|
+
alias caching_enabled? caching_enabled
|
35
|
+
|
36
|
+
def cache
|
37
|
+
@@cache ||= Moneta::Memory.new
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'rocket_pants/exceptions'
|
2
|
+
|
3
|
+
module RocketPants
|
4
|
+
|
5
|
+
autoload :Caching, 'rocket_pants/controller/caching'
|
6
|
+
autoload :ErrorHandling, 'rocket_pants/controller/error_handling'
|
7
|
+
autoload :Instrumentation, 'rocket_pants/controller/instrumentation'
|
8
|
+
autoload :Rescuable, 'rocket_pants/controller/rescuable'
|
9
|
+
autoload :Respondable, 'rocket_pants/controller/respondable'
|
10
|
+
autoload :Versioning, 'rocket_pants/controller/versioning'
|
11
|
+
autoload :UrlFor, 'rocket_pants/controller/url_for'
|
12
|
+
|
13
|
+
class Base < ActionController::Metal
|
14
|
+
|
15
|
+
abstract!
|
16
|
+
|
17
|
+
MODULES = [
|
18
|
+
ActionController::HideActions,
|
19
|
+
ActionController::UrlFor,
|
20
|
+
ActionController::Redirecting,
|
21
|
+
ActionController::ConditionalGet,
|
22
|
+
ActionController::RackDelegation,
|
23
|
+
ActionController::RecordIdentifier,
|
24
|
+
UrlFor,
|
25
|
+
Respondable,
|
26
|
+
Versioning,
|
27
|
+
Instrumentation,
|
28
|
+
Caching,
|
29
|
+
ActionController::MimeResponds,
|
30
|
+
# Include earliest as possible in the request.
|
31
|
+
AbstractController::Callbacks,
|
32
|
+
ActionController::Rescue,
|
33
|
+
ErrorHandling,
|
34
|
+
Rescuable
|
35
|
+
]
|
36
|
+
|
37
|
+
# If possible, include the Rails controller methods in Airbrake to make it useful.
|
38
|
+
begin
|
39
|
+
require 'airbrake'
|
40
|
+
require 'airbrake/rails/controller_methods'
|
41
|
+
MODULES << Airbrake::Rails::ControllerMethods
|
42
|
+
rescue LoadError => e
|
43
|
+
end
|
44
|
+
|
45
|
+
MODULES.each do |mixin|
|
46
|
+
include mixin
|
47
|
+
end
|
48
|
+
|
49
|
+
respond_to :json
|
50
|
+
|
51
|
+
# Bug fix for rails - include compatibility.
|
52
|
+
config_accessor :protected_instance_variables
|
53
|
+
self.protected_instance_variables = %w(@assigns @performed_redirect @performed_render
|
54
|
+
@variables_added @request_origin @url @parent_controller @action_name
|
55
|
+
@before_filter_chain_aborted @_headers @_params @_response)
|
56
|
+
|
57
|
+
ActiveSupport.run_load_hooks(:rocket_pants, self)
|
58
|
+
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module RocketPants
|
2
|
+
class CacheMiddleware
|
3
|
+
|
4
|
+
NOT_MODIFIED = [304, {}, []]
|
5
|
+
|
6
|
+
def initialize(app)
|
7
|
+
@app = app
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(env)
|
11
|
+
dup._call env
|
12
|
+
end
|
13
|
+
|
14
|
+
def _call(env)
|
15
|
+
@env = env
|
16
|
+
if has_valid_etag?
|
17
|
+
debug "Cache key is valid, returning not modified response."
|
18
|
+
NOT_MODIFIED.dup
|
19
|
+
else
|
20
|
+
@app.call env
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def request_path
|
27
|
+
@env['SCRIPT_NAME'].to_s + @env['PATH_INFO'].to_s
|
28
|
+
end
|
29
|
+
|
30
|
+
def has_valid_etag?
|
31
|
+
return false if (etags = request_etags).blank?
|
32
|
+
debug ""
|
33
|
+
etags.any? do |etag|
|
34
|
+
cache_key, value = extract_cache_key_and_value etag
|
35
|
+
debug "Processing cache key for path #{request_path}"
|
36
|
+
debug "Checking cache key #{cache_key} matches the value #{value}"
|
37
|
+
fresh? cache_key, value
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def extract_cache_key_and_value(etag)
|
42
|
+
etag.to_s.split(":", 2)
|
43
|
+
end
|
44
|
+
|
45
|
+
def fresh?(key, value)
|
46
|
+
RocketPants.cache[key.to_s] == value
|
47
|
+
end
|
48
|
+
|
49
|
+
def request_etags
|
50
|
+
stored = @env['HTTP_IF_NONE_MATCH']
|
51
|
+
stored.present? && stored.to_s.scan(/"([^"]+)"/)
|
52
|
+
end
|
53
|
+
|
54
|
+
def debug(message)
|
55
|
+
Rails.logger.debug message if defined?(Rails.logger)
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module RocketPants
|
2
|
+
module Cacheable
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
after_save :record_cache! if respond_to?(:after_save)
|
7
|
+
after_destroy :remove_cache! if respond_to?(:after_destroy)
|
8
|
+
end
|
9
|
+
|
10
|
+
def record_cache!
|
11
|
+
RocketPants::Caching.record self
|
12
|
+
end
|
13
|
+
|
14
|
+
def remove_cache!
|
15
|
+
RocketPants::Caching.remove self
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require 'active_support/core_ext/class/attribute'
|
2
|
+
require 'active_support/core_ext/object/blank'
|
3
|
+
require 'active_support/core_ext/string/inflections'
|
4
|
+
|
5
|
+
require 'rocket_pants/exceptions'
|
6
|
+
|
7
|
+
require 'api_smith'
|
8
|
+
require 'will_paginate/collection'
|
9
|
+
|
10
|
+
module RocketPants
|
11
|
+
# Implements a generalised base for building clients on top of
|
12
|
+
# the rocket pants controller. This automatically unpacks the API
|
13
|
+
# into the correct response types, handles errors (using the same error registry
|
14
|
+
# as the server) and in general makes it simpler to implement api clients.
|
15
|
+
#
|
16
|
+
# @example A versioned api client
|
17
|
+
# class MyApiClient < RocketPants::Client
|
18
|
+
#
|
19
|
+
# class Profile < APISmith::Smash
|
20
|
+
# property :name
|
21
|
+
# property :email
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# version 1
|
25
|
+
# base_uri "http://api.example.com/"
|
26
|
+
#
|
27
|
+
# def profile
|
28
|
+
# get 'profile', :transformer => Profile
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
class Client < APISmith::Base
|
33
|
+
|
34
|
+
class_attribute :_version, :_actual_endpoint
|
35
|
+
|
36
|
+
class << self
|
37
|
+
|
38
|
+
# @overload version
|
39
|
+
# @return [Integer] the current API version number for this client
|
40
|
+
# @overload version(number)
|
41
|
+
# Sets a variable noting what version of the api the client uses and updates
|
42
|
+
# the endpoint to prefix it with the given version number.
|
43
|
+
# @param [Integer] number the version of the api to process.
|
44
|
+
def version(number = nil)
|
45
|
+
if number.nil?
|
46
|
+
_version
|
47
|
+
else
|
48
|
+
self._version = number
|
49
|
+
endpoint _actual_endpoint
|
50
|
+
number
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
alias _original_endpoint endpoint
|
55
|
+
|
56
|
+
# Sets the endpoint url, taking into account the version number as a
|
57
|
+
# prefix if present.
|
58
|
+
def endpoint(path)
|
59
|
+
self._actual_endpoint = path
|
60
|
+
_original_endpoint File.join(*[_version, path].compact.map(&:to_s))
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
# Initializes a new client, optionally setting up the host for this client.
|
66
|
+
# @param [Hash] options general client options
|
67
|
+
# @option options [String] :api_host the overriden base_uri host for this client instance.
|
68
|
+
def initialize(options = {})
|
69
|
+
super
|
70
|
+
# Setup the base uri if passed in to the client.
|
71
|
+
if options[:api_host].present?
|
72
|
+
add_request_options! :base_uri => HTTParty.normalize_base_uri(options[:api_host])
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# Give a response hash from the api, will transform it into
|
79
|
+
# the correct data type.
|
80
|
+
# @param [Hash] response the incoming response
|
81
|
+
# @param [hash] options any options to pass through for transformation
|
82
|
+
# @return [Object] the transformed response.
|
83
|
+
def transform_response(response, options = {})
|
84
|
+
# Now unpack the response into the data types.
|
85
|
+
inner = response.delete("response")
|
86
|
+
objects = unpack inner, options
|
87
|
+
# Unpack pagination as a special case.
|
88
|
+
if response.has_key?("pagination")
|
89
|
+
paginated_response objects, response
|
90
|
+
else
|
91
|
+
objects
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns an API response wrapped in a will_paginate collection
|
96
|
+
# using the pagination key in the response container to set up
|
97
|
+
# the current number of total entries and page details.
|
98
|
+
# @param [Array<Object>] objects the actual response contents
|
99
|
+
# @param [Hash{String => Object}] The response container
|
100
|
+
# @option container [Hash] "pagination" the pagination data to use.
|
101
|
+
def paginated_response(objects, container)
|
102
|
+
pagination = container.delete("pagination")
|
103
|
+
WillPaginate::Collection.create(pagination["current"], pagination["per_page"]) do |collection|
|
104
|
+
collection.replace objects
|
105
|
+
collection.total_entries = pagination["count"]
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Finds and uses the transformer for a given incoming object to
|
110
|
+
# unpack it into a useful data type.
|
111
|
+
# @param [Hash, Array<Hash>] object the response object to unpack
|
112
|
+
# @param [Hash] options the unpacking options
|
113
|
+
# @option options [Hash] :transformer,:as the transformer to use
|
114
|
+
# @return The transformed data or the data itself when no transformer is specified.
|
115
|
+
def unpack(object, options = {})
|
116
|
+
transformer = options[:transformer] || options[:as]
|
117
|
+
transformer ? transformer.call(object) : object
|
118
|
+
end
|
119
|
+
|
120
|
+
# Processes a given response to check for the presence of an error,
|
121
|
+
# either by the response not being a hash (e.g. it is returning HTML instead
|
122
|
+
# JSON or HTTParty couldn't parse the response).
|
123
|
+
# @param [Hash, Object] response the response to check errors on
|
124
|
+
# @raise [RocketPants::Error] a generic error when the type is wrong, or a registered error subclass otherwise.
|
125
|
+
def check_response_errors(response)
|
126
|
+
if !response.is_a?(Hash)
|
127
|
+
raise RocketPants::Error, "The response from the server was not in a supported format."
|
128
|
+
elsif response.has_key?("error")
|
129
|
+
klass = RocketPants::Errors[response["error"]] || RocketPants::Error
|
130
|
+
raise klass.new(response["error_description"])
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'digest/md5'
|
3
|
+
|
4
|
+
module RocketPants
|
5
|
+
# RocketPants::Caching adds unobtrusive support for automatic HTTP-level caching
|
6
|
+
# to controllers built on Rocket Pants. It will automatically.
|
7
|
+
module Caching
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
class_attribute :cached_actions, :caching_timeout, :caching_options
|
12
|
+
self.caching_timeout = 5.minutes
|
13
|
+
self.cached_actions = Set.new
|
14
|
+
self.caching_options = {:public => true}
|
15
|
+
end
|
16
|
+
|
17
|
+
class << self
|
18
|
+
|
19
|
+
# Removes the cache record for a given object, making sure
|
20
|
+
# It isn't contained in the Rocket Pants etag cache thus
|
21
|
+
# ignoring it in incoming responses.
|
22
|
+
# @param [Object] object what to remove from the cache
|
23
|
+
def remove(object)
|
24
|
+
RocketPants.cache.delete cache_key_for(object)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Takes a given object and sets the stored etag in the Rocket Pants
|
28
|
+
# etag key to have the correct value.
|
29
|
+
#
|
30
|
+
# Doing this means that on subsequent requests with caching enabled,
|
31
|
+
# the request will be automatically recorded by the middleware and not
|
32
|
+
# actually served.
|
33
|
+
#
|
34
|
+
# Please note that this expects the object has a cache_key method
|
35
|
+
# defined that will return a string, useable for caching. If not,
|
36
|
+
# it will use inspect which is a VERY VERY bad idea (and will likely
|
37
|
+
# be removed in the near future).
|
38
|
+
#
|
39
|
+
# @param [#cache_key] object what to record in the cache
|
40
|
+
def record(object, cache_key = cache_key_for(object))
|
41
|
+
default_etag = object.inspect
|
42
|
+
if object.respond_to?(:cache_key).presence && (ck = object.cache_key).present?
|
43
|
+
default_etag = ck
|
44
|
+
end
|
45
|
+
generated_etag = Digest::MD5.hexdigest(default_etag)
|
46
|
+
RocketPants.cache[cache_key] = generated_etag
|
47
|
+
end
|
48
|
+
|
49
|
+
# Given an object, returns the etag value to be used in
|
50
|
+
# the response / etag header (prior to any more processing).
|
51
|
+
# If the object isn't in the cache, we'll instead record
|
52
|
+
# it and return the etag for it.
|
53
|
+
# @param [#cache_key] object what to look up the etag for
|
54
|
+
def etag_for(object)
|
55
|
+
cache_key = cache_key_for(object)
|
56
|
+
etag_value = RocketPants.cache[cache_key].presence || record(object, cache_key)
|
57
|
+
"#{cache_key}:#{etag_value}"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Returns the default cache key for a given object.
|
61
|
+
# Note that when the object defines a rp_object_key method, it will
|
62
|
+
# be used as the
|
63
|
+
#
|
64
|
+
# @param [Object, #rp_object_key] the object to find the cache key for.
|
65
|
+
def cache_key_for(object)
|
66
|
+
if object.respond_to?(:rp_object_key) && (ok = object.rp_object_key).present?
|
67
|
+
Digest::MD5.hexdigest ok
|
68
|
+
else
|
69
|
+
suffix = (object.respond_to?(:new?) && object.new?) ? "new" : object.id
|
70
|
+
Digest::MD5.hexdigest "#{object.class.name}/#{suffix}"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def normalise_etag(identifier_or_object)
|
75
|
+
%("#{identifier_or_object.to_s}")
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
module ClassMethods
|
81
|
+
|
82
|
+
# Sets up automatic etag and cache control headers for api resource
|
83
|
+
# controllers using an after filter. Note that for the middleware
|
84
|
+
# to actually be inserted, `RocketPants.enable_caching` needs to be
|
85
|
+
# set to true.
|
86
|
+
# @param [Symbol*] args a collection of action names to perform caching on.
|
87
|
+
# @param [Hash] options options to configure caching
|
88
|
+
# @option options [Integer, ActiveSupport::Duration] :cache_for the amount of
|
89
|
+
# time to cache timeout based actions for.
|
90
|
+
# @example Setting up caching on a series of actions
|
91
|
+
# caches :index, :show
|
92
|
+
# @example Setting up caching with options
|
93
|
+
# caches :index, :show, :cache_for => 3.minutes
|
94
|
+
def caches(*args)
|
95
|
+
options = args.extract_options!
|
96
|
+
self.cached_actions += Array.wrap(args).map(&:to_s).compact
|
97
|
+
# Setup the time based caching.
|
98
|
+
if options.has_key?(:cache_for)
|
99
|
+
self.caching_timeout = options.delete(:cache_for)
|
100
|
+
end
|
101
|
+
# Merge in any caching options for other controllers.
|
102
|
+
caching_options.merge!(options.delete(:caching_options) || {})
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
def cache_action?(action = params[:action])
|
108
|
+
RocketPants.caching_enabled? && cached_actions.include?(action)
|
109
|
+
end
|
110
|
+
|
111
|
+
def cache_response(resource, single_resource)
|
112
|
+
# Add in the default options.
|
113
|
+
response.cache_control.merge! caching_options
|
114
|
+
# We need to set the etag based on the object when it is singular
|
115
|
+
# Note that the object is responsible for clearing the etag cache.
|
116
|
+
if single_resource
|
117
|
+
response["ETag"] = Caching.normalise_etag Caching.etag_for(resource)
|
118
|
+
# Otherwise, it's a collection and we need to use time based caching.
|
119
|
+
else
|
120
|
+
response.cache_control[:max_age] = caching_timeout
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# The callback use to automatically cache the current response object, using it's
|
125
|
+
# cache key as a guide. For collections, instead of using an etag we'll use the request
|
126
|
+
# path as a cache key and instead use a timeout.
|
127
|
+
def post_process_exposed_object(resource, type, singular)
|
128
|
+
super # Make sure we invoke the old hook.
|
129
|
+
if cache_action?
|
130
|
+
cache_response resource, singular
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
end
|