rhino-rails 4.9.0 → 4.10.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 +4 -4
- data/lib/rhino/commands/generate_command.rb +64 -0
- data/lib/rhino/concerns/has_validation.rb +24 -0
- data/lib/rhino/configuration.rb +56 -2
- data/lib/rhino/configuration_error.rb +14 -0
- data/lib/rhino/controllers/resources_controller.rb +291 -21
- data/lib/rhino/resource_request.rb +184 -0
- data/lib/rhino/templates/generate/request.rb.erb +111 -0
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7940c1894a48b568ac853c65c73597adc6e56128b3a4d5958c003c6bc0ab03e9
|
|
4
|
+
data.tar.gz: 4214c47df8a3caaa7bb7ef81957a77935b795d67c057b1797bf4945e66c677cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f8235a208d9e2b23be8e6a33f2ad0623d56f43fa2443ed0727c19aa8888e888ea88778ea4885bb8d38abc2d3d3de030e500767f9e372b0986d0ebfb6037bd76
|
|
7
|
+
data.tar.gz: d373f8eed1679caef1d7f2f6a9f516161eb8eb6bfae9e6e536add126cf8a3f923ed849fe3713865810cd2d9e943905bf3befbc0201d599bbb0ab8455808fb4f2
|
|
@@ -15,6 +15,7 @@ module Rhino
|
|
|
15
15
|
menu.choice "Model (with migration and factory)", "model"
|
|
16
16
|
menu.choice "Policy (extends ResourcePolicy)", "policy"
|
|
17
17
|
menu.choice "Scope (for ScopedDB)", "scope"
|
|
18
|
+
menu.choice "Request (validation for store/update)", "request"
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
name = ask("What is the resource name? (PascalCase singular, e.g., Post):")
|
|
@@ -32,6 +33,8 @@ module Rhino
|
|
|
32
33
|
generate_policy(name)
|
|
33
34
|
when "scope"
|
|
34
35
|
generate_scope(name)
|
|
36
|
+
when "request"
|
|
37
|
+
generate_request(name)
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
|
|
@@ -198,6 +201,50 @@ module Rhino
|
|
|
198
201
|
say ""
|
|
199
202
|
end
|
|
200
203
|
|
|
204
|
+
# ----------------------------------------------------------------
|
|
205
|
+
# Request generation
|
|
206
|
+
# ----------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
# Generates {Model}StoreRequest / {Model}UpdateRequest into app/requests/,
|
|
209
|
+
# which Zeitwerk autoloads like every other app/* directory — no
|
|
210
|
+
# initializer and no eager_load_paths entry is needed for the naming
|
|
211
|
+
# convention to find them.
|
|
212
|
+
def generate_request(name)
|
|
213
|
+
model_name = name.sub(/(Store|Update)?Request\z/, "")
|
|
214
|
+
model_name = name if model_name.blank?
|
|
215
|
+
|
|
216
|
+
which = select("Which request classes should be generated?") do |menu|
|
|
217
|
+
menu.choice "Store (POST /{resource})", "store"
|
|
218
|
+
menu.choice "Update (PUT /{resource}/:id)", "update"
|
|
219
|
+
menu.choice "Both", "both"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
actions = which == "both" ? %w[store update] : [which]
|
|
223
|
+
created = []
|
|
224
|
+
|
|
225
|
+
actions.each do |request_action|
|
|
226
|
+
class_name = request_class_name(model_name, request_action)
|
|
227
|
+
task("Generating #{class_name}") do
|
|
228
|
+
created << write_request_file(model_name, request_action)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
say ""
|
|
233
|
+
say "#{created.length == 1 ? 'Request' : 'Requests'} generated successfully!", :green
|
|
234
|
+
say ""
|
|
235
|
+
created.each { |path| say " Created: #{path}" }
|
|
236
|
+
say ""
|
|
237
|
+
say " Next steps:", :yellow
|
|
238
|
+
say " 1. Declare an `attribute` for EVERY field the action may write —"
|
|
239
|
+
say " an undeclared field is dropped, not persisted."
|
|
240
|
+
say " 2. Add validations, and override authorize?/prepare if needed."
|
|
241
|
+
say ""
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def request_class_name(model_name, request_action)
|
|
245
|
+
"#{model_name}#{request_action == 'update' ? 'Update' : 'Store'}Request"
|
|
246
|
+
end
|
|
247
|
+
|
|
201
248
|
# ----------------------------------------------------------------
|
|
202
249
|
# Column collection
|
|
203
250
|
# ----------------------------------------------------------------
|
|
@@ -389,6 +436,23 @@ module Rhino
|
|
|
389
436
|
File.write(dest, content)
|
|
390
437
|
end
|
|
391
438
|
|
|
439
|
+
def write_request_file(name, request_action)
|
|
440
|
+
template = File.expand_path("../../templates/generate/request.rb.erb", __FILE__)
|
|
441
|
+
class_name = request_class_name(name, request_action)
|
|
442
|
+
relative = "app/requests/#{class_name.underscore}.rb"
|
|
443
|
+
dest = Rails.root.join(relative)
|
|
444
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
445
|
+
|
|
446
|
+
content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
|
|
447
|
+
name: name,
|
|
448
|
+
class_name: class_name,
|
|
449
|
+
action: request_action
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
File.write(dest, content)
|
|
453
|
+
relative
|
|
454
|
+
end
|
|
455
|
+
|
|
392
456
|
def register_model_in_config(name)
|
|
393
457
|
config_path = Rails.root.join("config/initializers/rhino.rb")
|
|
394
458
|
return unless File.exist?(config_path)
|
|
@@ -33,6 +33,11 @@ module Rhino
|
|
|
33
33
|
# Filters to only permitted fields, then runs ActiveModel validations
|
|
34
34
|
# and cross-tenant FK validation.
|
|
35
35
|
#
|
|
36
|
+
# @deprecated Model-level validation is superseded by request classes
|
|
37
|
+
# ({Model}StoreRequest / {Model}UpdateRequest, see Rhino::ResourceRequest).
|
|
38
|
+
# It still works unchanged for every model that has no request class for
|
|
39
|
+
# the action and will be removed in 5.0.
|
|
40
|
+
#
|
|
36
41
|
# @param params [Hash] The request data
|
|
37
42
|
# @param permitted_fields [Array<String>] Fields the user is allowed to set (['*'] for all)
|
|
38
43
|
# @param organization [Object, nil] Current organization for FK scoping (optional)
|
|
@@ -80,6 +85,25 @@ module Rhino
|
|
|
80
85
|
end
|
|
81
86
|
end
|
|
82
87
|
|
|
88
|
+
# Public entry point for cross-tenant FK validation.
|
|
89
|
+
#
|
|
90
|
+
# The request-class path (Rhino::ResourceRequest) runs its own validations
|
|
91
|
+
# and therefore never calls validate_for_action, but it still needs the
|
|
92
|
+
# cross-tenant FK check — including the indirect case, where the referenced
|
|
93
|
+
# table reaches the organization through a FK chain rather than an
|
|
94
|
+
# organization_id column. This wraps the existing private implementation so
|
|
95
|
+
# the chain walk and its class-level caches stay in one place.
|
|
96
|
+
#
|
|
97
|
+
# @param data [Hash] the write payload (string-keyed)
|
|
98
|
+
# @param organization [Object, nil]
|
|
99
|
+
# @return [Hash<String, Array<String>>] {} when there is no organization
|
|
100
|
+
def rhino_validate_foreign_keys(data, organization)
|
|
101
|
+
return {} unless organization
|
|
102
|
+
return {} unless data.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
validate_foreign_keys_for_organization(data, organization)
|
|
105
|
+
end
|
|
106
|
+
|
|
83
107
|
private
|
|
84
108
|
|
|
85
109
|
# Cache for FK chain lookups (class-level)
|
data/lib/rhino/configuration.rb
CHANGED
|
@@ -16,9 +16,14 @@ module Rhino
|
|
|
16
16
|
# Default 3 — a base scope, a window, and one more predicate.
|
|
17
17
|
attr_reader :max_scopes_per_request
|
|
18
18
|
attr_reader :auth
|
|
19
|
+
# Explicit per-model request-class registrations, kept OUT of @models so its
|
|
20
|
+
# `slug => "ClassName"` shape (read all over the library) is untouched.
|
|
21
|
+
# Shape: { slug_sym => { store: "ClassName" | nil, update: "ClassName" | nil } }
|
|
22
|
+
attr_reader :model_requests
|
|
19
23
|
|
|
20
24
|
def initialize
|
|
21
25
|
@models = {}
|
|
26
|
+
@model_requests = {}
|
|
22
27
|
@route_groups = {}
|
|
23
28
|
@multi_tenant = {
|
|
24
29
|
organization_identifier_column: "id"
|
|
@@ -63,9 +68,58 @@ module Rhino
|
|
|
63
68
|
|
|
64
69
|
# Register a model with its slug
|
|
65
70
|
# Usage: config.model :posts, 'Post'
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
#
|
|
72
|
+
# The optional `store_request:` / `update_request:` keywords override the
|
|
73
|
+
# `{Model}StoreRequest` / `{Model}UpdateRequest` naming convention for that
|
|
74
|
+
# model's POST / PUT action:
|
|
75
|
+
#
|
|
76
|
+
# config.model :tasks, "Task", store_request: "CreateTask", update_request: "EditTask"
|
|
77
|
+
#
|
|
78
|
+
# Class NAMES (strings) are stored, never constants, so a dev-mode Zeitwerk
|
|
79
|
+
# reload never hands back an unloaded class. A registration that cannot be
|
|
80
|
+
# constantized at request time raises Rhino::ConfigurationError rather than
|
|
81
|
+
# silently skipping validation.
|
|
82
|
+
def model(slug, klass_name, store_request: nil, update_request: nil)
|
|
83
|
+
key = slug.to_sym
|
|
84
|
+
@models[key] = klass_name.to_s
|
|
85
|
+
|
|
86
|
+
requests = {
|
|
87
|
+
store: normalize_request_class_name(store_request),
|
|
88
|
+
update: normalize_request_class_name(update_request)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if requests[:store].nil? && requests[:update].nil?
|
|
92
|
+
@model_requests.delete(key)
|
|
93
|
+
else
|
|
94
|
+
@model_requests[key] = requests
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The explicitly registered request class NAME for a model slug + action,
|
|
99
|
+
# or nil when the model relies on the naming convention.
|
|
100
|
+
#
|
|
101
|
+
# @param slug [String, Symbol, nil]
|
|
102
|
+
# @param action [String, Symbol] "store" or "update"
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
def request_class_for(slug, action)
|
|
105
|
+
return nil if slug.nil? || slug.to_s.empty?
|
|
106
|
+
|
|
107
|
+
entry = @model_requests[slug.to_sym]
|
|
108
|
+
return nil unless entry
|
|
109
|
+
|
|
110
|
+
entry[action.to_s == "update" ? :update : :store]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Coerce a request-class registration to a String class name. A Class is
|
|
114
|
+
# accepted for convenience but stored by name (reloader safety); blank
|
|
115
|
+
# values register nothing.
|
|
116
|
+
def normalize_request_class_name(value)
|
|
117
|
+
return nil if value.nil?
|
|
118
|
+
|
|
119
|
+
name = value.is_a?(Class) ? value.name.to_s : value.to_s
|
|
120
|
+
name.strip.empty? ? nil : name.strip
|
|
68
121
|
end
|
|
122
|
+
private :normalize_request_class_name
|
|
69
123
|
|
|
70
124
|
# Register a route group with its configuration
|
|
71
125
|
# Usage: config.route_group :tenant, prefix: ':organization', middleware: [Rhino::Middleware::ResolveOrganizationFromRoute], models: :all
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Raised when Rhino is configured with something it cannot resolve at
|
|
5
|
+
# request time — today only an explicit request-class registration
|
|
6
|
+
# (`config.model :tasks, "Task", store_request: "..."`) whose class name
|
|
7
|
+
# cannot be constantized, or which does not inherit from
|
|
8
|
+
# Rhino::ResourceRequest.
|
|
9
|
+
#
|
|
10
|
+
# This is deliberately NOT rescued into a JSON response: a silently ignored
|
|
11
|
+
# validation class is a security hole, so a misconfigured app must fail
|
|
12
|
+
# loudly (500) on first use rather than quietly skipping validation.
|
|
13
|
+
class ConfigurationError < StandardError; end
|
|
14
|
+
end
|
|
@@ -95,6 +95,34 @@ module Rhino
|
|
|
95
95
|
}, status: :forbidden
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
# Request class (Rhino::ResourceRequest) for this model + action, if any.
|
|
99
|
+
# Resolved AFTER the forbidden-field gate so `prepare` can never launder a
|
|
100
|
+
# field past the policy, and BEFORE the legacy model rules, which are not
|
|
101
|
+
# consulted at all when a request class is present for this action.
|
|
102
|
+
if (request_class = request_class_for("store"))
|
|
103
|
+
status, payload = run_resource_request(request_class, data, "store", nil, model_class)
|
|
104
|
+
return render_request_class_forbidden if status == :forbidden
|
|
105
|
+
return render json: { errors: payload }, status: :unprocessable_entity if status == :invalid
|
|
106
|
+
|
|
107
|
+
add_organization_to_data(payload)
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
created = model_class.create!(payload)
|
|
111
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
112
|
+
# A model-level `validates` rule the request class did not reproduce
|
|
113
|
+
# still runs inside create!. Render it in the same envelope the
|
|
114
|
+
# request class's own failures use instead of letting it escape as a
|
|
115
|
+
# 500. The legacy path cannot reach here — it ran those very rules up
|
|
116
|
+
# front — so this rescue is confined to the request-class branch.
|
|
117
|
+
return render json: { errors: record_validation_errors(e.record) },
|
|
118
|
+
status: :unprocessable_entity
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return render json: serialize_record(created), status: :created
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @deprecated Legacy model-level validation path. Byte-for-byte unchanged;
|
|
125
|
+
# reached only when no request class exists for this model + action.
|
|
98
126
|
model_instance = model_class.new
|
|
99
127
|
validation = model_instance.validate_for_action(
|
|
100
128
|
data, permitted_fields: permitted_fields, organization: current_organization
|
|
@@ -159,6 +187,29 @@ module Rhino
|
|
|
159
187
|
}, status: :forbidden
|
|
160
188
|
end
|
|
161
189
|
|
|
190
|
+
# Request class for this model + action, if any. `record` is the already
|
|
191
|
+
# loaded, ORGANIZATION-SCOPED row (never re-fetched by bare id), so a
|
|
192
|
+
# record-dependent rule can never see another tenant's state.
|
|
193
|
+
if (request_class = request_class_for("update"))
|
|
194
|
+
status, payload = run_resource_request(request_class, data, "update", record, model_class)
|
|
195
|
+
return render_request_class_forbidden if status == :forbidden
|
|
196
|
+
return render json: { errors: payload }, status: :unprocessable_entity if status == :invalid
|
|
197
|
+
|
|
198
|
+
begin
|
|
199
|
+
record.update!(payload)
|
|
200
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
201
|
+
# See the note in `store`: a model rule stricter than the request
|
|
202
|
+
# class must surface as the standard 422, not a 500.
|
|
203
|
+
return render json: { errors: record_validation_errors(e.record) },
|
|
204
|
+
status: :unprocessable_entity
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
record.reload
|
|
208
|
+
return render json: serialize_record(record)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @deprecated Legacy model-level validation path. Byte-for-byte unchanged;
|
|
212
|
+
# reached only when no request class exists for this model + action.
|
|
162
213
|
model_instance = model_class.new
|
|
163
214
|
validation = model_instance.validate_for_action(
|
|
164
215
|
data, permitted_fields: permitted_fields, organization: current_organization
|
|
@@ -329,6 +380,10 @@ module Rhino
|
|
|
329
380
|
|
|
330
381
|
# Execute all operations in a transaction
|
|
331
382
|
results = execute_nested_operations(operations, validated_per_op, auth_results)
|
|
383
|
+
# execute_nested_operations renders (and rolls back) when a model-level
|
|
384
|
+
# rule fails at save time on the request-class path.
|
|
385
|
+
return if performed?
|
|
386
|
+
|
|
332
387
|
render json: { results: results }
|
|
333
388
|
end
|
|
334
389
|
|
|
@@ -968,6 +1023,39 @@ module Rhino
|
|
|
968
1023
|
return nil
|
|
969
1024
|
end
|
|
970
1025
|
|
|
1026
|
+
# Request class for this operation. `action` maps to the request-class
|
|
1027
|
+
# vocabulary: a nested "create" op is the "store" action.
|
|
1028
|
+
request_action = action == "create" ? "store" : "update"
|
|
1029
|
+
if (request_class = request_class_for(request_action, op_model_class, slug))
|
|
1030
|
+
# Non-failing, organization-scoped lookup: `nil` on a miss so the
|
|
1031
|
+
# existing authorize_nested_operation step still produces today's
|
|
1032
|
+
# 403/404 in today's order.
|
|
1033
|
+
op_record = request_action == "update" ? find_nested_operation_record(op_model_class, operation["id"]) : nil
|
|
1034
|
+
|
|
1035
|
+
status, payload = run_resource_request(
|
|
1036
|
+
request_class, operation["data"], request_action, op_record, op_model_class
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
if status == :forbidden
|
|
1040
|
+
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
1041
|
+
return nil
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
if status == :invalid
|
|
1045
|
+
errors = {}
|
|
1046
|
+
payload.each do |key, messages|
|
|
1047
|
+
errors["operations.#{index}.data.#{key}"] = messages
|
|
1048
|
+
end
|
|
1049
|
+
render json: { message: "Validation failed.", errors: errors }, status: :unprocessable_entity
|
|
1050
|
+
return nil
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
return payload
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# @deprecated Legacy model-level validation path for nested operations.
|
|
1057
|
+
# Unchanged; reached only when no request class exists for this
|
|
1058
|
+
# operation's model + action.
|
|
971
1059
|
model_instance = op_model_class.new
|
|
972
1060
|
validation = model_instance.validate_for_action(operation["data"], permitted_fields: permitted_fields)
|
|
973
1061
|
|
|
@@ -995,7 +1083,20 @@ module Rhino
|
|
|
995
1083
|
end
|
|
996
1084
|
nil
|
|
997
1085
|
else
|
|
998
|
-
|
|
1086
|
+
# ORGANIZATION-SCOPED lookup. A bare `op_model_class.find` let org B
|
|
1087
|
+
# update org A's rows through POST /nested: only models including
|
|
1088
|
+
# Rhino::BelongsToOrganization were protected, and then only by that
|
|
1089
|
+
# concern's default_scope, so a plain organization_id column, a
|
|
1090
|
+
# for_organization scope and every indirect belongs_to chain
|
|
1091
|
+
# (task -> project -> org) leaked on the WRITE path while the member
|
|
1092
|
+
# endpoints 404'd. Uses the same lenient mechanism find_record and
|
|
1093
|
+
# index use, so a model with no org mechanism stays reachable and a
|
|
1094
|
+
# request with no org context is unscoped exactly as before. A
|
|
1095
|
+
# cross-org id now raises RecordNotFound, indistinguishable from an id
|
|
1096
|
+
# that does not exist.
|
|
1097
|
+
record = Rhino::ScopesToOrganization.scope_to_organization(
|
|
1098
|
+
op_model_class.all, op_model_class, current_organization
|
|
1099
|
+
).find(operation["id"])
|
|
999
1100
|
unless policy.new(current_user, record).update?
|
|
1000
1101
|
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
1001
1102
|
return nil
|
|
@@ -1006,39 +1107,74 @@ module Rhino
|
|
|
1006
1107
|
|
|
1007
1108
|
def execute_nested_operations(operations, validated_per_op, auth_results)
|
|
1008
1109
|
results = []
|
|
1110
|
+
# [index, record] for an operation whose MODEL rules failed at save time
|
|
1111
|
+
# on the request-class path. Set inside the transaction, rendered after it
|
|
1112
|
+
# has rolled back.
|
|
1113
|
+
save_failure = nil
|
|
1009
1114
|
|
|
1010
1115
|
ActiveRecord::Base.transaction do
|
|
1011
1116
|
operations.each_with_index do |op, index|
|
|
1012
1117
|
validated = validated_per_op[index]
|
|
1013
1118
|
model_or_nil = auth_results[index]
|
|
1014
1119
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1120
|
+
begin
|
|
1121
|
+
if op["action"] == "create"
|
|
1122
|
+
op_model_class = Rhino.config.resolve_model(op["model"])
|
|
1123
|
+
data = validated.dup
|
|
1124
|
+
add_organization_to_data(data)
|
|
1125
|
+
record = op_model_class.create!(data)
|
|
1126
|
+
results << {
|
|
1127
|
+
model: op["model"],
|
|
1128
|
+
action: "create",
|
|
1129
|
+
id: record.id,
|
|
1130
|
+
data: serialize_record(record)
|
|
1131
|
+
}
|
|
1132
|
+
else
|
|
1133
|
+
model_or_nil.update!(validated)
|
|
1134
|
+
model_or_nil.reload
|
|
1135
|
+
results << {
|
|
1136
|
+
model: op["model"],
|
|
1137
|
+
action: "update",
|
|
1138
|
+
id: model_or_nil.id,
|
|
1139
|
+
data: serialize_record(model_or_nil)
|
|
1140
|
+
}
|
|
1141
|
+
end
|
|
1142
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1143
|
+
# Only the request-class path converts a save-time model-rule
|
|
1144
|
+
# failure into a 422. The legacy path ran those same rules in
|
|
1145
|
+
# validate_for_action before getting here, so it keeps raising
|
|
1146
|
+
# exactly as it does today.
|
|
1147
|
+
raise unless nested_operation_uses_request_class?(op)
|
|
1148
|
+
|
|
1149
|
+
save_failure = [index, e.record]
|
|
1150
|
+
raise ActiveRecord::Rollback
|
|
1035
1151
|
end
|
|
1036
1152
|
end
|
|
1037
1153
|
end
|
|
1038
1154
|
|
|
1155
|
+
if save_failure
|
|
1156
|
+
index, invalid_record = save_failure
|
|
1157
|
+
errors = {}
|
|
1158
|
+
record_validation_errors(invalid_record).each do |key, messages|
|
|
1159
|
+
errors["operations.#{index}.data.#{key}"] = messages
|
|
1160
|
+
end
|
|
1161
|
+
render json: { message: "Validation failed.", errors: errors }, status: :unprocessable_entity
|
|
1162
|
+
return nil
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1039
1165
|
results
|
|
1040
1166
|
end
|
|
1041
1167
|
|
|
1168
|
+
# Whether a nested operation was validated by a request class. Re-resolved
|
|
1169
|
+
# (never memoized) rather than threaded through, and only ever called from
|
|
1170
|
+
# the RecordInvalid rescue, so the happy path pays nothing.
|
|
1171
|
+
def nested_operation_uses_request_class?(operation)
|
|
1172
|
+
op_model_class = Rhino.config.resolve_model(operation["model"])
|
|
1173
|
+
request_action = operation["action"] == "create" ? "store" : "update"
|
|
1174
|
+
|
|
1175
|
+
!request_class_for(request_action, op_model_class, operation["model"]).nil?
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1042
1178
|
# ------------------------------------------------------------------
|
|
1043
1179
|
# Permitted fields resolution
|
|
1044
1180
|
# ------------------------------------------------------------------
|
|
@@ -1059,6 +1195,140 @@ module Rhino
|
|
|
1059
1195
|
end
|
|
1060
1196
|
end
|
|
1061
1197
|
|
|
1198
|
+
# ------------------------------------------------------------------
|
|
1199
|
+
# Request classes (Rhino::ResourceRequest)
|
|
1200
|
+
# ------------------------------------------------------------------
|
|
1201
|
+
|
|
1202
|
+
# Resolve the request class for a model + action, or nil.
|
|
1203
|
+
#
|
|
1204
|
+
# Precedence: explicit registration (config.model ..., store_request:) →
|
|
1205
|
+
# "{ModelBasename}StoreRequest" / "{ModelBasename}UpdateRequest" → none.
|
|
1206
|
+
#
|
|
1207
|
+
# Resolution happens on EVERY request and is never memoized: caching the
|
|
1208
|
+
# constant would hand back a stale, unloaded class after a dev-mode Zeitwerk
|
|
1209
|
+
# reload.
|
|
1210
|
+
#
|
|
1211
|
+
# An EXPLICIT registration that cannot be resolved — missing constant, or a
|
|
1212
|
+
# constant that is not a Rhino::ResourceRequest — raises. A silently ignored
|
|
1213
|
+
# validation class the developer asked for by name is a security hole.
|
|
1214
|
+
#
|
|
1215
|
+
# A CONVENTION hit that is not a Rhino::ResourceRequest is LOGGED and
|
|
1216
|
+
# ignored, falling through to the legacy path exactly as if no class
|
|
1217
|
+
# existed. The convention is a guess: an app upgrading from 4.9.0 that
|
|
1218
|
+
# happens to own an unrelated top-level `PostStoreRequest` must keep
|
|
1219
|
+
# working, not start returning 500s. A convention miss falls through
|
|
1220
|
+
# silently; that is what "convention" means.
|
|
1221
|
+
#
|
|
1222
|
+
# @param action [String] "store" or "update"
|
|
1223
|
+
# @return [Class, nil]
|
|
1224
|
+
def request_class_for(action, klass = model_class, slug = model_slug)
|
|
1225
|
+
explicit = Rhino.config.request_class_for(slug, action)
|
|
1226
|
+
|
|
1227
|
+
if explicit.present?
|
|
1228
|
+
const = explicit.safe_constantize
|
|
1229
|
+
raise_request_class_configuration_error(explicit, slug, action) unless const
|
|
1230
|
+
unless resource_request_class?(const)
|
|
1231
|
+
raise_request_class_configuration_error(explicit, slug, action)
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
return const
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
return nil unless klass.respond_to?(:name) && klass.name.present?
|
|
1238
|
+
|
|
1239
|
+
suffix = action.to_s == "update" ? "UpdateRequest" : "StoreRequest"
|
|
1240
|
+
name = "#{klass.name.demodulize}#{suffix}"
|
|
1241
|
+
const = name.safe_constantize
|
|
1242
|
+
return nil unless const
|
|
1243
|
+
return const if resource_request_class?(const)
|
|
1244
|
+
|
|
1245
|
+
warn_ignored_request_class(name, slug, action)
|
|
1246
|
+
nil
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def resource_request_class?(const)
|
|
1250
|
+
const.is_a?(Class) && const < Rhino::ResourceRequest
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def warn_ignored_request_class(name, slug, action)
|
|
1254
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger)
|
|
1255
|
+
|
|
1256
|
+
Rails.logger&.warn(
|
|
1257
|
+
"Rhino: ignoring #{name} for [#{slug}.#{action}]: " \
|
|
1258
|
+
"it does not inherit from Rhino::ResourceRequest"
|
|
1259
|
+
)
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
def raise_request_class_configuration_error(label, slug, action)
|
|
1263
|
+
raise Rhino::ConfigurationError,
|
|
1264
|
+
"Rhino: request class [#{label}] configured for [#{slug}.#{action}] does not exist."
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Instantiate and run a resolved request class.
|
|
1268
|
+
#
|
|
1269
|
+
# @return [Array] [:ok, validated_hash] | [:forbidden, nil] | [:invalid, errors_hash]
|
|
1270
|
+
def run_resource_request(request_class, data, action, record, fk_model_class)
|
|
1271
|
+
request = request_class.new(
|
|
1272
|
+
input: data,
|
|
1273
|
+
user: current_user,
|
|
1274
|
+
organization: current_organization,
|
|
1275
|
+
route_group: current_route_group,
|
|
1276
|
+
action: action,
|
|
1277
|
+
record: record
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
return [:forbidden, nil] unless request.authorize?
|
|
1281
|
+
|
|
1282
|
+
result = request.run
|
|
1283
|
+
errors = result[:errors] || {}
|
|
1284
|
+
|
|
1285
|
+
# Cross-tenant FK validation runs ON TOP of the request class's own rules,
|
|
1286
|
+
# through the same (direct + indirect FK chain) implementation the legacy
|
|
1287
|
+
# path uses. Errors merge into the same 422 body.
|
|
1288
|
+
if current_organization
|
|
1289
|
+
fk_instance = fk_model_class.new
|
|
1290
|
+
if fk_instance.respond_to?(:rhino_validate_foreign_keys)
|
|
1291
|
+
errors = errors.merge(
|
|
1292
|
+
fk_instance.rhino_validate_foreign_keys(result[:validated], current_organization)
|
|
1293
|
+
)
|
|
1294
|
+
end
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
return [:invalid, errors] if errors.any?
|
|
1298
|
+
|
|
1299
|
+
[:ok, result[:validated]]
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1302
|
+
# Model-level validations still run inside create!/update!. On the
|
|
1303
|
+
# request-class path a rule the request class did not reproduce would
|
|
1304
|
+
# otherwise escape as ActiveRecord::RecordInvalid (a 500), so it is rendered
|
|
1305
|
+
# in the same shape the request class's own failures use: String keys,
|
|
1306
|
+
# arrays of messages.
|
|
1307
|
+
def record_validation_errors(record)
|
|
1308
|
+
return {} unless record.respond_to?(:errors)
|
|
1309
|
+
|
|
1310
|
+
record.errors.to_hash.each_with_object({}) do |(attribute, messages), memo|
|
|
1311
|
+
memo[attribute.to_s] = Array(messages)
|
|
1312
|
+
end
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
# Byte-identical to a policy denial on purpose: a client must not be able to
|
|
1316
|
+
# tell an `authorize?` refusal from a policy refusal.
|
|
1317
|
+
def render_request_class_forbidden
|
|
1318
|
+
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
# Organization-scoped, non-failing primary-key lookup used to populate
|
|
1322
|
+
# `record` for a nested update operation.
|
|
1323
|
+
def find_nested_operation_record(op_model_class, id)
|
|
1324
|
+
return nil if id.nil?
|
|
1325
|
+
|
|
1326
|
+
scope = Rhino::ScopesToOrganization.scope_to_organization(
|
|
1327
|
+
op_model_class.all, op_model_class, current_organization
|
|
1328
|
+
)
|
|
1329
|
+
scope.find_by(op_model_class.primary_key => id)
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1062
1332
|
def find_forbidden_fields(params_data, permitted_fields)
|
|
1063
1333
|
return [] if permitted_fields == ["*"]
|
|
1064
1334
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "active_support/core_ext/object/deep_dup"
|
|
5
|
+
|
|
6
|
+
module Rhino
|
|
7
|
+
# Base class for per-model, per-action request classes.
|
|
8
|
+
#
|
|
9
|
+
# A request class owns the entire shape/format contract for ONE action of ONE
|
|
10
|
+
# model. Unlike model-level validations it receives the full request context —
|
|
11
|
+
# the authenticated user, the resolved organization, the matched route group,
|
|
12
|
+
# the action, and (on update) the pre-update record — so a rule can branch on
|
|
13
|
+
# any of them.
|
|
14
|
+
#
|
|
15
|
+
# Discovery is by convention: `{Model}StoreRequest` / `{Model}UpdateRequest`,
|
|
16
|
+
# autoloaded from `app/requests/` (Zeitwerk autoloads every `app/*`
|
|
17
|
+
# directory, so no initializer or eager_load_paths entry is required). An
|
|
18
|
+
# explicit registration overrides the convention:
|
|
19
|
+
#
|
|
20
|
+
# Rhino.configure do |config|
|
|
21
|
+
# config.model :tasks, "Task", store_request: "CreateTask", update_request: "EditTask"
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
#
|
|
26
|
+
# # app/requests/task_store_request.rb
|
|
27
|
+
# class TaskStoreRequest < Rhino::ResourceRequest
|
|
28
|
+
# attribute :title, :string
|
|
29
|
+
# attribute :status, :string
|
|
30
|
+
# attribute :project_id, :integer
|
|
31
|
+
#
|
|
32
|
+
# validates :title, presence: true, length: { maximum: 255 }
|
|
33
|
+
# validates :status, inclusion: { in: %w[todo doing] }, unless: -> { user&.admin? }
|
|
34
|
+
#
|
|
35
|
+
# def authorize?
|
|
36
|
+
# route_group != "public"
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# def prepare(input)
|
|
40
|
+
# input.merge("title" => input["title"].to_s.strip)
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# Rules are ordinary ActiveModel declarations. There is no `rules` method and
|
|
45
|
+
# no `messages` method: dynamic rules use `validate :method_name` or
|
|
46
|
+
# `validates ..., if: -> { ... }` with the context readers in scope, and
|
|
47
|
+
# messages use the standard `message:` option / i18n.
|
|
48
|
+
#
|
|
49
|
+
# THE WRITE PAYLOAD IS `validated`. Only DECLARED attributes that are PRESENT
|
|
50
|
+
# in the prepared input are persisted, with their cast values. A field with no
|
|
51
|
+
# `attribute` declaration is silently dropped — including a field added by
|
|
52
|
+
# `prepare` that no `attribute` covers. This fails closed: when a policy
|
|
53
|
+
# permits `['*']`, the request class is the only field filter left.
|
|
54
|
+
class ResourceRequest
|
|
55
|
+
include ActiveModel::Model
|
|
56
|
+
include ActiveModel::Attributes
|
|
57
|
+
include ActiveModel::Validations
|
|
58
|
+
|
|
59
|
+
# The request context. All six values are available to `authorize?`,
|
|
60
|
+
# `prepare` and every validation.
|
|
61
|
+
#
|
|
62
|
+
# @return [Object, nil] the authenticated user, nil when unauthenticated
|
|
63
|
+
attr_reader :user
|
|
64
|
+
# @return [Object, nil] the resolved organization, nil outside a tenant context
|
|
65
|
+
attr_reader :organization
|
|
66
|
+
# @return [String, nil] the matched route's route group ("tenant", "public", ...)
|
|
67
|
+
attr_reader :route_group
|
|
68
|
+
# @return [String] "store" or "update"
|
|
69
|
+
attr_reader :action
|
|
70
|
+
# @return [Object, nil] the PRE-UPDATE record on update, nil on store
|
|
71
|
+
attr_reader :record
|
|
72
|
+
# @return [Hash] the prepared input, string-keyed
|
|
73
|
+
attr_reader :input
|
|
74
|
+
|
|
75
|
+
# @param input [Hash] the raw request data, AFTER the policy forbidden-field
|
|
76
|
+
# gate has run (so `prepare` can never launder a field past the policy)
|
|
77
|
+
# @param user [Object, nil]
|
|
78
|
+
# @param organization [Object, nil]
|
|
79
|
+
# @param route_group [String, nil]
|
|
80
|
+
# @param action [String] "store" or "update"
|
|
81
|
+
# @param record [Object, nil]
|
|
82
|
+
def initialize(input:, user: nil, organization: nil, route_group: nil, action: "store", record: nil)
|
|
83
|
+
@user = user
|
|
84
|
+
@organization = organization
|
|
85
|
+
@route_group = route_group.nil? ? nil : route_group.to_s
|
|
86
|
+
@action = action.to_s
|
|
87
|
+
@record = record
|
|
88
|
+
|
|
89
|
+
# ActiveModel::Attributes defaults must exist before anything reads or
|
|
90
|
+
# writes an attribute — including a `prepare` override that touches one.
|
|
91
|
+
super()
|
|
92
|
+
|
|
93
|
+
raw = self.class.normalize_input_keys(input)
|
|
94
|
+
prepared = prepare(raw.deep_dup)
|
|
95
|
+
# A `prepare` that returns a non-Hash (or nil) is treated as "no change".
|
|
96
|
+
@input = prepared.is_a?(Hash) ? self.class.normalize_input_keys(prepared) : raw
|
|
97
|
+
|
|
98
|
+
assign_declared_attributes
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Override point: return false to refuse the request with a 403 whose body
|
|
102
|
+
# is byte-identical to a policy denial, so `authorize?` cannot be used to
|
|
103
|
+
# enumerate anything about the model.
|
|
104
|
+
#
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def authorize?
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Override point: normalize the input before validation. Runs BEFORE
|
|
111
|
+
# `authorize?`, so `authorize?` sees normalized input.
|
|
112
|
+
#
|
|
113
|
+
# Fields added here are SERVER-AUTHORED and are not re-checked against the
|
|
114
|
+
# policy's permitted attributes — the forbidden-field gate already ran on
|
|
115
|
+
# exactly what the client sent. Never copy a client value into a different
|
|
116
|
+
# key here; that writes a field the policy denied.
|
|
117
|
+
#
|
|
118
|
+
# A return value that is not a Hash is ignored.
|
|
119
|
+
#
|
|
120
|
+
# @param input [Hash] string-keyed copy of the raw input
|
|
121
|
+
# @return [Hash]
|
|
122
|
+
def prepare(input)
|
|
123
|
+
input
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Run the validations.
|
|
127
|
+
#
|
|
128
|
+
# @return [Hash] { valid: Boolean, errors: Hash<String, Array<String>>, validated: Hash }
|
|
129
|
+
def run
|
|
130
|
+
ok = valid?
|
|
131
|
+
|
|
132
|
+
{ valid: ok, errors: error_messages, validated: validated }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The write payload: declared attribute names that are present in the
|
|
136
|
+
# prepared input, mapped to their CAST values.
|
|
137
|
+
#
|
|
138
|
+
# @return [Hash<String, Object>]
|
|
139
|
+
def validated
|
|
140
|
+
attributes.select { |name, _| @input.key?(name) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Errors in the shape Rhino renders at 422:
|
|
144
|
+
# { "title" => ["can't be blank"], ... }
|
|
145
|
+
#
|
|
146
|
+
# Built exactly the way HasValidation#validate_for_action builds it, but
|
|
147
|
+
# WITHOUT its "only report errors on fields the client sent" guard — on a
|
|
148
|
+
# request class an error on an absent-but-required field is the point.
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash<String, Array<String>>]
|
|
151
|
+
def error_messages
|
|
152
|
+
messages = {}
|
|
153
|
+
errors.each do |error|
|
|
154
|
+
field_name = error.attribute.to_s
|
|
155
|
+
messages[field_name] ||= []
|
|
156
|
+
messages[field_name] << error.message
|
|
157
|
+
end
|
|
158
|
+
messages
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Stringify top-level keys so `input["title"]` works regardless of whether
|
|
162
|
+
# the caller handed us a Hash, a HashWithIndifferentAccess or symbol keys.
|
|
163
|
+
#
|
|
164
|
+
# @api private
|
|
165
|
+
def self.normalize_input_keys(hash)
|
|
166
|
+
return {} unless hash.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
hash.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# Assign ONLY declared attributes, and only those actually present in the
|
|
174
|
+
# prepared input, so an absent attribute keeps its declared default rather
|
|
175
|
+
# than being overwritten with nil.
|
|
176
|
+
def assign_declared_attributes
|
|
177
|
+
self.class.attribute_names.each do |name|
|
|
178
|
+
next unless @input.key?(name)
|
|
179
|
+
|
|
180
|
+
public_send("#{name}=", @input[name])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Validation for <%= action == "update" ? "PUT /{resource}/:id" : "POST /{resource}" %> on <%= name %>.
|
|
4
|
+
#
|
|
5
|
+
# Found by convention — Rhino looks for <%= class_name %> whenever
|
|
6
|
+
# <%= name %> handles its <%= action %> action, and falls back to the model's own
|
|
7
|
+
# validations when no request class exists. Override the convention with:
|
|
8
|
+
#
|
|
9
|
+
# config.model :<%= name.underscore.pluralize %>, "<%= name %>", <%= action %>_request: "My<%= action == "update" ? "Update" : "Store" %>Class"
|
|
10
|
+
#
|
|
11
|
+
# Context available to every method below:
|
|
12
|
+
# user — the authenticated user (or nil)
|
|
13
|
+
# organization — the resolved organization (or nil outside a tenant context)
|
|
14
|
+
# route_group — the matched route's group ("tenant", "public", ...)
|
|
15
|
+
# action — "<%= action %>"
|
|
16
|
+
# record — <%= action == "update" ? "the PRE-UPDATE record" : "nil (there is no record yet on store)" %>
|
|
17
|
+
# input — the prepared, string-keyed request data
|
|
18
|
+
class <%= class_name %> < Rhino::ResourceRequest
|
|
19
|
+
# Declare an attribute for EVERY field this action may write.
|
|
20
|
+
#
|
|
21
|
+
# The declared attributes present in the input ARE the write payload: a field
|
|
22
|
+
# with no `attribute` here is silently dropped, never persisted — even if the
|
|
23
|
+
# policy permits it, and even if `prepare` added it.
|
|
24
|
+
#
|
|
25
|
+
# attribute :title, :string
|
|
26
|
+
# attribute :status, :string
|
|
27
|
+
# attribute :project_id, :integer
|
|
28
|
+
# attribute :due_date, :date
|
|
29
|
+
|
|
30
|
+
# Ordinary ActiveModel validations. Unlike the model-level path, these are NOT
|
|
31
|
+
# relaxed for partial updates — declare what is optional yourself.
|
|
32
|
+
#
|
|
33
|
+
# validates :title, presence: true, length: { maximum: 255 }
|
|
34
|
+
# validates :status, inclusion: { in: %w[todo doing done] }, allow_nil: true
|
|
35
|
+
|
|
36
|
+
# Rules that depend on WHO is asking: branch on `user` instead of keeping a
|
|
37
|
+
# role-keyed map on the model. Remember that the policy already decided which
|
|
38
|
+
# fields the role may write — this decides what a valid value looks like.
|
|
39
|
+
#
|
|
40
|
+
# validates :status,
|
|
41
|
+
# inclusion: { in: %w[todo doing] },
|
|
42
|
+
# if: -> { user.nil? || user.role_slug_for_validation(organization) != "admin" }
|
|
43
|
+
|
|
44
|
+
# Rules that depend on WHERE the request came from.
|
|
45
|
+
#
|
|
46
|
+
# validates :assignee_id, presence: true, if: -> { route_group == "tenant" }
|
|
47
|
+
|
|
48
|
+
<% if action == "update" -%>
|
|
49
|
+
# Rules that depend on the record's CURRENT state. `record` is the
|
|
50
|
+
# organization-scoped row as it exists BEFORE this update is applied.
|
|
51
|
+
#
|
|
52
|
+
# validate :status_may_not_move_backwards
|
|
53
|
+
|
|
54
|
+
<% end -%>
|
|
55
|
+
# Refuse the request outright. Returning false renders 403 with exactly the
|
|
56
|
+
# body a policy denial renders, so it leaks nothing about this model.
|
|
57
|
+
#
|
|
58
|
+
# def authorize?
|
|
59
|
+
# route_group != "public"
|
|
60
|
+
# end
|
|
61
|
+
|
|
62
|
+
# Normalize the input before validation. Runs BEFORE authorize?, and AFTER the
|
|
63
|
+
# policy's forbidden-field check — so anything added here is server-authored
|
|
64
|
+
# and trusted. Never copy a client value into a different key here; that would
|
|
65
|
+
# write a field the policy denied. A field added here still needs an
|
|
66
|
+
# `attribute` above to be persisted. A non-Hash return value is ignored.
|
|
67
|
+
#
|
|
68
|
+
# Because it runs before the validations, `prepare` sees RAW client input: a
|
|
69
|
+
# client can send {"title": ["x"]} or {"title": 5}. Guard every string
|
|
70
|
+
# operation with is_a?(String) and leave anything else untouched, so a
|
|
71
|
+
# validation rejects it with a 422 instead of prepare blowing up (or silently
|
|
72
|
+
# stringifying it into something that passes).
|
|
73
|
+
#
|
|
74
|
+
# def prepare(input)
|
|
75
|
+
# title = input["title"]
|
|
76
|
+
# input.merge("title" => title.is_a?(String) ? title.strip : title)
|
|
77
|
+
# end
|
|
78
|
+
|
|
79
|
+
# An `attribute :title, :string` casts ANY value to a String, so ["x"] becomes
|
|
80
|
+
# '["x"]' and would sail past presence/length. When the shape matters, check
|
|
81
|
+
# the RAW input rather than the cast value.
|
|
82
|
+
#
|
|
83
|
+
# validate :title_must_be_text
|
|
84
|
+
<% if action != "update" -%>
|
|
85
|
+
|
|
86
|
+
# private
|
|
87
|
+
|
|
88
|
+
# def title_must_be_text
|
|
89
|
+
# return if input["title"].nil? || input["title"].is_a?(String)
|
|
90
|
+
#
|
|
91
|
+
# errors.add(:title, "must be a string")
|
|
92
|
+
# end
|
|
93
|
+
<% end -%>
|
|
94
|
+
<% if action == "update" -%>
|
|
95
|
+
|
|
96
|
+
# private
|
|
97
|
+
|
|
98
|
+
# def title_must_be_text
|
|
99
|
+
# return if input["title"].nil? || input["title"].is_a?(String)
|
|
100
|
+
#
|
|
101
|
+
# errors.add(:title, "must be a string")
|
|
102
|
+
# end
|
|
103
|
+
|
|
104
|
+
# def status_may_not_move_backwards
|
|
105
|
+
# return if record.nil? || status.nil?
|
|
106
|
+
# return unless record.status == "done" && status != "done"
|
|
107
|
+
#
|
|
108
|
+
# errors.add(:status, "cannot be reopened once the task is done")
|
|
109
|
+
# end
|
|
110
|
+
<% end -%>
|
|
111
|
+
end
|
data/lib/rhino/version.rb
CHANGED
data/lib/rhino.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "rhino/auth_rejected"
|
|
|
6
6
|
require "rhino/scope_not_allowed_error"
|
|
7
7
|
require "rhino/invalid_scope_arguments_error"
|
|
8
8
|
require "rhino/invalid_computed_attribute_arguments_error"
|
|
9
|
+
require "rhino/configuration_error"
|
|
9
10
|
require "rhino/argument_binder"
|
|
10
11
|
require "rhino/scope_spec"
|
|
11
12
|
require "rhino/computed_attribute_spec"
|
|
@@ -14,6 +15,7 @@ require "rhino/auth_hooks"
|
|
|
14
15
|
require "rhino/group_membership"
|
|
15
16
|
require "rhino/resource_scope"
|
|
16
17
|
require "rhino/scopes_to_organization"
|
|
18
|
+
require "rhino/resource_request"
|
|
17
19
|
require "rhino/context"
|
|
18
20
|
require "rhino/query"
|
|
19
21
|
require "rhino/routing/domain_constraint"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rhino-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Cipolla
|
|
@@ -219,6 +219,7 @@ files:
|
|
|
219
219
|
- lib/rhino/concerns/hidable_columns.rb
|
|
220
220
|
- lib/rhino/concerns/route_group_context.rb
|
|
221
221
|
- lib/rhino/configuration.rb
|
|
222
|
+
- lib/rhino/configuration_error.rb
|
|
222
223
|
- lib/rhino/context.rb
|
|
223
224
|
- lib/rhino/controllers/auth_controller.rb
|
|
224
225
|
- lib/rhino/controllers/invitations_controller.rb
|
|
@@ -239,6 +240,7 @@ files:
|
|
|
239
240
|
- lib/rhino/query.rb
|
|
240
241
|
- lib/rhino/query_builder.rb
|
|
241
242
|
- lib/rhino/railtie.rb
|
|
243
|
+
- lib/rhino/resource_request.rb
|
|
242
244
|
- lib/rhino/resource_scope.rb
|
|
243
245
|
- lib/rhino/routes.rb
|
|
244
246
|
- lib/rhino/routing/domain_constraint.rb
|
|
@@ -252,6 +254,7 @@ files:
|
|
|
252
254
|
- lib/rhino/templates/generate/migration.rb.erb
|
|
253
255
|
- lib/rhino/templates/generate/model.rb.erb
|
|
254
256
|
- lib/rhino/templates/generate/policy.rb.erb
|
|
257
|
+
- lib/rhino/templates/generate/request.rb.erb
|
|
255
258
|
- lib/rhino/templates/generate/scope.rb.erb
|
|
256
259
|
- lib/rhino/templates/multi_tenant/factories/organizations.rb.erb
|
|
257
260
|
- lib/rhino/templates/multi_tenant/factories/roles.rb.erb
|