rest-easy 1.3.1 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95a7fdafc540b448187281dfcd184257553fc79cbf93af6236463e9bbcadcfa5
4
- data.tar.gz: a5710b3e26d6fb8078d1079359f78dbe0b7a12810411f86652a84997086a0022
3
+ metadata.gz: 567f6e8a9344027299c80aeebc330318bedf9ea0a030493b378ed3bfa9c3047d
4
+ data.tar.gz: 8d9015ee4dd9eeda16ed953cec2804c7a8ba723cd346dc0e1da481c4c3a9683a
5
5
  SHA512:
6
- metadata.gz: 7444dc20090fc6547e1f55516fcb0a9d7d467ecfd6f0a9530c768ae7d6b735fa2ca789bdb7cc222277269fa4494a05c4187b2f3ffd28c74d82636ae09a197848
7
- data.tar.gz: 1c7af7dc5042410d6a11483356c41a24dacff39c240fb1cdce1950b859f47c2adb4a6293f93e0257ef5e5b482fb28b000d3660c6f2c705fe9b1925a3837e3837
6
+ metadata.gz: 6e10536c6514eaca3e02acec7e303dbf085b4cef96e9fceb1d48260571087d56589aec201e3996153f6bb3dfafd235a20dda099ac634c300492d18c831ebb50d
7
+ data.tar.gz: c92c35d0ab63b770d2abf29e24ca8b4f7ff3a59945594e55d30bbf0ec7843f39f0f734ed2ca01aa648b94df57e4e8302d50182c3934b71343f3acfb4d9124271
data/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.0] - 2026-06-26
6
+
7
+ ### Fixed
8
+
9
+ - **`:required` is now enforced on serialise as well as parse.** Previously
10
+ the flag raised `MissingAttributeError` only when an API response omitted
11
+ the field; it was silently ignored when sending (`save`), letting
12
+ incomplete payloads reach the backend and surface as a generic
13
+ `RequestError`. The flag now also fires at `serialise` time (and therefore
14
+ on `save`) when a required attribute is `nil`, before any HTTP request,
15
+ with the attribute name on the exception (`#attribute_name`). Inbound
16
+ behavior is unchanged — `parse` still raises on missing required fields.
17
+ A field is treated as missing whether the API response omits the key
18
+ entirely or includes it as explicit `null`; both are rejected as
19
+ incomplete data.
20
+ - **`:required` is now enforced on synthetic attributes (merge / combine /
21
+ split patterns).** Previously the flag was silently a no-op for any
22
+ attribute with a multi-parameter parse or serialise block. It now
23
+ enforces presence on all underlying API or model fields: a merge
24
+ attribute requires every source API field on parse; a combine attribute
25
+ requires every named model attribute on serialise; a split attribute
26
+ requires the underlying API field on parse. `:read_only` continues to
27
+ skip the serialise-time check.
28
+
29
+ ### Changed
30
+
31
+ - **The DSL auto-applies `:synthetic` to multi-parameter serialise blocks**
32
+ (and mapper `serialise` methods), mirroring the existing behavior for
33
+ multi-parameter parse. Previously only the parse side was tagged,
34
+ leaving the flag inconsistent with its intent of marking attributes whose
35
+ storage shape diverges from the standard one-slot layout.
36
+ - **Combine attributes no longer read from their `api_name` on parse.**
37
+ A combine attribute's API name does not correspond to a real inbound
38
+ field by design — the value is built from `target_fields` at serialise
39
+ time. The previous code looked up `api_data[api_name]`, stored it at
40
+ the model slot, and ran the standard `:required` check, raising
41
+ spuriously when the field was absent (the documented case). Inbound
42
+ values for the shadowed API key are now ignored and the model slot is
43
+ set to `nil`, avoiding the contradiction where `instance.address` could
44
+ return one value while serialise would overwrite it with another.
45
+
5
46
  ## [1.3.1] - 2026-05-27
6
47
 
7
48
  ### Fixed
@@ -82,7 +123,8 @@
82
123
 
83
124
  Initial release.
84
125
 
85
- [Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.3.1...HEAD
126
+ [Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.4.0...HEAD
127
+ [1.4.0]: https://github.com/accodeing/rest-easy/compare/v1.3.1...v1.4.0
86
128
  [1.3.1]: https://github.com/accodeing/rest-easy/compare/v1.3.0...v1.3.1
87
129
  [1.3.0]: https://github.com/accodeing/rest-easy/compare/v1.2.0...v1.3.0
88
130
  [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
@@ -250,6 +250,7 @@ module RestEasy
250
250
 
251
251
  serialise_params = serialise_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
252
252
  if serialise_params.length > 1
253
+ flags << :synthetic unless flags.include?(:synthetic)
253
254
  target_fields = serialise_params.map { |_, pname| pname }
254
255
  end
255
256
  elsif block
@@ -284,9 +285,22 @@ module RestEasy
284
285
  if serialise_block
285
286
  params = serialise_block.parameters.select { |ptype, _| ptype == :opt || ptype == :req }
286
287
  if params.length > 1
288
+ flags << :synthetic unless flags.include?(:synthetic)
287
289
  target_fields = params.map { |_, pname| pname }
288
290
  end
289
291
  end
292
+
293
+ # Combine pattern (multi-param serialise, no multi-param parse)
294
+ # has no inbound api_name on parse. If the user also wrote an
295
+ # explicit `parse` block, it will be silently ignored — warn so
296
+ # the inconsistency is visible at load time.
297
+ if target_fields.any? && source_fields.empty? && parse_block
298
+ warn "RestEasy: :#{attribute_model_name} declares a combine pattern " \
299
+ "(serialise from #{target_fields.inspect}) and also defines a parse block. " \
300
+ "Combine attributes have no inbound API field to read, so the parse block " \
301
+ "will not run. Remove the parse block, or restructure the declaration if you " \
302
+ "intended to read from the API."
303
+ end
290
304
  end
291
305
  end
292
306
 
@@ -591,13 +605,15 @@ module RestEasy
591
605
  # Serialise all attributes
592
606
  klass.all_attribute_definitions.each do |_model_name, attr_def|
593
607
  next if attr_def.read_only?
594
- value = @model_attributes[attr_def.model_name]
595
608
 
596
609
  if attr_def.target_fields.any?
597
610
  # Multi-param serialise: gather model values by param names, splat into block
598
611
  model_values = attr_def.target_fields.map { |fn| @model_attributes[fn] }
612
+ attr_def.validate_required!(*model_values)
599
613
  result[attr_def.api_name] = attr_def.serialise_value(*model_values)
600
614
  elsif attr_def.source_fields.any?
615
+ value = @model_attributes[attr_def.model_name]
616
+ attr_def.validate_required!(value)
601
617
  serialised = attr_def.serialise_value(value)
602
618
  if serialised.is_a?(::Array)
603
619
  # Array return: zip with source field API names
@@ -613,6 +629,8 @@ module RestEasy
613
629
  result[attr_def.api_name] = serialised
614
630
  end
615
631
  else
632
+ value = @model_attributes[attr_def.model_name]
633
+ attr_def.validate_required!(value)
616
634
  result[attr_def.api_name] = attr_def.serialise_value(value)
617
635
  end
618
636
  end
@@ -683,13 +701,19 @@ module RestEasy
683
701
  api_key = convention.serialise(field_name)
684
702
  api_data[api_key]
685
703
  end
704
+
705
+ attr_def.validate_required!(*raw_values)
706
+
686
707
  @model_attributes[model_name] = attr_def.parse_value(*raw_values)
708
+ elsif attr_def.combine?
709
+ # Combine pattern: the attribute's api_name does not exist on the
710
+ # API side by design — the value is built from target_fields at
711
+ # serialise time. Nothing inbound to read or validate.
712
+ @model_attributes[model_name] = nil
687
713
  else
688
714
  raw_value = api_data[attr_def.api_name]
689
715
 
690
- if raw_value.nil? && attr_def.required?
691
- raise MissingAttributeError.new(model_name)
692
- end
716
+ attr_def.validate_required!(raw_value)
693
717
 
694
718
  if raw_value.nil?
695
719
  @model_attributes[model_name] = nil
@@ -717,9 +741,12 @@ module RestEasy
717
741
  end
718
742
  end
719
743
 
720
- # Warn about declared attributes missing from the API response
744
+ # Warn about declared attributes missing from the API response.
745
+ # Combine attrs have no inbound api_name by design; non-combine
746
+ # required attrs already raised in the parse loop above.
721
747
  klass.all_attribute_definitions.each do |model_name, attr_def|
722
- next if attr_def.required? # already raises
748
+ next if attr_def.combine?
749
+ next if attr_def.required?
723
750
 
724
751
  api_keys_to_check = if attr_def.source_fields.any?
725
752
  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.0"
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.0
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-06-26 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: dry-types