Skip to content

Commit 8bf58a0

Browse files
committed
Improved API Support from downstream
This merges support from changes and improvements made in Sauron to be part of this library. This adds middleware the Rails rack stack. It sits after the Rails debug exceptions middleware so it can intercept errors prior to Rails handling them. It also seem to move our middleware after the other debug middleware plugins. This is good for the test environment so we actually see our JSON error messages. There is a bit of boiler plate setup code here just to make Rails happy. This also moves the previous custom resource controller code out into the lib directory prepping it for extraction. This adds error pages for beacons and team resources in the API. This conforms to the JSON API by adding: - a 409 code if there is a conflict - always rendering an array of errors - showing the error attr path in the request In order to handle the conflict a new module is available to use. It sits on top of the uniqueness validator. It uses it to shim in what attrs may or may not have a uniqueness conflict. This is necessary to check if a specific attribute was a conflict instead of relying on inspecting the generated message. No current resources in the public API use the conflict validator.
1 parent 675d370 commit 8bf58a0

File tree

11 files changed

+533
-25
lines changed

11 files changed

+533
-25
lines changed

lib/kracken/controllers/json_api_compatible.rb

Lines changed: 171 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,194 @@
1+
require 'kracken/controllers/json_api_compatible'
2+
13
module Kracken
24
module Controllers
35
module JsonApiCompatible
6+
7+
class ResourceNotFound < StandardError
8+
attr_reader :missing_ids, :resource
9+
def initialize(resource, missing_ids)
10+
@missing_ids = Array(missing_ids)
11+
@resource = resource
12+
super(
13+
"Couldn't find #{resource} with id(s): #{missing_ids.join(', ')}"
14+
)
15+
end
16+
end
17+
18+
class TokenUnauthorized < StandardError
19+
def initialize(msg = nil)
20+
msg ||= 'HTTP Token: Access denied.'
21+
super(msg)
22+
end
23+
end
24+
25+
class UnprocessableEntity < StandardError; end
26+
27+
module MungeAndMirror
28+
# Wraps the data root in an Array, if it is not already an Array. This
29+
# will not wrap the value if the resource root is not present.
30+
def munge_resource_root!
31+
return unless params.key?(resource_type)
32+
# We don't want to munge a non-existent key
33+
params[resource_type] = Array.wrap(params[resource_type])
34+
munge_optional_id!
35+
end
36+
37+
private
38+
39+
def munge_chained_param_ids!
40+
return unless params[:id]
41+
params[:id] = params[:id].split(/,\s*/)
42+
end
43+
44+
def can_munge_ids?
45+
(!params[:id].nil? && params[:id].size == 1) &&
46+
params[resource_type].size == 1
47+
end
48+
49+
def munge_optional_id!
50+
return unless can_munge_ids?
51+
params[resource_type].first[:id] ||= params[:id].first
52+
end
53+
end
54+
55+
module Macros
56+
def self.extended(klass)
57+
klass.instance_exec do
58+
include MungeAndMirror
59+
end
60+
end
61+
62+
def resource_type(type = nil)
63+
if type
64+
alias_method "#{type}_root", :data_root
65+
@_resource_type = type.to_sym
66+
end
67+
@_resource_type ||= :data
68+
end
69+
70+
def munge_resource_root!
71+
before_action :munge_resource_root!
72+
end
73+
74+
def verify_scoped_resource(resource, options = {})
75+
name = "verify_scoped_#{resource}"
76+
relation = options.extract!(:as).fetch(:as, resource).to_s.pluralize
77+
scope = options.extract!(:scope).fetch(:scope, :current_user)
78+
resource_id = (resource_type == resource.to_sym) ? :id : "#{resource}_id"
79+
define_method(name) do
80+
param_ids = Array(params[resource_id])
81+
found_ids = self.send(scope)
82+
.send(relation)
83+
.where(id: param_ids)
84+
.ids
85+
.map(&:to_s)
86+
missing_ids = param_ids - found_ids
87+
unless missing_ids.empty?
88+
raise ResourceNotFound.new(resource, missing_ids)
89+
end
90+
end
91+
before_action name, options
92+
end
93+
94+
def verify_required_params(options = {})
95+
before_action :verify_required_params!, options
96+
end
97+
end
98+
499
def self.included(base)
5100
base.instance_exec do
101+
extend Macros
102+
6103
before_action :munge_chained_param_ids!
7104
skip_before_action :verify_authenticity_token
105+
end
106+
end
8107

9-
unless Rails.application.config.consider_all_requests_local
10-
rescue_from StandardError do |error|
11-
render_json_error 500, error
12-
end
13-
14-
rescue_from ActionController::RoutingError do |error|
15-
render_json_error 404, error
16-
end
108+
# NOTE: Monkey-patch until this is merged into the gem
109+
def request_http_token_authentication(realm = 'Application')
110+
headers["WWW-Authenticate"] = %(Token realm="#{realm.gsub(/"/, "")}")
111+
raise TokenUnauthorized
112+
end
17113

18-
if defined? ActiveRecord
19-
rescue_from ActiveRecord::RecordNotFound do |error|
20-
render_json_error 404, error
21-
end
22-
end
114+
module DataIntegrity
115+
# Scan each item in the data root and enforce it has an id set.
116+
def enforce_resource_ids!
117+
Array.wrap(data_root).each do |resource|
118+
resource.require(:id)
23119
end
120+
end
24121

122+
# Check the provided params to make sure the root resource type key is set.
123+
# If the value for the key is an array, make sure all of the contained hashes
124+
# have an `id` set.
125+
def verify_required_params!
126+
return unless params.require(resource_type) && params[:id]
127+
if Array === data_root
128+
enforce_resource_ids!
129+
elsif params[:id].many?
130+
raise UnprocessableEntity,
131+
"Single beacon object provided but multiple resources requested"
132+
end
25133
end
26134
end
135+
include DataIntegrity
27136

28-
private
137+
module VirtualAttributes
138+
# Grab the data root from the params.
139+
#
140+
# This will either be params[:data] or the custom resource type set on the
141+
# class.
142+
def data_root
143+
params[resource_type]
144+
end
29145

30-
def munge_chained_param_ids!
31-
return unless params[:id]
32-
params[:id] = params[:id].split(/,\s*/)
146+
# Get the set resource type from the class
147+
def resource_type
148+
self.class.resource_type
149+
end
33150
end
151+
include VirtualAttributes
34152

35-
def render_json_error(status, error)
36-
notify_bugsnag(error)
37-
body = {error: {message: error.message, backtrace: error.backtrace}}
38-
render status: status, json: body
153+
# Common Actions Necessary in JSON API controllers
154+
155+
# Wrap a block in an Active Record transaction
156+
#
157+
# The return value of the block is checked to see if it should be considered
158+
# successful. If the value is falsey the transaction is rolledback. If a
159+
# collection of ActiveRecord (or quacking) objects are returned they are
160+
# checked to make sure none have any errors.
161+
def in_transaction
162+
ActiveRecord::Base.transaction {
163+
memo = yield
164+
was_success = !!memo && Array(memo).all? { |t| t.errors.empty? }
165+
was_success or raise ActiveRecord::Rollback
166+
}
39167
end
40168

41-
def notify_bugsnag(error)
42-
Bugsnag.notify(error) if defined? BugSnag
169+
# Process parameters in the standard JSON API way.
170+
#
171+
# If there is no set `id`, the request was likely a `POST` / create action.
172+
# The params are mapped to the permitted params. Otherwise, index the data by
173+
# the supplied ids and transform all the values based on the provided
174+
# permitted params (for strong parameters). Only objects whose `id`s match
175+
# the requested ids are returned.
176+
#
177+
# If the data was a Hash, then the single `permit_params` object is returned.
178+
# Or the tuple [`id`, `permitd_params`] respectively.
179+
def process_params(permitted_params)
180+
single_resource = Hash === data_root
181+
data = Array.wrap(data_root)
182+
mapping = if params[:id].blank?
183+
data.map { |attrs| attrs.permit(permitted_params) }
184+
else
185+
data.index_by { |d| d[:id].to_s }
186+
.transform_values { |attrs| attrs.permit(permitted_params) }
187+
.slice(*params[:id])
188+
end
189+
single_resource ? mapping.first : mapping
43190
end
191+
44192
end
45193
end
46194
end
47-

lib/kracken/controllers/token_authenticatable.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ def self.included(base)
1010
base.instance_exec do
1111
before_action :authenticate_user_with_token!
1212
helper_method :current_user
13+
14+
rescue_from Kracken::RequestError do |_|
15+
# TODO: Handle other types of errors (such as if the server is down)
16+
raise Kracken::Controllers::JsonApiCompatible::TokenUnauthorized,
17+
"Invalid credentials"
18+
end
1319
end
20+
1421
end
1522

1623
attr_reader :current_user

lib/kracken/json_api.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
require_relative 'json_api/exception_wrapper'
2+
require_relative 'json_api/path'
3+
require_relative 'json_api/public_exceptions'
4+
require_relative 'json_api/request'
5+
require_relative 'json_api/routing_mapper'
6+
7+
module Kracken
8+
module JsonApi
9+
def self.has_path?(request)
10+
paths.any? { |path| path.matches?(request) }
11+
end
12+
13+
def self.paths
14+
@paths ||= []
15+
end
16+
17+
def self.add_path(path)
18+
paths << Path.new(path)
19+
end
20+
end
21+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module Kracken
2+
module JsonApi
3+
class ExceptionWrapper < ActionDispatch::ExceptionWrapper
4+
cattr_accessor :rescue_with_details_responses
5+
@@rescue_with_details_responses = Hash.new
6+
@@rescue_with_details_responses.merge!(
7+
'Kracken::Controllers::JsonApiCompatible::ResourceNotFound' => :not_found,
8+
'Kracken::Controllers::JsonApiCompatible::TokenUnauthorized' => :unauthorized,
9+
'Kracken::Controllers::JsonApiCompatible::UnprocessableEntity' => :unprocessable_entity,
10+
)
11+
12+
def self.status_code_for_exception(class_name)
13+
if @@rescue_with_details_responses.has_key?(class_name)
14+
Rack::Utils.status_code(@@rescue_with_details_responses[class_name])
15+
else
16+
Rack::Utils.status_code(@@rescue_responses[class_name])
17+
end
18+
end
19+
20+
def is_details_exception?
21+
@@rescue_with_details_responses.has_key?(exception.class.name)
22+
end
23+
end
24+
end
25+
end

lib/kracken/json_api/path.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module Kracken
2+
module JsonApi
3+
class Path
4+
attr_reader :path_match
5+
6+
def initialize(path)
7+
@path_match = Pathname(path).join('*').to_path
8+
end
9+
10+
def matches?(request)
11+
request.supports_json_format? && request.path.fnmatch?(path_match)
12+
end
13+
end
14+
end
15+
end

0 commit comments

Comments
 (0)