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