caprese 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.coveralls.yml +1 -0
- data/.gitignore +12 -0
- data/.travis.yml +18 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +41 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/caprese.gemspec +38 -0
- data/lib/caprese/adapter/json_api/error.rb +123 -0
- data/lib/caprese/adapter/json_api/json_pointer.rb +52 -0
- data/lib/caprese/adapter/json_api/jsonapi.rb +49 -0
- data/lib/caprese/adapter/json_api/link.rb +88 -0
- data/lib/caprese/adapter/json_api/meta.rb +37 -0
- data/lib/caprese/adapter/json_api/pagination_links.rb +69 -0
- data/lib/caprese/adapter/json_api/relationship.rb +65 -0
- data/lib/caprese/adapter/json_api/resource_identifier.rb +49 -0
- data/lib/caprese/adapter/json_api.rb +509 -0
- data/lib/caprese/concerns/versioning.rb +69 -0
- data/lib/caprese/controller/concerns/callbacks.rb +60 -0
- data/lib/caprese/controller/concerns/errors.rb +42 -0
- data/lib/caprese/controller/concerns/persistence.rb +327 -0
- data/lib/caprese/controller/concerns/query.rb +209 -0
- data/lib/caprese/controller/concerns/relationships.rb +250 -0
- data/lib/caprese/controller/concerns/rendering.rb +43 -0
- data/lib/caprese/controller/concerns/typing.rb +39 -0
- data/lib/caprese/controller.rb +26 -0
- data/lib/caprese/error.rb +121 -0
- data/lib/caprese/errors.rb +69 -0
- data/lib/caprese/record/errors.rb +82 -0
- data/lib/caprese/record.rb +19 -0
- data/lib/caprese/routing/caprese_resources.rb +27 -0
- data/lib/caprese/serializer/concerns/links.rb +96 -0
- data/lib/caprese/serializer/concerns/lookup.rb +37 -0
- data/lib/caprese/serializer/error_serializer.rb +13 -0
- data/lib/caprese/serializer.rb +13 -0
- data/lib/caprese/version.rb +3 -0
- data/lib/caprese.rb +35 -0
- metadata +273 -0
@@ -0,0 +1,250 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'caprese/errors'
|
3
|
+
|
4
|
+
module Caprese
|
5
|
+
module Relationships
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
# Applies further scopes to a collection association
|
9
|
+
# @note Can be overridden to customize scoping at a per-relationship level
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# def relationship_scope(name, scope)
|
13
|
+
# case name
|
14
|
+
# when :transactions
|
15
|
+
# scope.by_merchant(...)
|
16
|
+
# when :orders
|
17
|
+
# scope.by_user(...)
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# @param [String] name the name of the association
|
22
|
+
# @param [Relation] scope the scope corresponding to a collection association
|
23
|
+
def relationship_scope(name, scope)
|
24
|
+
scope
|
25
|
+
end
|
26
|
+
|
27
|
+
# Retrieves the data for a relationship, not just the definition/resource identifier
|
28
|
+
# @note Resource Identifier = { id: '...', type: '....' }
|
29
|
+
# @note Resource = Resource Identifier + { attributes: { ... } }
|
30
|
+
#
|
31
|
+
# @note Adds a links[:self] link to this endpoint itself, to be JSON API compliant
|
32
|
+
# @note When returning single resource, adds a related endpoint URL that points to
|
33
|
+
# the root resource URL
|
34
|
+
#
|
35
|
+
# @example Order<token: 'asd27h'> with product
|
36
|
+
# links[:self] = 'http://www.example.com/api/v1/orders/asd27h/product'
|
37
|
+
# links[:related] = 'http://www.example.com/api/v1/products/h45sql'
|
38
|
+
#
|
39
|
+
# @example Order<token: 'asd27h'> with transactions
|
40
|
+
# links[:self] = 'http://www.example.com/api/v1/orders/asd27h/transactions/7ytr4l'
|
41
|
+
# links[:related] = 'http://www.example.com/api/v1/transactions/7ytr4l'
|
42
|
+
#
|
43
|
+
# GET /api/v1/:controller/:id/:relationship(/:relation_primary_key_value)
|
44
|
+
def get_relationship_data
|
45
|
+
target =
|
46
|
+
if queried_association.reflection.collection?
|
47
|
+
scope = relationship_scope(params[:relationship], queried_association.reader)
|
48
|
+
|
49
|
+
if params[:relation_primary_key_value].present?
|
50
|
+
get_record!(scope, self.class.config.resource_primary_key, params[:relation_primary_key_value])
|
51
|
+
else
|
52
|
+
apply_sorting_pagination_to_scope(scope)
|
53
|
+
end
|
54
|
+
else
|
55
|
+
queried_association.reader
|
56
|
+
end
|
57
|
+
|
58
|
+
links = { self: request.original_url }
|
59
|
+
|
60
|
+
if !target.respond_to?(:to_ary) &&
|
61
|
+
respond_to?(related_url = version_name("#{params[:relationship].singularize}_url"))
|
62
|
+
|
63
|
+
links[:related] =
|
64
|
+
send(
|
65
|
+
related_url,
|
66
|
+
target.read_attribute(self.config.resource_primary_key)
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
render json: target, links: links
|
71
|
+
end
|
72
|
+
|
73
|
+
# Returns relationship data for a resource
|
74
|
+
#
|
75
|
+
# 1. Find resource we are updating relationship for
|
76
|
+
# 2. Check relationship exists *or respond with error*
|
77
|
+
# 3. Add self/related links for relationship
|
78
|
+
# 4. Respond with relationship data
|
79
|
+
#
|
80
|
+
# @example to-one relationship
|
81
|
+
# GET /orders/asd27h/relationships/product
|
82
|
+
#
|
83
|
+
# {
|
84
|
+
# "links": {
|
85
|
+
# "self": "/orders/asd27h/relationships/product",
|
86
|
+
# "related": "orders/asd27h/product"
|
87
|
+
# },
|
88
|
+
# "data": {
|
89
|
+
# "type": "products",
|
90
|
+
# "token": "hy7sql"
|
91
|
+
# }
|
92
|
+
# }
|
93
|
+
#
|
94
|
+
# @example to-many relationship
|
95
|
+
# GET /orders/1/relationships/transactions
|
96
|
+
#
|
97
|
+
# {
|
98
|
+
# "links": {
|
99
|
+
# "self": "/orders/asd27h/relationships/transactions",
|
100
|
+
# "related": "orders/asd27h/transactions"
|
101
|
+
# },
|
102
|
+
# "data": [
|
103
|
+
# { "type": "transactions", "token": "hy7sql" },
|
104
|
+
# { "type": "transactions", "token": "lki26s" },
|
105
|
+
# ]
|
106
|
+
# }
|
107
|
+
#
|
108
|
+
# GET /api/v1/:controller/:id/relationships/:relationship
|
109
|
+
def get_relationship_definition
|
110
|
+
links = { self: request.original_url }
|
111
|
+
|
112
|
+
# Add related link for this relationship if it exists
|
113
|
+
if respond_to?(related_path = "relationship_data_#{version_name(unversion(params[:controller]).singularize)}_url")
|
114
|
+
links[:related] = send(related_path, params[:id], params[:relationship])
|
115
|
+
end
|
116
|
+
|
117
|
+
target = queried_association.reader
|
118
|
+
|
119
|
+
if queried_association.reflection.collection?
|
120
|
+
target = relationship_scope(params[:relationship], target)
|
121
|
+
end
|
122
|
+
|
123
|
+
render json: target, links: links, fields: {}
|
124
|
+
end
|
125
|
+
|
126
|
+
# Updates a relationship for a resource
|
127
|
+
#
|
128
|
+
# 1. Find resource we are updating relationship for
|
129
|
+
# 2. Check relationship exists *or respond with error*
|
130
|
+
# 3. Find each potential relationship resource corresponding to the resource identifiers passed in
|
131
|
+
# 4. Modify relationship based on relationship type (one-to-many, one-to-one) and HTTP verb (PATCH, POST, DELETE)
|
132
|
+
# 5. Check if update was successful
|
133
|
+
# * If successful, return 204 No Content
|
134
|
+
# * If unsuccessful, return 403 Forbidden
|
135
|
+
#
|
136
|
+
# @example modify to-one relationship
|
137
|
+
# PATCH /orders/asd27h/relationships/product
|
138
|
+
#
|
139
|
+
# {
|
140
|
+
# "data": { "type": "products", "token": "hy7sql" }
|
141
|
+
# }
|
142
|
+
#
|
143
|
+
# @example clear to-one relationship
|
144
|
+
# PATCH /orders/asd27h/relationships/product
|
145
|
+
#
|
146
|
+
# {
|
147
|
+
# "data": null
|
148
|
+
# }
|
149
|
+
#
|
150
|
+
# @example modify to-many relationship
|
151
|
+
# PATCH /orders/asd27h/relationships/transactions
|
152
|
+
#
|
153
|
+
# {
|
154
|
+
# "data": [
|
155
|
+
# { "type": "transactions", "token": "hy7sql" },
|
156
|
+
# { "type": "transactions", "token": "lki26s" },
|
157
|
+
# ]
|
158
|
+
# }
|
159
|
+
#
|
160
|
+
# @example clear to to-many relationship
|
161
|
+
# PATCH /orders/asd27h/relationships/transactions
|
162
|
+
#
|
163
|
+
# {
|
164
|
+
# "data": []
|
165
|
+
# }
|
166
|
+
#
|
167
|
+
# @example append to to-many relationship
|
168
|
+
# POST /orders/asd27h/relationships/transactions
|
169
|
+
#
|
170
|
+
# {
|
171
|
+
# "data": [
|
172
|
+
# { "type": "transactions", "token": "hy7sql" },
|
173
|
+
# { "type": "transactions", "token": "lki26s" },
|
174
|
+
# ]
|
175
|
+
# }
|
176
|
+
#
|
177
|
+
# @example remove from to-many relationship
|
178
|
+
# DELETE /orders/asd27h/relationships/transactions
|
179
|
+
#
|
180
|
+
# {
|
181
|
+
# "data": [
|
182
|
+
# { "type": "transactions", "token": "hy7sql" },
|
183
|
+
# { "type": "transactions", "token": "lki26s" },
|
184
|
+
# ]
|
185
|
+
# }
|
186
|
+
#
|
187
|
+
# PATCH/POST/DELETE /api/v1/:controller/:id/relationships/:relationship
|
188
|
+
def update_relationship_definition
|
189
|
+
if queried_association &&
|
190
|
+
flattened_keys_for(permitted_params_for(:update)).include?(params[:relationship].to_sym)
|
191
|
+
|
192
|
+
relationship_resources =
|
193
|
+
Array.wrap(params[:data]).map do |resource_identifier|
|
194
|
+
get_record!(
|
195
|
+
resource_identifier[:type],
|
196
|
+
column = self.config.resource_primary_key,
|
197
|
+
resource_identifier[column]
|
198
|
+
)
|
199
|
+
end
|
200
|
+
|
201
|
+
successful =
|
202
|
+
case queried_association.reflection.macro
|
203
|
+
when :has_many
|
204
|
+
if request.patch?
|
205
|
+
queried_record.send("#{params[:relationship]}=", relationship_resources)
|
206
|
+
elsif request.post?
|
207
|
+
queried_record.send(params[:relationship]).push relationship_resources
|
208
|
+
elsif request.delete?
|
209
|
+
queried_record.send(params[:relationship]).delete relationship_resources
|
210
|
+
end
|
211
|
+
when :has_one
|
212
|
+
if request.patch?
|
213
|
+
queried_record.send("#{params[:relationship]}=", relationship_resources[0])
|
214
|
+
objects[0].save
|
215
|
+
end
|
216
|
+
when :belongs_to
|
217
|
+
if request.patch?
|
218
|
+
queried_record.send("#{params[:relationship]}=", relationship_resources[0])
|
219
|
+
queried_record.save
|
220
|
+
end
|
221
|
+
end
|
222
|
+
else
|
223
|
+
successful = false
|
224
|
+
end
|
225
|
+
|
226
|
+
if successful
|
227
|
+
head :no_content
|
228
|
+
else
|
229
|
+
fail ActionForbiddenError.new
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
private
|
234
|
+
|
235
|
+
# Gets the association queried by the relationship call
|
236
|
+
#
|
237
|
+
# @note Fails with 404 Not Found if association cannot be found
|
238
|
+
def queried_association
|
239
|
+
unless @queried_association
|
240
|
+
begin
|
241
|
+
@queried_association = queried_record.association(params[:relationship])
|
242
|
+
rescue ActiveRecord::AssociationNotFoundError => e
|
243
|
+
fail AssociationNotFoundError.new(params[:relationship])
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
@queried_association
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'caprese/adapter/json_api'
|
3
|
+
|
4
|
+
module Caprese
|
5
|
+
module Rendering
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
# Override render so we can automatically use our adapter and find the appropriate serializer
|
9
|
+
# instead of requiring that they be explicity stated
|
10
|
+
def render(options = {})
|
11
|
+
options[:adapter] = Caprese::Adapter::JsonApi
|
12
|
+
|
13
|
+
if options[:json].respond_to?(:to_ary)
|
14
|
+
if options[:json].first.is_a?(Error)
|
15
|
+
options[:each_serializer] ||= Serializer::ErrorSerializer
|
16
|
+
elsif options[:json].any?
|
17
|
+
options[:each_serializer] ||= serializer_for(options[:json].first)
|
18
|
+
end
|
19
|
+
else
|
20
|
+
if options[:json].is_a?(Error)
|
21
|
+
options[:serializer] ||= Serializer::ErrorSerializer
|
22
|
+
elsif options[:json].present?
|
23
|
+
options[:serializer] ||= serializer_for(options[:json])
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
super
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Finds a versioned serializer for a given resource
|
33
|
+
#
|
34
|
+
# @example
|
35
|
+
# serializer_for(post) => API::V1::PostSerializer
|
36
|
+
#
|
37
|
+
# @param [ActiveRecord::Base] record the record to find a serializer for
|
38
|
+
# @return [Serializer,Nil] the serializer for the given record
|
39
|
+
def serializer_for(record)
|
40
|
+
version_module("#{record.class.name}Serializer").constantize
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'caprese/errors'
|
3
|
+
|
4
|
+
# Manages type determination and checking for a given controller
|
5
|
+
module Caprese
|
6
|
+
module Typing
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
# Gets the class for a record type
|
10
|
+
# @note "record type" can be plural, singular, or classified
|
11
|
+
# i.e. 'orders', 'order', or 'Order'
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# record_class(:orders) # => Order
|
15
|
+
#
|
16
|
+
# @param [Symbol] type the record type to get the class for
|
17
|
+
# @return [Class] the class for a given record type
|
18
|
+
def record_class(type)
|
19
|
+
type.to_s.classify.constantize
|
20
|
+
end
|
21
|
+
|
22
|
+
# Gets the record class for the current controller
|
23
|
+
#
|
24
|
+
# @return [Class] the record class for the current controller
|
25
|
+
def controller_record_class
|
26
|
+
record_class(unversion(params[:controller]))
|
27
|
+
end
|
28
|
+
|
29
|
+
# Checks if a given type mismatches the controller type
|
30
|
+
# @note Throws :invalid_type error if true
|
31
|
+
#
|
32
|
+
# @param [String] type the pluralized type to check ('products','orders',etc.)
|
33
|
+
def fail_on_type_mismatch(type)
|
34
|
+
unless record_class(type) == controller_record_class
|
35
|
+
fail InvalidTypeError.new(type)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'action_controller'
|
2
|
+
require 'active_support/configurable'
|
3
|
+
require 'caprese/concerns/versioning'
|
4
|
+
require 'caprese/controller/concerns/callbacks'
|
5
|
+
require 'caprese/controller/concerns/errors'
|
6
|
+
require 'caprese/controller/concerns/persistence'
|
7
|
+
require 'caprese/controller/concerns/query'
|
8
|
+
require 'caprese/controller/concerns/relationships'
|
9
|
+
require 'caprese/controller/concerns/rendering'
|
10
|
+
require 'caprese/controller/concerns/typing'
|
11
|
+
|
12
|
+
module Caprese
|
13
|
+
# TODO: Convert to ActionController::API with Rails 5
|
14
|
+
class Controller < ActionController::Base
|
15
|
+
include ActiveSupport::Configurable
|
16
|
+
include Callbacks
|
17
|
+
include Errors
|
18
|
+
include Persistence
|
19
|
+
include Query
|
20
|
+
include Relationships
|
21
|
+
include Rendering
|
22
|
+
include Typing
|
23
|
+
include Versioning
|
24
|
+
extend Versioning
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# TODO: Remove in favor of Rails 5 error details and dynamically setting i18n_scope
|
2
|
+
module Caprese
|
3
|
+
class Error < StandardError
|
4
|
+
attr_reader :field
|
5
|
+
attr_reader :code
|
6
|
+
|
7
|
+
attr_reader :header
|
8
|
+
|
9
|
+
# Initializes a new error
|
10
|
+
#
|
11
|
+
# @param [Symbol] model a symbol representing the model that the error occurred on
|
12
|
+
# @param [String] controller the name of the controller the error occurred in
|
13
|
+
# @param [String] action the name of the controller action the error occurred in
|
14
|
+
# @param [Symbol,String] field a symbol or string representing the field (model attribute or controller param) that the error occurred on
|
15
|
+
# if Symbol, a shallow field name. EX: :password
|
16
|
+
# if String, a nested field name. EX: 'order_items.amount'
|
17
|
+
# @param [Symbol] code the error code
|
18
|
+
# @param [Hash] t the interpolation variables to supply to I18n.t when creating the full error message
|
19
|
+
def initialize(model: nil, controller: nil, action: nil, field: nil, code: :invalid, t: {})
|
20
|
+
@model = model
|
21
|
+
|
22
|
+
@controller = controller
|
23
|
+
@action = action
|
24
|
+
|
25
|
+
@field = field
|
26
|
+
@code = code
|
27
|
+
|
28
|
+
@t = t
|
29
|
+
|
30
|
+
@header = { status: :bad_request }
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [String] The scope to look for I18n translations in
|
34
|
+
def i18n_scope
|
35
|
+
Caprese.config.i18n_scope
|
36
|
+
end
|
37
|
+
|
38
|
+
# The full error message based on the different attributes we initialized the error with
|
39
|
+
#
|
40
|
+
# @return [String] the full error message
|
41
|
+
def full_message
|
42
|
+
if @model
|
43
|
+
if field
|
44
|
+
if i18n_set? "#{i18n_scope}.models.#{@model}.#{field}.#{code}", t
|
45
|
+
I18n.t("#{i18n_scope}.models.#{@model}.#{field}.#{code}", t)
|
46
|
+
elsif i18n_set?("#{i18n_scope}.field.#{code}", t)
|
47
|
+
I18n.t("#{i18n_scope}.field.#{code}", t)
|
48
|
+
else
|
49
|
+
I18n.t("#{i18n_scope}.#{code}", t)
|
50
|
+
end
|
51
|
+
else
|
52
|
+
if i18n_set? "#{i18n_scope}.models.#{@model}.#{code}", t
|
53
|
+
I18n.t("#{i18n_scope}.models.#{@model}.#{code}", t)
|
54
|
+
elsif i18n_set? "#{i18n_scope}.#{code}", t
|
55
|
+
I18n.t("#{i18n_scope}.#{code}", t)
|
56
|
+
else
|
57
|
+
code.to_s
|
58
|
+
end
|
59
|
+
end
|
60
|
+
elsif @controller && @action
|
61
|
+
if field && i18n_set?("#{i18n_scope}.controllers.#{@controller}.#{@action}.#{field}.#{code}", t)
|
62
|
+
I18n.t("#{i18n_scope}.controllers.#{@controller}.#{@action}.#{field}.#{code}", t)
|
63
|
+
elsif i18n_set?("#{i18n_scope}.controllers.#{@controller}.#{@action}.#{code}", t)
|
64
|
+
I18n.t("#{i18n_scope}.controllers.#{@controller}.#{@action}.#{code}", t)
|
65
|
+
else
|
66
|
+
I18n.t("#{i18n_scope}.#{code}", t)
|
67
|
+
end
|
68
|
+
elsif field && i18n_set?("#{i18n_scope}.field.#{code}", t)
|
69
|
+
I18n.t("#{i18n_scope}.field.#{code}", t)
|
70
|
+
elsif i18n_set? "#{i18n_scope}.#{code}", t
|
71
|
+
I18n.t("#{i18n_scope}.#{code}", t)
|
72
|
+
else
|
73
|
+
code.to_s
|
74
|
+
end
|
75
|
+
end
|
76
|
+
alias_method :message, :full_message
|
77
|
+
|
78
|
+
# Allows us to add to the response header when we are failing
|
79
|
+
#
|
80
|
+
# @note Should be used as such: fail Error.new(...).with_headers(...)
|
81
|
+
#
|
82
|
+
# @param [Hash] headers the headers to supply in the error response
|
83
|
+
# @option [Symbol] status the HTTP status code to return
|
84
|
+
# @option [String, Path] location the value for the Location header, useful for redirects
|
85
|
+
def with_header(header = {})
|
86
|
+
@header = header
|
87
|
+
@header[:status] ||= :bad_request
|
88
|
+
self
|
89
|
+
end
|
90
|
+
|
91
|
+
# Creates a serializable hash for the error so we can serialize and return it
|
92
|
+
#
|
93
|
+
# @return [Hash] the serializable hash of the error
|
94
|
+
def as_json
|
95
|
+
{
|
96
|
+
code: code,
|
97
|
+
field: field,
|
98
|
+
message: full_message
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
# Adds field and capitalized Field title to the translation params for every error
|
105
|
+
#
|
106
|
+
# @return [Hash] the full translation params of the error
|
107
|
+
def t
|
108
|
+
@t.merge(field: @field, field_title: @field.to_s.titleize)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Checks whether or not a translation exists
|
112
|
+
#
|
113
|
+
# @param [String] key the I18n translation key
|
114
|
+
# @param [Hash] params any params to pass into the translations so we only raise NotFound
|
115
|
+
# exception we're looking for missing params would cause this to return false improperly
|
116
|
+
# @return [Boolean] whether or not the translation exists in I18n
|
117
|
+
def i18n_set?(key, params = {})
|
118
|
+
I18n.t key, params, :raise => true rescue false
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'caprese/error'
|
2
|
+
|
3
|
+
module Caprese
|
4
|
+
# Thrown when a record was attempted to be persisted and was invalidated
|
5
|
+
#
|
6
|
+
# @param [ActiveRecord::Base] record the record that is invalid
|
7
|
+
class RecordInvalidError < Error
|
8
|
+
attr_reader :record
|
9
|
+
|
10
|
+
def initialize(record)
|
11
|
+
super()
|
12
|
+
@record = record
|
13
|
+
@header = { status: :unprocessable_entity }
|
14
|
+
end
|
15
|
+
|
16
|
+
def as_json
|
17
|
+
record.errors.as_json
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Thrown when a record could not be found
|
22
|
+
#
|
23
|
+
# @param [Symbol] field the field that we searched with
|
24
|
+
# @param [String] model the name of the model we searched for a record of
|
25
|
+
# @param [Value] value the value we searched for a match with
|
26
|
+
class RecordNotFoundError < Error
|
27
|
+
def initialize(field: :id, model: nil, value: nil)
|
28
|
+
super field: field, code: :not_found, t: { model: model, value: value }
|
29
|
+
@header = { status: :not_found }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Thrown when an association was not found when calling `record.association()`
|
34
|
+
#
|
35
|
+
# @param [String] name the name of the association
|
36
|
+
class AssociationNotFoundError < Error
|
37
|
+
def initialize(name)
|
38
|
+
super field: name, code: :association_not_found
|
39
|
+
@header = { status: :not_found }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Thrown when an action that is forbidden was attempted
|
44
|
+
class ActionForbiddenError < Error
|
45
|
+
def initialize
|
46
|
+
super code: :forbidden
|
47
|
+
@header = { status: :forbidden }
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Thrown when an attempt was made to delete a record, but the record could not be deleted
|
52
|
+
# because of restrictions
|
53
|
+
#
|
54
|
+
# @param [String] reason the reason for the restriction
|
55
|
+
class DeleteRestrictedError < Error
|
56
|
+
def initialize(reason)
|
57
|
+
super code: :delete_restricted, t: { reason: reason }
|
58
|
+
@header = { status: :forbidden }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Thrown when an attempt was made to create a record of type that is different than the type
|
63
|
+
# of the controller that the data was sent to
|
64
|
+
class InvalidTypeError < Error
|
65
|
+
def initialize(type)
|
66
|
+
super code: :invalid_type, t: { type: type }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
|
3
|
+
module Caprese
|
4
|
+
module Record
|
5
|
+
class Errors < ActiveModel::Errors
|
6
|
+
# Adds an error object for a field, with a code, to the messages hash
|
7
|
+
#
|
8
|
+
# @param [Symbol] attribute the attribute of the model that this error applies to
|
9
|
+
# @param [Symbol] code the error code for the attribute
|
10
|
+
# @option [Exception,Boolean] strict raise an exception when creating an error
|
11
|
+
# if Exception, raises that exception
|
12
|
+
# if Boolean `true`, raises ActiveModel::StrictValidationFailed
|
13
|
+
# @option [Hash] t interpolation variables to add into the translated full message
|
14
|
+
def add(attribute, code = :invalid, options = {})
|
15
|
+
options = options.dup
|
16
|
+
options[:t] ||= {}
|
17
|
+
options[:t][:count] = options[:count]
|
18
|
+
options[:t][:value] =
|
19
|
+
if attribute != :base && @base.respond_to?(attribute)
|
20
|
+
@base.send(:read_attribute_for_validation, attribute)
|
21
|
+
else
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
|
25
|
+
e = Error.new(
|
26
|
+
model: @base.class.name.underscore.downcase,
|
27
|
+
field: attribute == :base ? nil : attribute,
|
28
|
+
code: code,
|
29
|
+
t: options[:t]
|
30
|
+
)
|
31
|
+
|
32
|
+
if (exception = options[:strict])
|
33
|
+
exception = ActiveModel::StrictValidationFailed if exception == true
|
34
|
+
raise exception, e.full_message
|
35
|
+
end
|
36
|
+
|
37
|
+
self[attribute] << e
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [Boolean] true if the model has no errors
|
41
|
+
def empty?
|
42
|
+
all? { |k,v| !v }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns the full error messages for each error in the model
|
46
|
+
# @note Overriden because original renders full_messages using internal helpers of self instead of error
|
47
|
+
# @return [Hash] a hash mapped by attribute, with each value being an array of full messages for that attribute of the model
|
48
|
+
def full_messages
|
49
|
+
map { |_, e| e.full_message }
|
50
|
+
end
|
51
|
+
|
52
|
+
# @param attribute [Symbol] an attribute in the model
|
53
|
+
# @note Overriden because original renders full_messages using internal helpers of self instead of error
|
54
|
+
# @return [Array] an array of full error messages for a given attribute of the model
|
55
|
+
def full_messages_for(attribute)
|
56
|
+
(get(attribute) || []).map { |e| e.full_message }
|
57
|
+
end
|
58
|
+
|
59
|
+
# @note Overriden because traditionally to_a is an alias for `full_messages`, because in Rails standard there is
|
60
|
+
# no difference between an error and a full message, an error is that full message. With our API, an error is a
|
61
|
+
# model that can render full messages, but it is still a distinct model. `to_a` thus has a different meaning here
|
62
|
+
# than in Rails standard.
|
63
|
+
# @return [Array] an array of all errors in the model
|
64
|
+
def to_a
|
65
|
+
values.flatten
|
66
|
+
end
|
67
|
+
|
68
|
+
# We have not and will not ever implement an XML rendering of these errors
|
69
|
+
def to_xml(options = {})
|
70
|
+
raise NotImplementedError
|
71
|
+
end
|
72
|
+
|
73
|
+
def as_json
|
74
|
+
Hash[
|
75
|
+
map do |field, errors|
|
76
|
+
[field, Array.wrap(errors).map { |e| e.as_json }]
|
77
|
+
end
|
78
|
+
]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
require 'active_support/concern'
|
3
|
+
require 'active_support/dependencies'
|
4
|
+
require 'caprese/errors'
|
5
|
+
require 'caprese/record/errors'
|
6
|
+
|
7
|
+
module Caprese
|
8
|
+
module Record
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
|
11
|
+
mattr_accessor :caprese_style_errors
|
12
|
+
@@caprese_style_errors = true
|
13
|
+
|
14
|
+
# @return [Errors] a cached instance of the model errors class
|
15
|
+
def errors
|
16
|
+
@errors ||= (Caprese::Record.caprese_style_errors ? Caprese::Record : ActiveModel)::Errors.new(self)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'action_dispatch/routing/mapper'
|
2
|
+
|
3
|
+
class ActionDispatch::Routing::Mapper
|
4
|
+
def caprese_resources(*resources, &block)
|
5
|
+
options = resources.extract_options!
|
6
|
+
|
7
|
+
resources.each do |r|
|
8
|
+
resources r, only: [:index, :show, :create, :update, :destroy] do
|
9
|
+
yield if block_given?
|
10
|
+
|
11
|
+
member do
|
12
|
+
get 'relationships/:relationship',
|
13
|
+
to: "#{parent_resource.name}#get_relationship_definition",
|
14
|
+
as: :relationship_definition
|
15
|
+
|
16
|
+
match 'relationships/:relationship',
|
17
|
+
to: "#{parent_resource.name}#update_relationship_definition",
|
18
|
+
via: [:patch, :post, :delete]
|
19
|
+
|
20
|
+
get ':relationship(/:relation_primary_key_value)',
|
21
|
+
to: "#{parent_resource.name}#get_relationship_data",
|
22
|
+
as: :relationship_data
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|