rest-easy 1.4.0 → 1.4.2

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: 567f6e8a9344027299c80aeebc330318bedf9ea0a030493b378ed3bfa9c3047d
4
- data.tar.gz: 8d9015ee4dd9eeda16ed953cec2804c7a8ba723cd346dc0e1da481c4c3a9683a
3
+ metadata.gz: a192d482f07dc654cae8267e3166742ee45d90c4bf960f54f632f94ca11c20a6
4
+ data.tar.gz: 74b41469d03fb34c19616774f9ccb97dfd3191f937dfcf2e96560986333ccf12
5
5
  SHA512:
6
- metadata.gz: 6e10536c6514eaca3e02acec7e303dbf085b4cef96e9fceb1d48260571087d56589aec201e3996153f6bb3dfafd235a20dda099ac634c300492d18c831ebb50d
7
- data.tar.gz: c92c35d0ab63b770d2abf29e24ca8b4f7ff3a59945594e55d30bbf0ec7843f39f0f734ed2ca01aa648b94df57e4e8302d50182c3934b71343f3acfb4d9124271
6
+ metadata.gz: b493ff7a6f39fa303665e6e09174b2f664d35960e18864dbb39e3d2cf95fe3ea13543f4384fc3a4c4656d8ada2b029f486ccf99ac6e2d9dda983c74e05717f6b
7
+ data.tar.gz: fdec9a5343a0bf002fe43b1532849ec7a28520ee95392e4beddcd0e11af1b3fd2ec0c9c2c11eceeafb65ea8dd640281fe521f1613022bc43f60615ca0afc1095
data/CHANGELOG.md CHANGED
@@ -2,6 +2,78 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.2] - 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - **`update` no longer discards state it should carry forward.** It rebuilt
10
+ the instance's metadata and its change set from scratch instead of
11
+ inheriting them, which broke three things at once:
12
+
13
+ - An instance that had never been saved came back marked as persisted, so
14
+ `save` took the update branch: a PUT to the collection path with no id in
15
+ it, for a record the API has never seen, instead of a POST that creates
16
+ one. Building a record and adjusting it before the first save is the
17
+ ordinary way to use an immutable resource, and it could not produce a
18
+ correct create request.
19
+ - Chained updates kept only the last call's fields. After
20
+ `a.update(x: 1).update(y: 2)`, `__changes__` reported just `y` while `x`
21
+ read back as `1` on the instance, so a partial payload built from the
22
+ change set left out a field the object claimed to carry, and nothing
23
+ raised.
24
+ - Metadata set by a `before_parse` hook lives on the instance, not the
25
+ class, and was dropped. A consumer flagging parsed instances — to mark
26
+ the thin records a collection endpoint returns, say — lost the flag as
27
+ soon as anything called `update`.
28
+
29
+ `update` now inherits the receiver's metadata and merges its change set.
30
+ The metadata is copied rather than shared, so mutating an updated copy's
31
+ metadata still cannot reach the original's.
32
+
33
+ ## [1.4.1] - 2026-08-18
34
+
35
+ ### Fixed
36
+
37
+ - **`Meta` no longer claims to implement methods it has no value for.**
38
+ `Meta#respond_to_missing?` returned `true` for every name, so any caller
39
+ that asks an object what it can do before calling it got a false yes, then
40
+ received the `nil` that `method_missing` returns for an unknown key as
41
+ though it were a real answer. Ruby does this constantly and implicitly —
42
+ coercion probes when a value is splatted or interpolated, serialisation
43
+ hooks, and any library that duck-types with `respond_to?` — so the failure
44
+ surfaced far from `Meta` and looked unrelated to it. Marshalling was the
45
+ sharpest case: `dump` silently wrote an empty payload and `load` then
46
+ raised `NoMethodError` from inside `method_missing`, which broke every
47
+ consumer caching resources in a store that marshals its entries.
48
+
49
+ Bare getters are now claimed only for keys the instance actually holds.
50
+ Setters and predicates are still claimed unconditionally, since writing is
51
+ how a key comes into existence and an unset predicate is meaningfully
52
+ `false`; no core Ruby probe uses those two shapes. Library-level
53
+ duck-typing can, though — ActiveSupport's `acts_like?(:date)` asks for
54
+ `acts_like_date?`, and `Meta` still answers yes — so the guarantee is
55
+ narrower than "nothing probes for `=` or `?`", and closing it properly is
56
+ tracked in #7. Operators are no longer claimed either: the check is
57
+ anchored to identifier-shaped names, so `<=` is not mistaken for a setter
58
+ (`method_missing` still is — #6).
59
+ `Resource::MetaCollector` had the identical defect and is fixed the same
60
+ way.
61
+
62
+ **Upgrading with a warm cache:** entries written by an earlier version are
63
+ not recoverable. A payload dumped before this fix carries `nil` where the
64
+ meta state should be, and loading it now raises `TypeError: instance of
65
+ RestEasy::Meta needs to have method 'marshal_load'` — legible, but still
66
+ an error. Consumers on a cache that survives deploys (`:file_store`,
67
+ Redis, Memcached) must flush it or bump the cache key namespace when
68
+ upgrading. A process-local `:memory_store` clears itself on restart and
69
+ needs nothing.
70
+
71
+ `method_missing` is unchanged: a bare getter for a key that was never set
72
+ still returns `nil` rather than raising, as the "gem author extensions"
73
+ example in `docs/model-architecture.md` depends on. `respond_to?`
74
+ therefore under-reports for those keys; reconciling the two halves
75
+ requires declaring meta keys up front and is tracked in #7.
76
+
5
77
  ## [1.4.0] - 2026-06-26
6
78
 
7
79
  ### Fixed
@@ -123,7 +195,9 @@
123
195
 
124
196
  Initial release.
125
197
 
126
- [Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.4.0...HEAD
198
+ [Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.4.2...HEAD
199
+ [1.4.2]: https://github.com/accodeing/rest-easy/compare/v1.4.1...v1.4.2
200
+ [1.4.1]: https://github.com/accodeing/rest-easy/compare/v1.4.0...v1.4.1
127
201
  [1.4.0]: https://github.com/accodeing/rest-easy/compare/v1.3.1...v1.4.0
128
202
  [1.3.1]: https://github.com/accodeing/rest-easy/compare/v1.3.0...v1.3.1
129
203
  [1.3.0]: https://github.com/accodeing/rest-easy/compare/v1.2.0...v1.3.0
@@ -6,6 +6,11 @@ module RestEasy
6
6
  @data = { new: new_record, saved: saved, **defaults }
7
7
  end
8
8
 
9
+ def initialize_copy(source)
10
+ super
11
+ @data = @data.dup
12
+ end
13
+
9
14
  def new?
10
15
  @data[:new]
11
16
  end
@@ -25,8 +30,46 @@ module RestEasy
25
30
  end
26
31
  end
27
32
 
28
- def respond_to_missing?(_name, _include_private = false)
29
- true
33
+ # Accessor shapes `method_missing` synthesises. Anchored, so operators
34
+ # stay out: `<=` ends with `=`, and claiming it would let `meta <= 5`
35
+ # read as a setter. `method_missing` itself is still unanchored and
36
+ # writes `@data[:<]` for that call — see issue #6.
37
+ #
38
+ # POSIX classes rather than `[a-zA-Z_]\w*`, because Ruby identifiers are
39
+ # not ASCII-only: `meta.företag = x` is a valid setter that
40
+ # `method_missing` stores, so `respond_to?` has to claim it too.
41
+ ACCESSOR_PATTERN = /\A[[:alpha:]_][[:word:]]*[=?]\z/
42
+ private_constant :ACCESSOR_PATTERN
43
+
44
+ # `method_missing` answers to any name, but this must not, because callers
45
+ # routinely ask an object what it can do before calling it — Ruby's own
46
+ # coercion probes (`to_hash` on double-splat, `to_ary`, `to_str`),
47
+ # serialisation hooks (`marshal_dump`, `init_with`), and any library that
48
+ # duck-types with `respond_to?`. Claiming those hands the caller the `nil`
49
+ # that `method_missing` returns for an unknown key as though it were an
50
+ # answer.
51
+ #
52
+ # Bare getters are therefore claimed only for keys actually held. Setters
53
+ # and predicates are claimed unconditionally: writing is how a key comes
54
+ # into existence, and an unset predicate is meaningfully `false`. No core
55
+ # Ruby probe uses those two shapes — though library-level duck-typing can,
56
+ # and ActiveSupport's `acts_like?(:date)` asking for `acts_like_date?` is
57
+ # a known false positive. Closing it properly means declaring meta keys
58
+ # up front, which is a breaking change — see issue #7.
59
+ #
60
+ # `@data` is guarded because `respond_to?` must never raise, and Ruby
61
+ # allocates an instance before initialising it: Psych's `revive` probes
62
+ # `init_with` on a bare allocation, and so does `Marshal.load` on a
63
+ # payload written before this fix.
64
+ #
65
+ # This under-claims a bare getter for a key that was never set, which
66
+ # `method_missing` still answers with `nil`. Reconciling the two halves
67
+ # means declaring meta keys up front, and that is a 2.0 — see issue #7.
68
+ def respond_to_missing?(name, include_private = false)
69
+ return true if ACCESSOR_PATTERN.match?(name.to_s)
70
+ return super unless @data
71
+
72
+ @data.key?(name.to_sym) || super
30
73
  end
31
74
  end
32
75
  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
 
@@ -584,7 +598,7 @@ module RestEasy
584
598
 
585
599
  new_model = @model_attributes.merge(coerced)
586
600
  new_instance = self.class.allocate
587
- new_instance.send(:init_from_update, new_model, @api_data, coerced)
601
+ new_instance.send(:init_from_update, new_model, @api_data, __changes__.merge(coerced), @meta)
588
602
  new_instance
589
603
  end
590
604
 
@@ -787,11 +801,14 @@ module RestEasy
787
801
  end
788
802
  end
789
803
 
790
- def init_from_update(new_model_attrs, original_api_data, changes)
804
+ def init_from_update(new_model_attrs, original_api_data, changes, meta)
791
805
  @api_data = original_api_data
792
806
  @model_attributes = new_model_attrs
793
807
  @changes = changes
794
- @meta = Meta.new(new_record: false, saved: true, **self.class.metadata)
808
+ # Carried from the receiver, not rebuilt: an instance that was never
809
+ # saved stays new, so `save` still POSTs it, and metadata a parse hook
810
+ # set (which lives on the instance, not the class) survives the update.
811
+ @meta = meta.dup
795
812
  end
796
813
  end
797
814
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RestEasy
4
- VERSION = "1.4.0"
4
+ VERSION = "1.4.2"
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.4.0
4
+ version: 1.4.2
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-06-26 00:00:00.000000000 Z
13
+ date: 2026-09-23 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: dry-types