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,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