rocket_pants 1.0.0.rc.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|