|
| 1 | +require 'kracken/controllers/json_api_compatible' |
| 2 | + |
1 | 3 | module Kracken
|
2 | 4 | module Controllers
|
3 | 5 | 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 | + |
4 | 99 | def self.included(base)
|
5 | 100 | base.instance_exec do
|
| 101 | + extend Macros |
| 102 | + |
6 | 103 | before_action :munge_chained_param_ids!
|
7 | 104 | skip_before_action :verify_authenticity_token
|
| 105 | + end |
| 106 | + end |
8 | 107 |
|
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 |
17 | 113 |
|
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) |
23 | 119 | end
|
| 120 | + end |
24 | 121 |
|
| 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 |
25 | 133 | end
|
26 | 134 | end
|
| 135 | + include DataIntegrity |
27 | 136 |
|
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 |
29 | 145 |
|
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 |
33 | 150 | end
|
| 151 | + include VirtualAttributes |
34 | 152 |
|
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 | + } |
39 | 167 | end
|
40 | 168 |
|
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 |
43 | 190 | end
|
| 191 | + |
44 | 192 | end
|
45 | 193 | end
|
46 | 194 | end
|
47 |
| - |
|
0 commit comments