rocket_pants 1.0.0.rc.1

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