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,108 @@
1
+ module RocketPants
2
+ # Makes it easier to both throw named exceptions (corresponding to
3
+ # error states in the response) and to then process the response into
4
+ # something useable in the response.
5
+ module ErrorHandling
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ rescue_from RocketPants::Error, :with => :render_error
10
+ class_attribute :error_mapping
11
+ self.error_mapping = {}
12
+ end
13
+
14
+ module ClassMethods
15
+
16
+ def map_error!(from, to)
17
+ error_mapping[from] = to
18
+ rescue_from from, :with => :render_error
19
+ end
20
+
21
+ end
22
+
23
+ # Dynamically looks up and then throws the error given by a symbolic name.
24
+ # Optionally takes a string message argument and a hash of 'context'.
25
+ # @overload error!(name, context = {})
26
+ # @param [Symbol] name the name of the exception, looked up using RocketPants::Errors
27
+ # @param [Hash{Symbol => Object}] context the options passed to the error message translation.
28
+ # @overload error!(name, message, context = {})
29
+ # @param [Symbol] name the name of the exception, looked up using RocketPants::Errors
30
+ # @param [String] message an optional message describing the error
31
+ # @param [Hash{Symbol => Object}] context the options passed to the error message translation.
32
+ # @raise [RocketPants::Error] the error from the given options
33
+ def error!(name, *args)
34
+ context = args.extract_options!
35
+ klass = Errors[name] || Error
36
+ exception = klass.new(*args).tap { |e| e.context = context }
37
+ raise exception
38
+ end
39
+
40
+ # From a given exception, gets the corresponding error message using I18n.
41
+ # It will use context (if defined) on the exception for the I18n context base and
42
+ # will use either the result of Error#error_name (if present) or :system for
43
+ # the name of the error.
44
+ # @param [StandardError] exception the exception to get the message from
45
+ # @return [String] the found error message.
46
+ def lookup_error_message(exception)
47
+ # TODO: Add in notification hooks for non-standard exceptions.
48
+ name = lookup_error_name(exception)
49
+ message = (exception.message == exception.class.name) ? 'An unknown error has occurred.' : exception.message
50
+ I18n.t name, lookup_error_context(exception).reverse_merge(:scope => :"rocket_pants.errors", :default => message)
51
+ end
52
+
53
+ # Lookup error name will automatically handle translating errors to a
54
+ # simpler, symbol representation. It's implemented like this to make it
55
+ # possible to override to a simple format.
56
+ # @param [StandardError] exception the exception to find the name for
57
+ # @return [Symbol] the name of the given error
58
+ def lookup_error_name(exception)
59
+ if exception.respond_to?(:error_name)
60
+ exception.error_name
61
+ else
62
+ :system
63
+ end
64
+ end
65
+
66
+ # Returns the error status code for a given exception.
67
+ # @param [StandardError] exception the exception to find the status for
68
+ # @return [Symbol] the name of the given error
69
+ def lookup_error_status(exception)
70
+ exception.respond_to?(:http_status) ? exception.http_status : 500
71
+ end
72
+
73
+ # Returns the i18n context for a given exception.
74
+ # @param [StandardError] exception the exception to find the context for
75
+ # @param [Hash] the i18n translation context.
76
+ def lookup_error_context(exception)
77
+ exception.respond_to?(:context) ? exception.context : {}
78
+ end
79
+
80
+ # Returns extra error details for a given object, making it useable
81
+ # for hooking in external exceptions.
82
+ def lookup_error_extras(exception)
83
+ {}
84
+ end
85
+
86
+ # Renders an exception as JSON using a nicer version of the error name and
87
+ # error message, following the typically error standard as laid out in the JSON
88
+ # api design.
89
+ # @param [StandardError] exception the error to render a response for.
90
+ def render_error(exception)
91
+ logger.debug "Rendering error for #{exception.class.name}: #{exception.message}" if logger
92
+ # When a normalised class is present, make sure we
93
+ # convert it to a useable error class.
94
+ normalised_class = exception.class.ancestors.detect do |klass|
95
+ klass < StandardError and error_mapping.has_key?(klass)
96
+ end
97
+ if normalised_class
98
+ exception = error_mapping[normalised_class].new(exception.message)
99
+ end
100
+ self.status = lookup_error_status(exception)
101
+ render_json({
102
+ :error => lookup_error_name(exception).to_s,
103
+ :error_description => lookup_error_message(exception)
104
+ }.merge(lookup_error_extras(exception)))
105
+ end
106
+
107
+ end
108
+ end
@@ -0,0 +1,30 @@
1
+ require 'action_controller/log_subscriber'
2
+ require 'abstract_controller/logger'
3
+
4
+ module RocketPants
5
+ module Instrumentation
6
+ extend ActiveSupport::Concern
7
+ include AbstractController::Logger
8
+
9
+ def process_action(action, *args)
10
+ raw_payload = {
11
+ :controller => self.class.name,
12
+ :action => self.action_name,
13
+ :params => request.filtered_parameters,
14
+ :formats => [:json],
15
+ :method => request.method,
16
+ :path => (request.fullpath rescue "unknown")
17
+ }
18
+
19
+ ActiveSupport::Notifications.instrument("start_processing.rocket_pants", raw_payload.dup)
20
+
21
+ ActiveSupport::Notifications.instrument("process_action.rocket_pants", raw_payload) do |payload|
22
+ result = super
23
+ payload[:status] = response.status
24
+ result
25
+ end
26
+ end
27
+
28
+ end
29
+ ActionController::LogSubscriber.attach_to :rocket_pants
30
+ end
@@ -0,0 +1,66 @@
1
+ module RocketPants
2
+ # An alternative to Rail's built in ActionController::Rescue module,
3
+ # tailored to deeply integrate rescue notififers into the application.
4
+ #
5
+ # Thus, it is relatively simple to
6
+ module Rescuable
7
+ extend ActiveSupport::Concern
8
+ include ActiveSupport::Rescuable
9
+
10
+ DEFAULT_NOTIFIER_CALLBACK = lambda do |controller, exception, req|
11
+ # Do nothing by default...
12
+ end
13
+
14
+ NAMED_NOTIFIER_CALLBACKS = {
15
+ :airbrake => lambda { |c, e, r|
16
+ unless c.send(:airbrake_local_request?)
17
+ c.error_identifier = Airbrake.notify(e, c.send(:airbrake_request_data))
18
+ end
19
+ }
20
+ }
21
+
22
+ included do
23
+ class_attribute :exception_notifier_callback
24
+ attr_accessor :error_identifier
25
+ self.exception_notifier_callback ||= DEFAULT_NOTIFIER_CALLBACK
26
+ end
27
+
28
+ module ClassMethods
29
+
30
+ # Tells rocketpants to use the given exception handler to deal with errors.
31
+ # E.g. use_named_exception_handler! :airbrake
32
+ # @param [Symbol] name the name of the exception handler to use.
33
+ def use_named_exception_notifier(name)
34
+ handler = NAMED_NOTIFIER_CALLBACKS.fetch(name, DEFAULT_NOTIFIER_CALLBACK)
35
+ self.exception_notifier_callback = handler
36
+ end
37
+
38
+ end
39
+
40
+ # Overrides the lookup_error_extras method to also include an error_identifier field
41
+ # to be sent back to the client.
42
+ def lookup_error_extras(exception)
43
+ extras = super
44
+ extras = extras.merge(:error_identifier => error_identifier) if error_identifier
45
+ extras
46
+ end
47
+
48
+ private
49
+
50
+ # Overrides the processing internals to rescue any exceptions and handle them with the
51
+ # registered exception rescue handler.
52
+ def process_action(*args)
53
+ super
54
+ rescue Exception => exception
55
+ # Otherwise, use the default built in handler.
56
+ logger.error "Exception occured: #{exception.class.name} - #{exception.message}"
57
+ logger.error "Exception backtrace:"
58
+ exception.backtrace[0, 10].each do |backtrace_line|
59
+ logger.error "=> #{backtrace_line}"
60
+ end
61
+ exception_notifier_callback.call(self, exception, request)
62
+ render_error exception
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,122 @@
1
+ require 'will_paginate/collection'
2
+
3
+ module RocketPants
4
+ module Respondable
5
+ extend ActiveSupport::Concern
6
+
7
+ def self.normalise_object(object, options = {})
8
+ # Convert the object using a standard grape-like lookup chain.
9
+ if object.is_a?(Array) || object.is_a?(Set)
10
+ object.map { |o| normalise_object o, options }
11
+ elsif object.respond_to?(:serializable_hash)
12
+ object.serializable_hash options
13
+ elsif object.respond_to?(:as_json)
14
+ object.as_json options
15
+ else
16
+ object
17
+ end
18
+ end
19
+
20
+ RENDERING_OPTIONS = [:status, :content_type]
21
+
22
+ private
23
+
24
+ def normalise_object(object, options = {})
25
+ Respondable.normalise_object object, options.except(*RENDERING_OPTIONS)
26
+ end
27
+
28
+ # Given a json object or encoded json, will encode it
29
+ # and set it to be the output of the given page.
30
+ def render_json(json, options = {})
31
+ # Setup data from options
32
+ self.status = options[:status] if options[:status]
33
+ self.content_type = options[:content_type] if options[:content_type]
34
+ options = options.slice(*RENDERING_OPTIONS)
35
+ # Don't convert raw strings to JSON.
36
+ json = ActiveSupport::JSON.encode(json) unless json.respond_to?(:to_str)
37
+ # Encode the object to json.
38
+ self.status ||= :ok
39
+ self.content_type ||= Mime::JSON
40
+ self.response_body = json
41
+ headers['Content-Length'] = Rack::Utils.bytesize(json).to_s
42
+ end
43
+
44
+ # Renders a raw object, without any wrapping etc.
45
+ # Suitable for nicer object handling.
46
+ def responds(object, options = {})
47
+ render_json normalise_object(object, options), options
48
+ end
49
+
50
+ # Renders a single resource.
51
+ def resource(object, options = {})
52
+ pre_process_exposed_object object, :resource, true
53
+ render_json({
54
+ :response => normalise_object(object, options)
55
+ }, options)
56
+ post_process_exposed_object object, :resource, true
57
+ end
58
+
59
+ # Renders a normal collection to JSON.
60
+ def collection(collection, options = {})
61
+ pre_process_exposed_object collection, :collection, false
62
+ options = options.reverse_merge(:compact => true)
63
+ render_json({
64
+ :response => normalise_object(collection, options),
65
+ :count => collection.length
66
+ }, options)
67
+ post_process_exposed_object collection, :collection, false
68
+ end
69
+
70
+ # Renders a paginated collecton to JSON.
71
+ def paginated(collection, options = {})
72
+ pre_process_exposed_object collection, :paginated, false
73
+ options = options.reverse_merge(:compact => true)
74
+ render_json({
75
+ :response => normalise_object(collection, options),
76
+ :count => collection.length,
77
+ :pagination => {
78
+ :previous => collection.previous_page.try(:to_i),
79
+ :next => collection.next_page.try(:to_i),
80
+ :current => collection.current_page.try(:to_i),
81
+ :per_page => collection.per_page.try(:to_i),
82
+ :count => collection.total_entries.try(:to_i),
83
+ :pages => collection.total_pages.try(:to_i)
84
+ }
85
+ }, options)
86
+ post_process_exposed_object collection, :paginated, false
87
+ end
88
+
89
+ # Exposes an object to the response - Essentiall, it tells the
90
+ # controller that this is what we need to render.
91
+ def exposes(object, options = {})
92
+ case object
93
+ when WillPaginate::Collection
94
+ paginated object, options
95
+ when Array
96
+ collection object, options
97
+ else
98
+ resource object, options
99
+ end
100
+ end
101
+ alias expose exposes
102
+
103
+ # Hooks in to allow you to perform pre-processing of objects
104
+ # when they are exposed. Used for plugins to the controller.
105
+ #
106
+ # @param [Object] resource the exposed object.
107
+ # @param [Symbol] type the type of object exposed, one of :resource, :collection or :paginated
108
+ # @param [true,false] singular Whether or not the given object is singular (e.g. :resource)
109
+ def pre_process_exposed_object(resource, type, singular)
110
+ end
111
+
112
+ # Hooks in to allow you to perform post-processing of objects
113
+ # when they are exposed. Used for plugins to the controller.
114
+ #
115
+ # @param [Object] resource the exposed object.
116
+ # @param [Symbol] type the type of object exposed, one of :resource, :collection or :paginated
117
+ # @param [true,false] singular Whether or not the given object is singular (e.g. :resource)
118
+ def post_process_exposed_object(resource, type, singular)
119
+ end
120
+
121
+ end
122
+ end
@@ -0,0 +1,11 @@
1
+ module RocketPants
2
+ module UrlFor
3
+
4
+ def url_options
5
+ options = super
6
+ options = options.merge(:version => version) if version.present?
7
+ options
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ module RocketPants
2
+ module Versioning
3
+
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :_version_range
8
+ end
9
+
10
+ module ClassMethods
11
+
12
+ def version(version)
13
+ version = version..version if version.is_a?(Integer)
14
+ self._version_range = version
15
+ before_filter :verify_api_version
16
+ end
17
+
18
+ end
19
+
20
+ protected
21
+
22
+ def version
23
+ if !instance_variable_defined?(:@version)
24
+ @version = begin
25
+ version = params[:version]
26
+ version.presence && Integer(version)
27
+ rescue ArgumentError
28
+ nil
29
+ end
30
+ end
31
+ @version
32
+ end
33
+
34
+ def verify_api_version
35
+ error! :invalid_version unless version.present? && _version_range.include?(version)
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,112 @@
1
+ module RocketPants
2
+
3
+ # Represents the standard error type as defined by the API.
4
+ # RocketPants::Error instances will be caught and automatically rendered as JSON
5
+ # by the controller during processing.
6
+ class Error < StandardError
7
+
8
+ # @overload error_name
9
+ # Returns the error name for this error class, defaulting to
10
+ # the class name underscorized minus _error.
11
+ # @return [Symbol] the given errors name.
12
+ # @overload error_name(value)
13
+ # Sets the error name for the current class.
14
+ # @param [#to_sym] the name of this error.
15
+ def self.error_name(value = nil)
16
+ if value.nil?
17
+ @name ||= name.underscore.split("/").last.sub(/_error$/, '').to_sym
18
+ else
19
+ @name = (value.presence && value.to_sym)
20
+ end
21
+ end
22
+
23
+ # @overload http_status
24
+ # Returns the http status code of this error, defaulting to 400 (Bad Request).
25
+ # @overload http_status(value)
26
+ # Sets the http status code for this error to a given symbol / integer.
27
+ # @param value [String, Fixnum] value the new status code.
28
+ def self.http_status(value = nil)
29
+ if value.nil?
30
+ @http_status ||= 400
31
+ else
32
+ @http_status = (value.presence && value)
33
+ end
34
+ end
35
+
36
+ # Gets the name of this error from the class.
37
+ def error_name
38
+ self.class.error_name
39
+ end
40
+
41
+ # Gets the http status of this error from the class.
42
+ def http_status
43
+ self.class.http_status
44
+ end
45
+
46
+ # Setter for optional data about this error, used for translation.
47
+ attr_writer :context
48
+
49
+ # Gets the context for this error, defaulting to nil.
50
+ # @return [Hash] the context for this param.
51
+ def context
52
+ @context ||= {}
53
+ end
54
+
55
+ error_name :unknown
56
+
57
+ end
58
+
59
+ # A simple map of data about errors that the rocket pants system can handle.
60
+ class Errors
61
+
62
+ @@errors = {}
63
+
64
+ # Returns a hash of all known errors, keyed by their error name.
65
+ # @return [Hash{Symbol => RocketPants::Error}] the hash of known errors.
66
+ def self.all
67
+ @@errors.dup
68
+ end
69
+
70
+ # Looks up a specific error from the given name, returning nil if none are found.
71
+ # @param [#to_sym] name the name of the error to look up.
72
+ # @return [Error, nil] the error class if found, otherwise nil.
73
+ def self.[](name)
74
+ @@errors[name.to_sym]
75
+ end
76
+
77
+ # Adds a given Error class in the list of all errors, making it suitable
78
+ # for lookup via [].
79
+ # @see Errors[]
80
+ # @param [Error] error the error to register.
81
+ def self.add(error)
82
+ @@errors[error.error_name] = error
83
+ end
84
+
85
+ # Creates an error class to represent a given error state.
86
+ # @param [Symbol] name the name of the given error
87
+ # @param [Hash] options the options used to create the error class.
88
+ # @option options [Symbol] :class_name the name of the class (under `RocketPants`), defaulting to the classified name.
89
+ # @option options [Symbol] :error_name the name of the error, defaulting to the name parameter.
90
+ # @option options [Symbol] :http_status the status code for the given error, doing nothing if absent.
91
+ # @example Adding a RocketPants::NinjasUnavailable class w/ `:service_unavailable` as the status code:
92
+ # register! :ninjas_unavailable, :http_status => :service_unavailable
93
+ def self.register!(name, options = {})
94
+ klass_name = (options[:class_name] || name.to_s.classify).to_sym
95
+ klass = Class.new(Error)
96
+ klass.error_name(options[:error_name] || name.to_s.underscore)
97
+ klass.http_status(options[:http_status]) if options[:http_status].present?
98
+ (options[:under] || RocketPants).const_set klass_name, klass
99
+ add klass
100
+ klass
101
+ end
102
+
103
+ # The default set of exceptions.
104
+ register! :throttled, :http_status => :service_unavailable
105
+ register! :unauthenticated, :http_status => :unauthorized
106
+ register! :invalid_version, :http_status => :not_found
107
+ register! :not_implemented, :http_status => :service_unavailable
108
+ register! :not_found, :http_status => :not_found
109
+
110
+ end
111
+
112
+ end