rest-easy 1.0.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/lib/rest_easy/attribute.rb +71 -0
- data/lib/rest_easy/auth/basic.rb +23 -0
- data/lib/rest_easy/auth/null.rb +17 -0
- data/lib/rest_easy/auth/psk.rb +23 -0
- data/lib/rest_easy/auth.rb +6 -0
- data/lib/rest_easy/conventions.rb +70 -0
- data/lib/rest_easy/meta.rb +32 -0
- data/lib/rest_easy/refinements.rb +17 -0
- data/lib/rest_easy/resource.rb +747 -0
- data/lib/rest_easy/settings.rb +14 -0
- data/lib/rest_easy/version.rb +5 -0
- data/lib/rest_easy.rb +198 -0
- metadata +168 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RestEasy
|
|
6
|
+
class Resource
|
|
7
|
+
extend Dry::Configurable
|
|
8
|
+
|
|
9
|
+
setting :path
|
|
10
|
+
setting :debug, default: false
|
|
11
|
+
|
|
12
|
+
# ── Types ─────────────────────────────────────────────────────────────
|
|
13
|
+
# Include Types so the full Dry::Types vocabulary (Strict::String,
|
|
14
|
+
# Coercible::Integer, Params::Date, etc.) is available without prefix.
|
|
15
|
+
include Types
|
|
16
|
+
|
|
17
|
+
# Shadow Ruby's built-in type names so that inside a regular class body
|
|
18
|
+
# (not Class.new blocks), `String`, `Integer`, etc. resolve to
|
|
19
|
+
# Dry::Types equivalents with coercion and constraint support.
|
|
20
|
+
String = Types::Coercible::String
|
|
21
|
+
Integer = Types::Coercible::Integer
|
|
22
|
+
Float = Types::Coercible::Float
|
|
23
|
+
Boolean = Types::Params::Bool
|
|
24
|
+
Date = Types::Params::Date
|
|
25
|
+
|
|
26
|
+
# Map Ruby's built-in classes to Dry::Types equivalents.
|
|
27
|
+
# Used by `attr` to resolve types passed from Class.new blocks
|
|
28
|
+
# where constant lookup doesn't find our shadowed constants.
|
|
29
|
+
TYPE_MAP = {
|
|
30
|
+
::String => Types::Coercible::String,
|
|
31
|
+
::Integer => Types::Coercible::Integer,
|
|
32
|
+
::Float => Types::Coercible::Float
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# ── Configure DSL proxy ─────────────────────────────────────────────
|
|
36
|
+
# Evaluates a block in a context where bare method calls map to config
|
|
37
|
+
# setters: `adapter :grpc` → config.adapter = :grpc
|
|
38
|
+
# No-arg calls read, so nested access works naturally:
|
|
39
|
+
# `database.dsn = "sqlite:memory"` → config.database is returned,
|
|
40
|
+
# then .dsn= is called on the nested config directly.
|
|
41
|
+
|
|
42
|
+
class ConfigureDSL < BasicObject
|
|
43
|
+
def initialize(config)
|
|
44
|
+
@config = config
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def method_missing(name, *args)
|
|
48
|
+
if args.empty?
|
|
49
|
+
@config.__send__(name)
|
|
50
|
+
else
|
|
51
|
+
@config.__send__(:"#{name}=", args.length == 1 ? args.first : args)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ── DSL helper for attribute parse/serialise blocks ─────────────────
|
|
57
|
+
|
|
58
|
+
class AttributeBlockDSL
|
|
59
|
+
attr_reader :parse_block, :serialise_block
|
|
60
|
+
|
|
61
|
+
def parse(&block)
|
|
62
|
+
@parse_block = block
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def serialise(&block)
|
|
66
|
+
@serialise_block = block
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ── Simple wrappers for instance state ──────────────────────────────
|
|
71
|
+
|
|
72
|
+
class ModelProxy
|
|
73
|
+
def initialize(attributes)
|
|
74
|
+
@attributes = attributes
|
|
75
|
+
attributes.each_key do |attr_name|
|
|
76
|
+
define_singleton_method(attr_name) { @attributes[attr_name] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def attributes
|
|
81
|
+
@attributes
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
85
|
+
@attributes.key?(method_name.to_sym) || super
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class ShadowCopy
|
|
90
|
+
def initialize(data)
|
|
91
|
+
@data = data
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def attributes
|
|
95
|
+
@data
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class MetaCollector
|
|
100
|
+
def initialize
|
|
101
|
+
@data = {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def to_h
|
|
105
|
+
@data
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def method_missing(name, *args)
|
|
109
|
+
key = name.to_s
|
|
110
|
+
if key.end_with?("=")
|
|
111
|
+
@data[key.chomp("=").to_sym] = args.first
|
|
112
|
+
else
|
|
113
|
+
@data[name.to_sym]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ── Class-level DSL ─────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
class << self
|
|
125
|
+
# -- settings -------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def settings(&block)
|
|
128
|
+
class_eval(&block)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def configure(&block)
|
|
132
|
+
dsl = ConfigureDSL.new(config)
|
|
133
|
+
dsl.instance_eval(&block)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# -- metadata ------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def metadata(**kwargs)
|
|
139
|
+
if kwargs.any?
|
|
140
|
+
own_metadata_defaults.merge!(kwargs)
|
|
141
|
+
else
|
|
142
|
+
all_metadata_defaults
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# -- attribute_convention ------------------------------------------
|
|
147
|
+
|
|
148
|
+
def attribute_convention(value = nil)
|
|
149
|
+
if value
|
|
150
|
+
@attribute_convention = Conventions.resolve(value)
|
|
151
|
+
else
|
|
152
|
+
@attribute_convention ||
|
|
153
|
+
(superclass.respond_to?(:attribute_convention) ? superclass.attribute_convention : nil) ||
|
|
154
|
+
Conventions.resolve(parent&.config&.attribute_convention || :PascalCase)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def parent
|
|
161
|
+
@parent ||= __get_parent
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def __get_parent
|
|
165
|
+
class_name = name
|
|
166
|
+
return nil unless class_name
|
|
167
|
+
|
|
168
|
+
parts = class_name.split("::")
|
|
169
|
+
return nil if parts.length < 2
|
|
170
|
+
|
|
171
|
+
# Walk up the namespace chain to find the namespace module that is extended with RestEasy.
|
|
172
|
+
# For MyAPI::V2::Invoice, try MyAPI::V2 first, then MyAPI.
|
|
173
|
+
(parts.length - 1).downto(1) do |i|
|
|
174
|
+
candidate = Object.const_get(parts[0...i].join("::"))
|
|
175
|
+
return candidate if candidate.const_defined?(:ExtendedByRestEasy, false)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
nil
|
|
179
|
+
rescue NameError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
public
|
|
184
|
+
|
|
185
|
+
# -- attr ----------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def attr(name_or_mapping, *args, &block)
|
|
188
|
+
# Determine attribute_api_name and attribute_model_name
|
|
189
|
+
if name_or_mapping.is_a?(::Array)
|
|
190
|
+
attribute_model_name = name_or_mapping[0].to_sym
|
|
191
|
+
attribute_api_name = name_or_mapping[1].to_s
|
|
192
|
+
else
|
|
193
|
+
attribute_model_name = name_or_mapping.to_sym
|
|
194
|
+
attribute_api_name = attribute_convention.serialise(attribute_model_name)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Extract type (non-Symbol), flags (Symbols), and optional mapper object
|
|
198
|
+
type = nil
|
|
199
|
+
flags = []
|
|
200
|
+
mapper = nil
|
|
201
|
+
args.each do |arg|
|
|
202
|
+
if arg.is_a?(::Symbol)
|
|
203
|
+
flags << arg
|
|
204
|
+
elsif arg.respond_to?(:parse) && arg.respond_to?(:serialise)
|
|
205
|
+
mapper = arg
|
|
206
|
+
else
|
|
207
|
+
type = resolve_type(arg)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
raise AttributeError, "Attribute :#{attribute_model_name} must have a type" if type.nil?
|
|
212
|
+
|
|
213
|
+
# Handle mapper object or block DSL for custom parse/serialise
|
|
214
|
+
parse_block = nil
|
|
215
|
+
serialise_block = nil
|
|
216
|
+
source_fields = []
|
|
217
|
+
target_fields = []
|
|
218
|
+
if mapper
|
|
219
|
+
parse_block = mapper.method(:parse)
|
|
220
|
+
serialise_block = mapper.method(:serialise)
|
|
221
|
+
|
|
222
|
+
# Introspect mapper method parameters the same way we do blocks.
|
|
223
|
+
# This enables merge/split patterns with mapper objects.
|
|
224
|
+
parse_params = parse_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
|
|
225
|
+
if parse_params.length > 1
|
|
226
|
+
flags << :synthetic unless flags.include?(:synthetic)
|
|
227
|
+
source_fields = parse_params.map { |_, pname| pname }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
serialise_params = serialise_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
|
|
231
|
+
if serialise_params.length > 1
|
|
232
|
+
target_fields = serialise_params.map { |_, pname| pname }
|
|
233
|
+
end
|
|
234
|
+
elsif block
|
|
235
|
+
block_params = block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
|
|
236
|
+
|
|
237
|
+
if block_params.any?
|
|
238
|
+
# Bare block with params = implicit parse block.
|
|
239
|
+
# The parameter names are API field references (resolved via convention).
|
|
240
|
+
parse_block = block
|
|
241
|
+
source_fields = block_params.map { |_, pname| pname }
|
|
242
|
+
flags << :synthetic unless flags.include?(:synthetic)
|
|
243
|
+
else
|
|
244
|
+
# DSL block — evaluate to extract parse/serialise sub-blocks
|
|
245
|
+
dsl = AttributeBlockDSL.new
|
|
246
|
+
dsl.instance_eval(&block)
|
|
247
|
+
parse_block = dsl.parse_block
|
|
248
|
+
serialise_block = dsl.serialise_block
|
|
249
|
+
|
|
250
|
+
# Introspect parse block parameters: if 2+ params, this is a
|
|
251
|
+
# synthetic attribute. The parameter names are the source API fields
|
|
252
|
+
# (e.g. |first_name, last_name| → source_fields [:first_name, :last_name]).
|
|
253
|
+
if parse_block
|
|
254
|
+
params = parse_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
|
|
255
|
+
if params.length > 1
|
|
256
|
+
flags << :synthetic unless flags.include?(:synthetic)
|
|
257
|
+
source_fields = params.map { |_, pname| pname }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Introspect serialise block parameters: if 2+ params, the parameter
|
|
262
|
+
# names are model field references to gather during serialisation.
|
|
263
|
+
if serialise_block
|
|
264
|
+
params = serialise_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
|
|
265
|
+
if params.length > 1
|
|
266
|
+
target_fields = params.map { |_, pname| pname }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Handle :key flag
|
|
273
|
+
if flags.include?(:key)
|
|
274
|
+
if @key_attribute_name && @key_attribute_name != attribute_model_name
|
|
275
|
+
warn "Warning: :#{@key_attribute_name} already defined as :key, ignoring :#{attribute_model_name} as :key"
|
|
276
|
+
else
|
|
277
|
+
@key_attribute_name = attribute_model_name
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Register attribute definition
|
|
282
|
+
own_attribute_definitions[attribute_model_name] = Attribute.new(
|
|
283
|
+
model_name: attribute_model_name,
|
|
284
|
+
api_name: attribute_api_name,
|
|
285
|
+
type:,
|
|
286
|
+
flags:,
|
|
287
|
+
parse_block:,
|
|
288
|
+
serialise_block:,
|
|
289
|
+
source_fields:,
|
|
290
|
+
target_fields:
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Define accessor method on the class
|
|
294
|
+
define_method(attribute_model_name) { @model_attributes[attribute_model_name] }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# -- key -----------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def key(attr_name, type = nil, *flags)
|
|
300
|
+
if @key_attribute_name
|
|
301
|
+
warn "Warning: key already defined as :#{@key_attribute_name}, overriding with :#{attr_name}"
|
|
302
|
+
end
|
|
303
|
+
if type
|
|
304
|
+
self.attr(attr_name, type, *flags, :key)
|
|
305
|
+
else
|
|
306
|
+
self.attr(attr_name, *flags, :key)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# -- ignore --------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def ignore(*api_field_names)
|
|
313
|
+
api_field_names.each do |field_name|
|
|
314
|
+
own_ignored_fields << field_name.to_sym
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# -- hooks ---------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def before_parse(&block)
|
|
321
|
+
@before_parse_hook = block
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def after_parse(&block)
|
|
325
|
+
@after_parse_hook = block
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def before_serialise(&block)
|
|
329
|
+
@before_serialise_hook = block
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def after_serialise(&block)
|
|
333
|
+
@after_serialise_hook = block
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# -- with_stub -----------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def with_stub(**defaults)
|
|
339
|
+
@stub_defaults = defaults
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ── Attribute introspection ────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def attributes
|
|
345
|
+
all_attribute_definitions.keys
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def all_attribute_definitions
|
|
349
|
+
parent = superclass.respond_to?(:all_attribute_definitions) ? superclass.all_attribute_definitions : {}
|
|
350
|
+
parent.merge(own_attribute_definitions)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def attributes_with_flag(flag)
|
|
354
|
+
all_attribute_definitions.select { |_, attr_def| attr_def.flags.include?(flag) }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def all_ignored_fields
|
|
358
|
+
parent = superclass.respond_to?(:all_ignored_fields) ? superclass.all_ignored_fields : []
|
|
359
|
+
parent + own_ignored_fields
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def key_attribute_name
|
|
363
|
+
@key_attribute_name ||
|
|
364
|
+
(superclass.respond_to?(:key_attribute_name) ? superclass.key_attribute_name : nil)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def stub_defaults
|
|
368
|
+
parent = superclass.respond_to?(:stub_defaults) ? superclass.stub_defaults : {}
|
|
369
|
+
(parent || {}).merge(@stub_defaults || {})
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# ── Hook lookup (walks ancestor chain) ─────────────────────────────
|
|
373
|
+
|
|
374
|
+
def resolve_before_parse_hook
|
|
375
|
+
@before_parse_hook ||
|
|
376
|
+
(superclass.respond_to?(:resolve_before_parse_hook) ? superclass.resolve_before_parse_hook : nil)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def resolve_after_parse_hook
|
|
380
|
+
@after_parse_hook ||
|
|
381
|
+
(superclass.respond_to?(:resolve_after_parse_hook) ? superclass.resolve_after_parse_hook : nil)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def resolve_before_serialise_hook
|
|
385
|
+
@before_serialise_hook ||
|
|
386
|
+
(superclass.respond_to?(:resolve_before_serialise_hook) ? superclass.resolve_before_serialise_hook : nil)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def resolve_after_serialise_hook
|
|
390
|
+
@after_serialise_hook ||
|
|
391
|
+
(superclass.respond_to?(:resolve_after_serialise_hook) ? superclass.resolve_after_serialise_hook : nil)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# ── Class-level operations ─────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
def parse(api_data)
|
|
397
|
+
meta_collector = MetaCollector.new
|
|
398
|
+
|
|
399
|
+
hook = resolve_before_parse_hook
|
|
400
|
+
if hook
|
|
401
|
+
api_data = instance_exec(api_data, meta_collector, &hook)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
collected_meta = meta_collector.to_h
|
|
405
|
+
|
|
406
|
+
if api_data.is_a?(::Array)
|
|
407
|
+
api_data.map { |item| allocate.tap { |instance| instance.send(:init_from_api, item, collected_meta) } }
|
|
408
|
+
else
|
|
409
|
+
allocate.tap { |instance| instance.send(:init_from_api, api_data, collected_meta) }
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def stub(**model_data)
|
|
414
|
+
defaults = stub_defaults || {}
|
|
415
|
+
data = defaults.merge(model_data)
|
|
416
|
+
allocate.tap { |instance| instance.send(:init_from_model, data) }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# CRUD operations
|
|
420
|
+
|
|
421
|
+
def find(id)
|
|
422
|
+
response = get(path: "#{config.path}/#{id}")
|
|
423
|
+
parse(response)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def all
|
|
427
|
+
response = get(path: config.path.to_s)
|
|
428
|
+
parse(response)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def save(instance)
|
|
432
|
+
if instance.meta.new?
|
|
433
|
+
create(instance)
|
|
434
|
+
else
|
|
435
|
+
update(instance)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def create(instance)
|
|
440
|
+
response = post(
|
|
441
|
+
path: "#{config.path}",
|
|
442
|
+
body: instance.serialise
|
|
443
|
+
)
|
|
444
|
+
parse(response)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def update(instance)
|
|
448
|
+
response = put(
|
|
449
|
+
path: "#{config.path}/#{instance.unique_id}",
|
|
450
|
+
body: instance.serialise
|
|
451
|
+
)
|
|
452
|
+
parse(response)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def delete(id)
|
|
456
|
+
parent.delete(path: "#{config.path}/#{id}")
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# HTTP primitives — delegate to the parent API module's connection
|
|
460
|
+
|
|
461
|
+
def get(path:, params: {}, headers: {})
|
|
462
|
+
parent.get(path:, params:, headers:)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def post(path:, body: nil, headers: {})
|
|
466
|
+
parent.post(path:, body:, headers:)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def put(path:, body: nil, headers: {})
|
|
470
|
+
parent.put(path:, body:, headers:)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
private
|
|
474
|
+
|
|
475
|
+
def own_attribute_definitions
|
|
476
|
+
@own_attribute_definitions ||= {}
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def own_ignored_fields
|
|
480
|
+
@own_ignored_fields ||= []
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def own_metadata_defaults
|
|
484
|
+
@own_metadata_defaults ||= {}
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def all_metadata_defaults
|
|
488
|
+
if superclass.respond_to?(:metadata, true)
|
|
489
|
+
superclass.metadata.merge(own_metadata_defaults)
|
|
490
|
+
else
|
|
491
|
+
own_metadata_defaults
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def resolve_type(type)
|
|
496
|
+
return nil if type.nil?
|
|
497
|
+
|
|
498
|
+
# If it's a Ruby built-in class, map to Dry::Types equivalent
|
|
499
|
+
if type.is_a?(::Class)
|
|
500
|
+
TYPE_MAP.fetch(type, type)
|
|
501
|
+
else
|
|
502
|
+
type # Already a Dry::Types type (including constrained), pass through
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# ── Instance ─────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
# Delegate class-level config so hooks can call it via instance_exec
|
|
510
|
+
def config
|
|
511
|
+
self.class.config
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
attr_reader :meta
|
|
515
|
+
|
|
516
|
+
def initialize(model_data = {})
|
|
517
|
+
init_from_model(model_data)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def model
|
|
521
|
+
ModelProxy.new(@model_attributes)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def api
|
|
525
|
+
ShadowCopy.new(@api_data)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def unique_id
|
|
529
|
+
key_name = self.class.key_attribute_name
|
|
530
|
+
key_name ? @model_attributes[key_name] : nil
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def update(changes = {}, **kwargs)
|
|
534
|
+
changes = changes.merge(kwargs) unless kwargs.empty?
|
|
535
|
+
return self if changes.empty?
|
|
536
|
+
|
|
537
|
+
klass = self.class
|
|
538
|
+
coerced = {}
|
|
539
|
+
changes.each do |attr_name, value|
|
|
540
|
+
attr_def = klass.all_attribute_definitions[attr_name]
|
|
541
|
+
coerced[attr_name] = if attr_def && !value.nil?
|
|
542
|
+
attr_def.coerce(value)
|
|
543
|
+
else
|
|
544
|
+
value
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
new_model = @model_attributes.merge(coerced)
|
|
549
|
+
new_instance = self.class.allocate
|
|
550
|
+
new_instance.send(:init_from_update, new_model, @api_data, coerced)
|
|
551
|
+
new_instance
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def __changes__
|
|
555
|
+
@changes || {}
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def serialise
|
|
559
|
+
klass = self.class
|
|
560
|
+
|
|
561
|
+
# Run before_serialise hook on the instance
|
|
562
|
+
# Input: model_attributes. Side-effect only; return value ignored.
|
|
563
|
+
hook = klass.resolve_before_serialise_hook
|
|
564
|
+
instance_exec(@model_attributes, &hook) if hook
|
|
565
|
+
|
|
566
|
+
result = {}
|
|
567
|
+
|
|
568
|
+
# Serialise all attributes
|
|
569
|
+
klass.all_attribute_definitions.each do |_model_name, attr_def|
|
|
570
|
+
next if attr_def.read_only?
|
|
571
|
+
value = @model_attributes[attr_def.model_name]
|
|
572
|
+
|
|
573
|
+
if attr_def.target_fields.any?
|
|
574
|
+
# Multi-param serialise: gather model values by param names, splat into block
|
|
575
|
+
model_values = attr_def.target_fields.map { |fn| @model_attributes[fn] }
|
|
576
|
+
result[attr_def.api_name] = attr_def.serialise_value(*model_values)
|
|
577
|
+
elsif attr_def.source_fields.any?
|
|
578
|
+
serialised = attr_def.serialise_value(value)
|
|
579
|
+
if serialised.is_a?(::Array)
|
|
580
|
+
# Array return: zip with source field API names
|
|
581
|
+
convention = klass.attribute_convention
|
|
582
|
+
attr_def.source_fields.zip(serialised).each do |field_name, field_value|
|
|
583
|
+
api_key = convention.serialise(field_name)
|
|
584
|
+
result[api_key] = field_value
|
|
585
|
+
end
|
|
586
|
+
elsif serialised.is_a?(::Hash)
|
|
587
|
+
# Hash return: merge into result
|
|
588
|
+
result.merge!(serialised)
|
|
589
|
+
else
|
|
590
|
+
result[attr_def.api_name] = serialised
|
|
591
|
+
end
|
|
592
|
+
else
|
|
593
|
+
result[attr_def.api_name] = attr_def.serialise_value(value)
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Merge ignored fields from shadow copy
|
|
598
|
+
if @api_data && !@api_data.empty?
|
|
599
|
+
known_api_names = klass.all_attribute_definitions.values.map(&:api_name)
|
|
600
|
+
@api_data.each do |api_key, value|
|
|
601
|
+
unless known_api_names.include?(api_key) || result.key?(api_key)
|
|
602
|
+
result[api_key] = value
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Run after_serialise hook on the instance
|
|
608
|
+
# Input: serialised_data, model. Output: final serialised_data.
|
|
609
|
+
hook = klass.resolve_after_serialise_hook
|
|
610
|
+
if hook
|
|
611
|
+
result = instance_exec(result, model, &hook)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
result
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def to_json(*_args)
|
|
618
|
+
model_hash = @model_attributes.transform_keys(&:to_s)
|
|
619
|
+
::JSON.generate(model_hash)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def to_api
|
|
623
|
+
::JSON.generate(serialise)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def ==(other)
|
|
627
|
+
other.is_a?(self.class) && self.class == other.class &&
|
|
628
|
+
@model_attributes == other.send(:model_attributes_hash)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
alias_method :eql?, :==
|
|
632
|
+
|
|
633
|
+
def hash
|
|
634
|
+
[self.class, @model_attributes].hash
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
private
|
|
638
|
+
|
|
639
|
+
def model_attributes_hash
|
|
640
|
+
@model_attributes
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def init_from_api(api_data, extra_meta = {})
|
|
644
|
+
klass = self.class
|
|
645
|
+
|
|
646
|
+
@api_data = api_data.is_a?(::Hash) ? api_data.dup : {}
|
|
647
|
+
@model_attributes = {}
|
|
648
|
+
@changes = {}
|
|
649
|
+
@meta = Meta.new(new_record: false, saved: true, **klass.metadata, **extra_meta)
|
|
650
|
+
|
|
651
|
+
return unless api_data.is_a?(::Hash)
|
|
652
|
+
|
|
653
|
+
# Parse all attributes
|
|
654
|
+
klass.all_attribute_definitions.each do |model_name, attr_def|
|
|
655
|
+
if attr_def.source_fields.any?
|
|
656
|
+
# Source fields declared via block params: extract individual
|
|
657
|
+
# values from api_data using convention, splat into parse block.
|
|
658
|
+
convention = klass.attribute_convention
|
|
659
|
+
raw_values = attr_def.source_fields.map do |field_name|
|
|
660
|
+
api_key = convention.serialise(field_name)
|
|
661
|
+
api_data[api_key]
|
|
662
|
+
end
|
|
663
|
+
@model_attributes[model_name] = attr_def.parse_value(*raw_values)
|
|
664
|
+
else
|
|
665
|
+
raw_value = api_data[attr_def.api_name]
|
|
666
|
+
|
|
667
|
+
if raw_value.nil? && attr_def.required?
|
|
668
|
+
raise MissingAttributeError.new(model_name)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
if raw_value.nil?
|
|
672
|
+
@model_attributes[model_name] = nil
|
|
673
|
+
else
|
|
674
|
+
@model_attributes[model_name] = attr_def.parse_value(raw_value)
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
if config.debug
|
|
680
|
+
# Warn about API fields that are neither declared attrs nor explicitly ignored
|
|
681
|
+
convention = klass.attribute_convention
|
|
682
|
+
known_api_keys = klass.all_attribute_definitions.values.flat_map do |ad|
|
|
683
|
+
keys = [ad.api_name]
|
|
684
|
+
ad.source_fields.each { |sf| keys << convention.serialise(sf) }
|
|
685
|
+
keys
|
|
686
|
+
end
|
|
687
|
+
ignored_api_keys = klass.all_ignored_fields.map { |f| convention.serialise(f) }
|
|
688
|
+
known_api_keys.concat(ignored_api_keys)
|
|
689
|
+
|
|
690
|
+
api_data.each_key do |api_key|
|
|
691
|
+
unless known_api_keys.include?(api_key)
|
|
692
|
+
warn "RestEasy: unknown API field '#{api_key}' in #{klass.name || 'Resource'}. " \
|
|
693
|
+
"Declare it with attr, or silence this warning with ignore."
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Warn about declared attributes missing from the API response
|
|
698
|
+
klass.all_attribute_definitions.each do |model_name, attr_def|
|
|
699
|
+
next if attr_def.required? # already raises
|
|
700
|
+
|
|
701
|
+
api_keys_to_check = if attr_def.source_fields.any?
|
|
702
|
+
attr_def.source_fields.map { |sf| convention.serialise(sf) }
|
|
703
|
+
else
|
|
704
|
+
[attr_def.api_name]
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
api_keys_to_check.each do |api_key|
|
|
708
|
+
unless api_data.key?(api_key)
|
|
709
|
+
warn "RestEasy: expected API field '#{api_key}' for attr :#{model_name} " \
|
|
710
|
+
"in #{klass.name || 'Resource'}, but it was not present in the response."
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Run after_parse hook on the instance
|
|
717
|
+
# Input: model (parsed attributes), api (shadow copy). Side-effect only; return value ignored.
|
|
718
|
+
hook = klass.resolve_after_parse_hook
|
|
719
|
+
if hook
|
|
720
|
+
instance_exec(model, api, &hook)
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def init_from_model(model_data)
|
|
725
|
+
klass = self.class
|
|
726
|
+
@api_data = {}
|
|
727
|
+
@model_attributes = {}
|
|
728
|
+
@changes = {}
|
|
729
|
+
@meta = Meta.new(new_record: true, saved: false, **klass.metadata)
|
|
730
|
+
|
|
731
|
+
# Set attributes from model data, coercing through the type
|
|
732
|
+
klass.all_attribute_definitions.each do |model_name, attr_def|
|
|
733
|
+
if model_data.key?(model_name)
|
|
734
|
+
value = model_data[model_name]
|
|
735
|
+
@model_attributes[model_name] = value.nil? ? nil : attr_def.coerce(value)
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def init_from_update(new_model_attrs, original_api_data, changes)
|
|
741
|
+
@api_data = original_api_data
|
|
742
|
+
@model_attributes = new_model_attrs
|
|
743
|
+
@changes = changes
|
|
744
|
+
@meta = Meta.new(new_record: false, saved: true, **self.class.metadata)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
end
|