rest-easy 1.3.1 → 1.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95a7fdafc540b448187281dfcd184257553fc79cbf93af6236463e9bbcadcfa5
4
- data.tar.gz: a5710b3e26d6fb8078d1079359f78dbe0b7a12810411f86652a84997086a0022
3
+ metadata.gz: a30780e0b38e31f1c183d1ec0a91ea93658a78e67d2415aeb8aef279d03359b7
4
+ data.tar.gz: 2ed7aafbcaf5e0c6fd463992a65293d1f064c693611f64b9b3256e8e4b518fdf
5
5
  SHA512:
6
- metadata.gz: 7444dc20090fc6547e1f55516fcb0a9d7d467ecfd6f0a9530c768ae7d6b735fa2ca789bdb7cc222277269fa4494a05c4187b2f3ffd28c74d82636ae09a197848
7
- data.tar.gz: 1c7af7dc5042410d6a11483356c41a24dacff39c240fb1cdce1950b859f47c2adb4a6293f93e0257ef5e5b482fb28b000d3660c6f2c705fe9b1925a3837e3837
6
+ metadata.gz: 3ac9ea37e26b652d5fe590075d7668f70e2cf8b7290b02d7e4dd1f227bf7a7e26ea9e5faa02ed1b84b5706262d90e774db52381ea0ce2d026da40d410ae38c11
7
+ data.tar.gz: 8f8773d0cbacc6366daade22ceee18ee76de91ee5eb5cd718c392e0abfe1ed3a911658a867dab1bcd1f21b077f2296e0cb5ffbb1aa5597f6fee7e77a7b40b84d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,91 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.1] - 2026-08-18
6
+
7
+ ### Fixed
8
+
9
+ - **`Meta` no longer claims to implement methods it has no value for.**
10
+ `Meta#respond_to_missing?` returned `true` for every name, so any caller
11
+ that asks an object what it can do before calling it got a false yes, then
12
+ received the `nil` that `method_missing` returns for an unknown key as
13
+ though it were a real answer. Ruby does this constantly and implicitly —
14
+ coercion probes when a value is splatted or interpolated, serialisation
15
+ hooks, and any library that duck-types with `respond_to?` — so the failure
16
+ surfaced far from `Meta` and looked unrelated to it. Marshalling was the
17
+ sharpest case: `dump` silently wrote an empty payload and `load` then
18
+ raised `NoMethodError` from inside `method_missing`, which broke every
19
+ consumer caching resources in a store that marshals its entries.
20
+
21
+ Bare getters are now claimed only for keys the instance actually holds.
22
+ Setters and predicates are still claimed unconditionally, since writing is
23
+ how a key comes into existence and an unset predicate is meaningfully
24
+ `false`; no core Ruby probe uses those two shapes. Library-level
25
+ duck-typing can, though — ActiveSupport's `acts_like?(:date)` asks for
26
+ `acts_like_date?`, and `Meta` still answers yes — so the guarantee is
27
+ narrower than "nothing probes for `=` or `?`", and closing it properly is
28
+ tracked in #7. Operators are no longer claimed either: the check is
29
+ anchored to identifier-shaped names, so `<=` is not mistaken for a setter
30
+ (`method_missing` still is — #6).
31
+ `Resource::MetaCollector` had the identical defect and is fixed the same
32
+ way.
33
+
34
+ **Upgrading with a warm cache:** entries written by an earlier version are
35
+ not recoverable. A payload dumped before this fix carries `nil` where the
36
+ meta state should be, and loading it now raises `TypeError: instance of
37
+ RestEasy::Meta needs to have method 'marshal_load'` — legible, but still
38
+ an error. Consumers on a cache that survives deploys (`:file_store`,
39
+ Redis, Memcached) must flush it or bump the cache key namespace when
40
+ upgrading. A process-local `:memory_store` clears itself on restart and
41
+ needs nothing.
42
+
43
+ `method_missing` is unchanged: a bare getter for a key that was never set
44
+ still returns `nil` rather than raising, as the "gem author extensions"
45
+ example in `docs/model-architecture.md` depends on. `respond_to?`
46
+ therefore under-reports for those keys; reconciling the two halves
47
+ requires declaring meta keys up front and is tracked in #7.
48
+
49
+ ## [1.4.0] - 2026-06-26
50
+
51
+ ### Fixed
52
+
53
+ - **`:required` is now enforced on serialise as well as parse.** Previously
54
+ the flag raised `MissingAttributeError` only when an API response omitted
55
+ the field; it was silently ignored when sending (`save`), letting
56
+ incomplete payloads reach the backend and surface as a generic
57
+ `RequestError`. The flag now also fires at `serialise` time (and therefore
58
+ on `save`) when a required attribute is `nil`, before any HTTP request,
59
+ with the attribute name on the exception (`#attribute_name`). Inbound
60
+ behavior is unchanged — `parse` still raises on missing required fields.
61
+ A field is treated as missing whether the API response omits the key
62
+ entirely or includes it as explicit `null`; both are rejected as
63
+ incomplete data.
64
+ - **`:required` is now enforced on synthetic attributes (merge / combine /
65
+ split patterns).** Previously the flag was silently a no-op for any
66
+ attribute with a multi-parameter parse or serialise block. It now
67
+ enforces presence on all underlying API or model fields: a merge
68
+ attribute requires every source API field on parse; a combine attribute
69
+ requires every named model attribute on serialise; a split attribute
70
+ requires the underlying API field on parse. `:read_only` continues to
71
+ skip the serialise-time check.
72
+
73
+ ### Changed
74
+
75
+ - **The DSL auto-applies `:synthetic` to multi-parameter serialise blocks**
76
+ (and mapper `serialise` methods), mirroring the existing behavior for
77
+ multi-parameter parse. Previously only the parse side was tagged,
78
+ leaving the flag inconsistent with its intent of marking attributes whose
79
+ storage shape diverges from the standard one-slot layout.
80
+ - **Combine attributes no longer read from their `api_name` on parse.**
81
+ A combine attribute's API name does not correspond to a real inbound
82
+ field by design — the value is built from `target_fields` at serialise
83
+ time. The previous code looked up `api_data[api_name]`, stored it at
84
+ the model slot, and ran the standard `:required` check, raising
85
+ spuriously when the field was absent (the documented case). Inbound
86
+ values for the shadowed API key are now ignored and the model slot is
87
+ set to `nil`, avoiding the contradiction where `instance.address` could
88
+ return one value while serialise would overwrite it with another.
89
+
5
90
  ## [1.3.1] - 2026-05-27
6
91
 
7
92
  ### Fixed
@@ -82,7 +167,9 @@
82
167
 
83
168
  Initial release.
84
169
 
85
- [Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.3.1...HEAD
170
+ [Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.4.1...HEAD
171
+ [1.4.1]: https://github.com/accodeing/rest-easy/compare/v1.4.0...v1.4.1
172
+ [1.4.0]: https://github.com/accodeing/rest-easy/compare/v1.3.1...v1.4.0
86
173
  [1.3.1]: https://github.com/accodeing/rest-easy/compare/v1.3.0...v1.3.1
87
174
  [1.3.0]: https://github.com/accodeing/rest-easy/compare/v1.2.0...v1.3.0
88
175
  [1.2.0]: https://github.com/accodeing/rest-easy/compare/v1.1.2...v1.2.0
data/README.md CHANGED
@@ -326,12 +326,12 @@ attr [:tax_url, '@urlTaxReductionList'], String, :read_only
326
326
 
327
327
  ### Flags
328
328
 
329
- | Flag | Effect |
330
- |--------------|----------------------------------------------------------|
331
- | `:required` | Raises `MissingAttributeError` if absent in API response |
332
- | `:optional` | Documents that the field may be absent (default) |
333
- | `:read_only` | Excluded from serialisation (not sent back to the API) |
334
- | `:key` | Marks the unique identifier for CRUD operations |
329
+ | Flag | Effect |
330
+ |--------------|------------------------------------------------------------------------|
331
+ | `:required` | Raises `MissingAttributeError` if missing or explicitly `null` in the API response (on parse) or `nil` at serialise time (`save`, `to_api`). Omitted keys and explicit `null` are treated identically — a required attribute must carry a value. See [Merge](#merge-pattern--many-api-fields-into-one-model-attribute), [Combine](#combine-pattern--many-model-attributes-into-one-api-field), and [Split](#split-pattern--one-api-field-into-many-model-attributes) patterns for how this applies to synthetic attributes. |
332
+ | `:optional` | Documents that the field may be absent (default) |
333
+ | `:read_only` | Excluded from serialisation (not sent back to the API) |
334
+ | `:key` | Marks the unique identifier for CRUD operations |
335
335
 
336
336
  ```ruby
337
337
  key :id, Integer, :read_only
@@ -430,6 +430,31 @@ end
430
430
  attr :full_name, String, FullNameMapper
431
431
  ```
432
432
 
433
+ Marking a merge attribute `:required` enforces that **all** underlying API fields are present in the response on parse. If the attribute is also round-trippable (not `:read_only`), the merged model value must additionally be non-`nil` at serialise time.
434
+
435
+ Note that string-concatenation merges like the `full_name` example above are inherently lossy on the round-trip — splitting `"Hans Erik Nilsson"` back into `FirstName`/`LastName` is ambiguous. Such attributes should normally be `:read_only`. Round-trippable merges work when the model representation preserves the structure (e.g. a tuple, a `Money` value object, or a `Date` built from year/month/day components).
436
+
437
+ ### Combine pattern — many model attributes into one API field
438
+
439
+ The dual of merge: when the serialise method takes multiple parameters, RestEasy gathers the values from the corresponding model attributes and passes them in. The block returns the single combined API value:
440
+
441
+ ```ruby
442
+ attr :street, String
443
+ attr :city, String
444
+
445
+ attr :address, String do
446
+ serialise { |street, city| "#{street}, #{city}" }
447
+ end
448
+ ```
449
+
450
+ The parameter names (`street`, `city`) name model attributes; the framework reads them from the instance and splats them into the block. The block's return value is written under the attribute's API name (`Address`).
451
+
452
+ This also works with mapper objects whose `serialise` method takes multiple parameters.
453
+
454
+ Marking a combine attribute `:required` enforces that **all** named model attributes are non-`nil` at serialise time. The parse side does not apply — combine attributes don't read from a single API field on the way in, so users typically populate the underlying model attributes directly.
455
+
456
+ Combine attributes do not run a `parse` block. If you declare one alongside a multi-parameter `serialise`, RestEasy emits a warning at load time — the parse block would be silently ignored otherwise. If you need to read from an API field on parse, declare a separate attribute for it (or restructure as a merge pattern).
457
+
433
458
  ### Split pattern — one API field into many model attributes
434
459
 
435
460
  Use a bare block with a parameter to extract from a single API field:
@@ -446,6 +471,10 @@ end
446
471
 
447
472
  The parameter name (`address`) determines which API field to read from.
448
473
 
474
+ Marking a split attribute `:required` enforces that the underlying API field is present on parse — in the example above, the API response must include `Address`.
475
+
476
+ On serialise, split attributes are emitted under their own api_names (here, `Street` and `City`) rather than being recombined into the original source field. If the API does not accept the parts as independent top-level fields, mark them `:read_only` to keep them out of the outbound payload entirely; alternatively, reconstruct the source field in an `after_serialise` hook.
477
+
449
478
  ### Ignoring fields
450
479
 
451
480
  Tell RestEasy to silently skip API fields you don't need:
@@ -36,6 +36,20 @@ module RestEasy
36
36
  @flags.include?(:synthetic)
37
37
  end
38
38
 
39
+ # A combine attribute is built from multiple model fields on serialise
40
+ # (target_fields), with no inbound source from its own api_name on parse.
41
+ # See the "Combine pattern" section of the README.
42
+ def combine?
43
+ @target_fields.any? && @source_fields.empty?
44
+ end
45
+
46
+ def validate_required!(*values)
47
+ return unless required?
48
+ return if values.none?(&:nil?)
49
+
50
+ raise RestEasy::MissingAttributeError.new(model_name)
51
+ end
52
+
39
53
  def coerce(value)
40
54
  @type[value]
41
55
  rescue Dry::Types::ConstraintError, Dry::Types::CoercionError => e
@@ -25,8 +25,46 @@ module RestEasy
25
25
  end
26
26
  end
27
27
 
28
- def respond_to_missing?(_name, _include_private = false)
29
- true
28
+ # Accessor shapes `method_missing` synthesises. Anchored, so operators
29
+ # stay out: `<=` ends with `=`, and claiming it would let `meta <= 5`
30
+ # read as a setter. `method_missing` itself is still unanchored and
31
+ # writes `@data[:<]` for that call — see issue #6.
32
+ #
33
+ # POSIX classes rather than `[a-zA-Z_]\w*`, because Ruby identifiers are
34
+ # not ASCII-only: `meta.företag = x` is a valid setter that
35
+ # `method_missing` stores, so `respond_to?` has to claim it too.
36
+ ACCESSOR_PATTERN = /\A[[:alpha:]_][[:word:]]*[=?]\z/
37
+ private_constant :ACCESSOR_PATTERN
38
+
39
+ # `method_missing` answers to any name, but this must not, because callers
40
+ # routinely ask an object what it can do before calling it — Ruby's own
41
+ # coercion probes (`to_hash` on double-splat, `to_ary`, `to_str`),
42
+ # serialisation hooks (`marshal_dump`, `init_with`), and any library that
43
+ # duck-types with `respond_to?`. Claiming those hands the caller the `nil`
44
+ # that `method_missing` returns for an unknown key as though it were an
45
+ # answer.
46
+ #
47
+ # Bare getters are therefore claimed only for keys actually held. Setters
48
+ # and predicates are claimed unconditionally: writing is how a key comes
49
+ # into existence, and an unset predicate is meaningfully `false`. No core
50
+ # Ruby probe uses those two shapes — though library-level duck-typing can,
51
+ # and ActiveSupport's `acts_like?(:date)` asking for `acts_like_date?` is
52
+ # a known false positive. Closing it properly means declaring meta keys
53
+ # up front, which is a breaking change — see issue #7.
54
+ #
55
+ # `@data` is guarded because `respond_to?` must never raise, and Ruby
56
+ # allocates an instance before initialising it: Psych's `revive` probes
57
+ # `init_with` on a bare allocation, and so does `Marshal.load` on a
58
+ # payload written before this fix.
59
+ #
60
+ # This under-claims a bare getter for a key that was never set, which
61
+ # `method_missing` still answers with `nil`. Reconciling the two halves
62
+ # means declaring meta keys up front, and that is a 2.0 — see issue #7.
63
+ def respond_to_missing?(name, include_private = false)
64
+ return true if ACCESSOR_PATTERN.match?(name.to_s)
65
+ return super unless @data
66
+
67
+ @data.key?(name.to_sym) || super
30
68
  end
31
69
  end
32
70
  end
@@ -119,8 +119,22 @@ module RestEasy
119
119
  end
120
120
  end
121
121
 
122
- def respond_to_missing?(_name, _include_private = false)
123
- true
122
+ # Anchored so operators stay out — see Meta::ACCESSOR_PATTERN and
123
+ # issue #6. Only the setter shape here; unlike Meta, `method_missing`
124
+ # above has no predicate form.
125
+ SETTER_PATTERN = /\A[[:alpha:]_][[:word:]]*=\z/
126
+ private_constant :SETTER_PATTERN
127
+
128
+ # Same reasoning as Meta#respond_to_missing? — a blanket `true` makes
129
+ # this claim Ruby's implicit protocol methods and hand them the `nil`
130
+ # from `method_missing`. `@data` is guarded for the same reason too:
131
+ # `respond_to?` must never raise, and Psych and Marshal both probe a
132
+ # bare allocation before it is initialised.
133
+ def respond_to_missing?(name, include_private = false)
134
+ return true if SETTER_PATTERN.match?(name.to_s)
135
+ return super unless @data
136
+
137
+ @data.key?(name.to_sym) || super
124
138
  end
125
139
  end
126
140
 
@@ -250,6 +264,7 @@ module RestEasy
250
264
 
251
265
  serialise_params = serialise_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
252
266
  if serialise_params.length > 1
267
+ flags << :synthetic unless flags.include?(:synthetic)
253
268
  target_fields = serialise_params.map { |_, pname| pname }
254
269
  end
255
270
  elsif block
@@ -284,9 +299,22 @@ module RestEasy
284
299
  if serialise_block
285
300
  params = serialise_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
286
301
  if params.length > 1
302
+ flags << :synthetic unless flags.include?(:synthetic)
287
303
  target_fields = params.map { |_, pname| pname }
288
304
  end
289
305
  end
306
+
307
+ # Combine pattern (multi-param serialise, no multi-param parse)
308
+ # has no inbound api_name on parse. If the user also wrote an
309
+ # explicit `parse` block, it will be silently ignored — warn so
310
+ # the inconsistency is visible at load time.
311
+ if target_fields.any? && source_fields.empty? && parse_block
312
+ warn "RestEasy: :#{attribute_model_name} declares a combine pattern " \
313
+ "(serialise from #{target_fields.inspect}) and also defines a parse block. " \
314
+ "Combine attributes have no inbound API field to read, so the parse block " \
315
+ "will not run. Remove the parse block, or restructure the declaration if you " \
316
+ "intended to read from the API."
317
+ end
290
318
  end
291
319
  end
292
320
 
@@ -591,13 +619,15 @@ module RestEasy
591
619
  # Serialise all attributes
592
620
  klass.all_attribute_definitions.each do |_model_name, attr_def|
593
621
  next if attr_def.read_only?
594
- value = @model_attributes[attr_def.model_name]
595
622
 
596
623
  if attr_def.target_fields.any?
597
624
  # Multi-param serialise: gather model values by param names, splat into block
598
625
  model_values = attr_def.target_fields.map { |fn| @model_attributes[fn] }
626
+ attr_def.validate_required!(*model_values)
599
627
  result[attr_def.api_name] = attr_def.serialise_value(*model_values)
600
628
  elsif attr_def.source_fields.any?
629
+ value = @model_attributes[attr_def.model_name]
630
+ attr_def.validate_required!(value)
601
631
  serialised = attr_def.serialise_value(value)
602
632
  if serialised.is_a?(::Array)
603
633
  # Array return: zip with source field API names
@@ -613,6 +643,8 @@ module RestEasy
613
643
  result[attr_def.api_name] = serialised
614
644
  end
615
645
  else
646
+ value = @model_attributes[attr_def.model_name]
647
+ attr_def.validate_required!(value)
616
648
  result[attr_def.api_name] = attr_def.serialise_value(value)
617
649
  end
618
650
  end
@@ -683,13 +715,19 @@ module RestEasy
683
715
  api_key = convention.serialise(field_name)
684
716
  api_data[api_key]
685
717
  end
718
+
719
+ attr_def.validate_required!(*raw_values)
720
+
686
721
  @model_attributes[model_name] = attr_def.parse_value(*raw_values)
722
+ elsif attr_def.combine?
723
+ # Combine pattern: the attribute's api_name does not exist on the
724
+ # API side by design — the value is built from target_fields at
725
+ # serialise time. Nothing inbound to read or validate.
726
+ @model_attributes[model_name] = nil
687
727
  else
688
728
  raw_value = api_data[attr_def.api_name]
689
729
 
690
- if raw_value.nil? && attr_def.required?
691
- raise MissingAttributeError.new(model_name)
692
- end
730
+ attr_def.validate_required!(raw_value)
693
731
 
694
732
  if raw_value.nil?
695
733
  @model_attributes[model_name] = nil
@@ -717,9 +755,12 @@ module RestEasy
717
755
  end
718
756
  end
719
757
 
720
- # Warn about declared attributes missing from the API response
758
+ # Warn about declared attributes missing from the API response.
759
+ # Combine attrs have no inbound api_name by design; non-combine
760
+ # required attrs already raised in the parse loop above.
721
761
  klass.all_attribute_definitions.each do |model_name, attr_def|
722
- next if attr_def.required? # already raises
762
+ next if attr_def.combine?
763
+ next if attr_def.required?
723
764
 
724
765
  api_keys_to_check = if attr_def.source_fields.any?
725
766
  attr_def.source_fields.map { |sf| convention.serialise(sf) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RestEasy
4
- VERSION = "1.3.1"
4
+ VERSION = "1.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rest-easy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Schubert Erlandsson
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-05-27 00:00:00.000000000 Z
13
+ date: 2026-08-18 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: dry-types