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.
@@ -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