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