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.
@@ -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