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