verse-schema 1.0.0 → 1.1.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: b980154b603e6ac267ee886cbbbdf68914f98ce3a327ea15d1a1b0fb28d50f25
4
- data.tar.gz: 5d8b23b022cc019c28fb9b4c6576bcae32fa33d747b804ea721076f9198df230
3
+ metadata.gz: 63618ff4cf11b43ec35e8bb88adaffdf8d15ac3a49a461427fbdc3cd8908299a
4
+ data.tar.gz: 2ab22e1cd477e2b4f0e4cd070f50e2fd56e89b1c323795f4ca3cbfa1944e271d
5
5
  SHA512:
6
- metadata.gz: b246be324304a3c3d0b61323fcea9bcfa1abd4dbe496d9fa0915f4a8d421663a44560bdfebc068c0e8f51899a8fba375b2ce51276d20055626b9e9ffa90c8c3b
7
- data.tar.gz: 95e3babf47e78975c5e6c044e7544b2ae0bed61dd2b8b589eab4bce2ee9db2fb24cd3fdd72fc2921d07b531fbdeec7cb3bb51665c7a2719a04dd78cded49c616
6
+ metadata.gz: d7c5cb3e34919f47de38726ea146aa1d3d3ffe77b26d999c18da1d5e441ca06fcba4d8827c42d3b515ea7c7abd73177e7627352e1c2714ef82c0e2e196b9d01a
7
+ data.tar.gz: 4b60874afb6bcf4b7721396b9fe799b38b1b21fe4fb31c9b8fd467b835ded25ff72f8df8a2ff7bb4cddd1dca322937aa8ad1f879bcc13e7d9e012cf027cc4f63
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ ## 1.1
2
+
3
+ - Add `strict` mode to `validate` which will raise an error if the input has
4
+ extra fields (in case of schema with extra_fields: false)
5
+ - Fix issue with query params by allowing coercion of String `'null'` into `nil` for `NilClass` field type
6
+
7
+ ## 1.0
8
+
9
+ - First release of the Gem
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- verse-schema (1.0.0)
4
+ verse-schema (1.1.0)
5
5
  attr_chainable (~> 1.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -64,6 +64,8 @@ These examples are extracted directly from the gem's specs, ensuring they are ac
64
64
 
65
65
  - [Open Hash](#open-hash)
66
66
 
67
+ - [Strict Validation Mode](#strict-validation-mode)
68
+
67
69
 
68
70
  - [2. Complex Structures](#2-complex-structures)
69
71
 
@@ -118,6 +120,8 @@ These examples are extracted directly from the gem's specs, ensuring they are ac
118
120
 
119
121
  - [Complex Example](#complex-example)
120
122
 
123
+ - [Polymorphic Schema](#polymorphic-schema)
124
+
121
125
 
122
126
 
123
127
 
@@ -434,6 +438,54 @@ end
434
438
  ```
435
439
 
436
440
 
441
+ ### Strict Validation Mode
442
+
443
+
444
+ ```ruby
445
+ it "demonstrates strict validation for extra fields during validation" do
446
+ # By default, schemas ignore fields not defined in the schema unless `extra_fields` is used.
447
+ # You can enforce strict validation by passing `strict: true` to the `validate` method.
448
+ # This will cause validation to fail if extra fields are provided and the schema
449
+ # does not explicitly allow them via `extra_fields`.
450
+
451
+ # Please note: `strict` mode is propagated to the children schemas, when you have
452
+ # nested structures.
453
+
454
+ # Define a standard schema (extra_fields is false by default)
455
+ schema = Verse::Schema.define do
456
+ field(:name, String)
457
+ end
458
+
459
+ # Default validation (strict: false) ignores extra fields
460
+ result_default = schema.validate({ name: "John", age: 30 })
461
+ expect(result_default.success?).to be true
462
+ expect(result_default.value).to eq({ name: "John" }) # 'age' is ignored
463
+
464
+ # Strict validation (strict: true) fails with extra fields
465
+ result_strict_fail = schema.validate({ name: "John", age: 30 }, strict: true)
466
+ expect(result_strict_fail.success?).to be false
467
+ expect(result_strict_fail.errors).to eq({ age: ["is not allowed"] }) # Error on extra field 'age'
468
+
469
+ # Strict validation succeeds if no extra fields are provided
470
+ result_strict_ok = schema.validate({ name: "John" }, strict: true)
471
+ expect(result_strict_ok.success?).to be true
472
+ expect(result_strict_ok.value).to eq({ name: "John" })
473
+
474
+ # Now, define a schema that explicitly allows extra fields
475
+ schema_with_extra = Verse::Schema.define do
476
+ field(:name, String)
477
+ extra_fields # Explicitly allow extra fields
478
+ end
479
+
480
+ # Strict validation has no effect if `extra_fields` is enabled in the schema definition
481
+ result_strict_extra_ok = schema_with_extra.validate({ name: "John", age: 30 }, strict: true)
482
+ expect(result_strict_extra_ok.success?).to be true
483
+ expect(result_strict_extra_ok.value).to eq({ name: "John", age: 30 }) # Extra field 'age' is allowed and included
484
+ end
485
+
486
+ ```
487
+
488
+
437
489
 
438
490
  ## 2. Complex Structures
439
491
 
@@ -1584,7 +1636,142 @@ end
1584
1636
  ```
1585
1637
 
1586
1638
 
1639
+ ### Polymorphic Schema
1640
+
1641
+
1642
+ ```ruby
1643
+ it "demonstrates a polymorphic schema" do
1644
+ # Polymorphism without selector model can be achieved using a builder
1645
+ # virtual object which will convert the input schema to the correct
1646
+ # schema based on the type of the input.
1647
+ #
1648
+ # Here is an example on how to do:
1649
+ #
1650
+ # 1. Define the base schema for the polymorphic structure:
1651
+ base_schema = Verse::Schema.define do
1652
+ field(:type, Symbol)
1653
+ end
1654
+
1655
+ # 2. Define the specific schemas for each type:
1656
+ facebook_schema = Verse::Schema.define(base_schema) do
1657
+ field(:url, String)
1658
+ field(:title, String)
1659
+ end
1660
+
1661
+ google_schema = Verse::Schema.define(base_schema) do
1662
+ field(:search, String)
1663
+ field(:location, String)
1664
+ end
1665
+
1666
+ # 3. Define a builder schema. The best way to do this is to use the
1667
+ # scalar type:
1668
+ builder_schema = Verse::Schema.scalar(Hash).transform do |input, error_builder|
1669
+
1670
+ type = input[:type]
1671
+
1672
+ if type.respond_to?(:to_sym)
1673
+ type = type.to_sym
1674
+ else
1675
+ error_builder.add(:type, "invalid type")
1676
+ stop
1677
+ end
1678
+
1679
+ schema = case type
1680
+ when :facebook
1681
+ facebook_schema
1682
+ when :google
1683
+ google_schema
1684
+ else
1685
+ error_builder.add(:type, "invalid type")
1686
+ stop
1687
+ end
1688
+
1689
+ # Validate the input against the selected schema
1690
+ result = schema.validate(input, error_builder:)
1691
+
1692
+ result.value if result.success?
1693
+ end
1694
+
1695
+ # 4. Now, you can use the builder schema as placeholder for your
1696
+ # polymorphic schema:
1697
+ schema = Verse::Schema.define do
1698
+ field(:events, Array, of: builder_schema)
1699
+ end
1700
+
1701
+ # 5. Create a complex data structure to validate
1702
+ data = {
1703
+ events: [
1704
+ {
1705
+ type: "facebook",
1706
+ url: "https://facebook.com/event/123",
1707
+ title: "Facebook Event"
1708
+ },
1709
+ {
1710
+ type: "google",
1711
+ search: "conference 2023",
1712
+ location: "New York"
1713
+ }
1714
+ ]
1715
+ }
1716
+
1717
+ # 6. Validate the complex data
1718
+ result = schema.validate(data)
1719
+ # The validation succeeds
1720
+ expect(result.success?).to be true
1721
+ # The output maintains the structure with coerced values
1722
+ expect(result.value[:events][0][:type]).to eq(:facebook)
1723
+ expect(result.value[:events][0][:url]).to eq("https://facebook.com/event/123")
1724
+ expect(result.value[:events][0][:title]).to eq("Facebook Event")
1725
+
1726
+ expect(result.value[:events][1][:type]).to eq(:google)
1727
+ expect(result.value[:events][1][:search]).to eq("conference 2023")
1728
+ expect(result.value[:events][1][:location]).to eq("New York")
1729
+
1730
+ # 6.1 Invalid data
1731
+ invalid_data = {
1732
+ events: [
1733
+ {
1734
+ type: "facebook",
1735
+ # missing required url field
1736
+ title: "Facebook Event"
1737
+ },
1738
+ {
1739
+ type: "google",
1740
+ search: "conference 2023",
1741
+ # missing required location field
1742
+ },
1743
+ {
1744
+ type: "invalid",
1745
+ url: "https://invalid.com/event/123",
1746
+ title: "Invalid Event"
1747
+ }
1748
+ ]
1749
+ }
1750
+ # Validate the invalid data
1751
+ invalid_result = schema.validate(invalid_data)
1752
+ # The validation fails
1753
+ expect(invalid_result.success?).to be false
1754
+ # The errors are collected
1755
+ expect(invalid_result.errors).to eq({
1756
+ :"events.0.url" => ["is required"],
1757
+ :"events.1.location" => ["is required"],
1758
+ :"events.2.type" => ["invalid type"]
1759
+ })
1760
+ end
1761
+
1762
+ ```
1763
+
1764
+
1765
+
1766
+
1767
+ ## License
1768
+
1769
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1770
+
1771
+ ## Sponsorship
1587
1772
 
1773
+ This gem was made possible thanks to the support of [Ingedata](https://ingedata.ai).
1774
+ In Ingedata, we build expert teams to support and enhance delivery of your data projects.
1588
1775
 
1589
1776
  ## Contributing
1590
1777
 
@@ -61,7 +61,7 @@ module Verse
61
61
 
62
62
  def valid?(input) = validate(input).success?
63
63
 
64
- def validate(input, error_builder: nil, locals: {}) = raise NotImplementedError
64
+ def validate(input, error_builder: nil, locals: {}, strict: false) = raise NotImplementedError
65
65
 
66
66
  def new(arg)
67
67
  result = validate(arg)
@@ -107,7 +107,7 @@ module Verse
107
107
  end
108
108
 
109
109
  register(nil, NilClass) do |value|
110
- next nil if value.nil? || value == ""
110
+ next nil if value.nil? || value == "" || value == "null"
111
111
 
112
112
  raise Coalescer::Error, "must be nil"
113
113
  end
@@ -8,13 +8,9 @@ module Verse
8
8
  @mapping = {}
9
9
 
10
10
  DEFAULT_MAPPER = lambda do |type|
11
- if type == Base
12
- proc do |value, opts, locals:|
13
- opts[:schema].validate(value, locals:)
14
- end
15
- elsif type.is_a?(Base)
16
- proc do |value, _opts, locals:|
17
- type.validate(value, locals:)
11
+ if type.is_a?(Base)
12
+ proc do |value, _opts, locals:, strict:|
13
+ type.validate(value, locals:, strict:)
18
14
  end
19
15
  elsif type.is_a?(Class)
20
16
  proc do |value|
@@ -36,7 +32,7 @@ module Verse
36
32
  end
37
33
  end
38
34
 
39
- def transform(value, type, opts = {}, locals: {})
35
+ def transform(value, type, opts = {}, locals: {}, strict: false)
40
36
  if type.is_a?(Array)
41
37
  # fast-path for when the type match already
42
38
  type.each do |t|
@@ -52,7 +48,7 @@ module Verse
52
48
  type.each do |t|
53
49
  converted = @mapping.fetch(t) do
54
50
  DEFAULT_MAPPER.call(t)
55
- end.call(value, opts, locals:)
51
+ end.call(value, opts, locals:, strict:)
56
52
 
57
53
  if !converted.is_a?(Result) ||
58
54
  (converted.is_a?(Result) && converted.success?)
@@ -70,7 +66,7 @@ module Verse
70
66
  else
71
67
  @mapping.fetch(type) do
72
68
  DEFAULT_MAPPER.call(type)
73
- end.call(value, opts, locals:)
69
+ end.call(value, opts, locals:, strict:)
74
70
  end
75
71
  end
76
72
  end
@@ -21,7 +21,7 @@ module Verse
21
21
  @values = values
22
22
  end
23
23
 
24
- def validate(input, error_builder: nil, locals: {})
24
+ def validate(input, error_builder: nil, locals: {}, strict: false)
25
25
  locals = locals.dup # Ensure they are not modified
26
26
 
27
27
  error_builder = \
@@ -34,7 +34,7 @@ module Verse
34
34
  ErrorBuilder.new
35
35
  end
36
36
 
37
- validate_array(input, error_builder, locals)
37
+ validate_array(input, error_builder, locals, strict)
38
38
  end
39
39
 
40
40
  def dup
@@ -130,7 +130,7 @@ module Verse
130
130
 
131
131
  protected
132
132
 
133
- def validate_array(input, error_builder, locals)
133
+ def validate_array(input, error_builder, locals, strict)
134
134
  locals[:__path__] ||= []
135
135
 
136
136
  output = []
@@ -148,7 +148,8 @@ module Verse
148
148
  value,
149
149
  @values,
150
150
  @opts,
151
- locals:
151
+ locals:,
152
+ strict:
152
153
  )
153
154
 
154
155
  if coalesced_value.is_a?(Result)
@@ -17,7 +17,7 @@ module Verse
17
17
  @values = values
18
18
  end
19
19
 
20
- def validate(input, error_builder: nil, locals: {})
20
+ def validate(input, error_builder: nil, locals: {}, strict: false)
21
21
  locals = locals.dup # Ensure they are not modified
22
22
 
23
23
  error_builder = \
@@ -32,7 +32,7 @@ module Verse
32
32
 
33
33
  locals[:__path__] ||= []
34
34
 
35
- validate_dictionary(input, error_builder, locals)
35
+ validate_dictionary(input, error_builder, locals, strict)
36
36
  end
37
37
 
38
38
  def dup
@@ -117,7 +117,7 @@ module Verse
117
117
 
118
118
  protected
119
119
 
120
- def validate_dictionary(input, error_builder, locals)
120
+ def validate_dictionary(input, error_builder, locals, strict)
121
121
  output = {}
122
122
 
123
123
  unless input.is_a?(Hash)
@@ -134,7 +134,8 @@ module Verse
134
134
  value,
135
135
  @values,
136
136
  @opts,
137
- locals:
137
+ locals:,
138
+ strict:
138
139
  )
139
140
 
140
141
  if coalesced_value.is_a?(Result)
@@ -347,12 +347,12 @@ module Verse
347
347
  alias_method :<, :inherit?
348
348
 
349
349
  # :nodoc:
350
- def apply(value, output, error_builder, locals)
350
+ def apply(value, output, error_builder, locals, strict)
351
351
  locals[:__path__].push(@name)
352
352
 
353
353
  if @type.is_a?(Base)
354
354
  error_builder.context(@name) do |error_builder|
355
- result = @type.validate(value, error_builder:, locals:)
355
+ result = @type.validate(value, error_builder:, locals:, strict:)
356
356
 
357
357
  # Apply field-level post-processors to the result of the nested schema validation
358
358
  output[@name] = if @post_processors && error_builder.errors.empty?
@@ -365,7 +365,7 @@ module Verse
365
365
  end
366
366
  else
367
367
  coalesced_value =
368
- Coalescer.transform(value, @type, @opts, locals:)
368
+ Coalescer.transform(value, @type, @opts, locals:, strict:)
369
369
 
370
370
  if coalesced_value.is_a?(Result)
371
371
  error_builder.combine(@name, coalesced_value.errors)
@@ -21,7 +21,7 @@ module Verse
21
21
  @values = values
22
22
  end
23
23
 
24
- def validate(input, error_builder: nil, locals: {})
24
+ def validate(input, error_builder: nil, locals: {}, strict: false)
25
25
  locals = locals.dup # Ensure they are not modified
26
26
 
27
27
  error_builder = \
@@ -34,7 +34,7 @@ module Verse
34
34
  ErrorBuilder.new
35
35
  end
36
36
 
37
- validate_scalar(input, error_builder, locals)
37
+ validate_scalar(input, error_builder, locals, strict)
38
38
  end
39
39
 
40
40
  def dup
@@ -122,7 +122,7 @@ module Verse
122
122
 
123
123
  protected
124
124
 
125
- def validate_scalar(input, error_builder, locals)
125
+ def validate_scalar(input, error_builder, locals, strict)
126
126
  coalesced_value = nil
127
127
 
128
128
  begin
@@ -131,7 +131,8 @@ module Verse
131
131
  input,
132
132
  @values,
133
133
  nil,
134
- locals:
134
+ locals:,
135
+ strict:
135
136
  )
136
137
 
137
138
  if coalesced_value.is_a?(Result)
@@ -20,7 +20,7 @@ module Verse
20
20
  @values = values.transform_values{ |v| v.is_a?(Array) ? v : [v] }
21
21
  end
22
22
 
23
- def validate(input, error_builder: nil, locals: {})
23
+ def validate(input, error_builder: nil, locals: {}, strict: false)
24
24
  locals = locals.dup # Ensure they are not modified
25
25
 
26
26
  error_builder = \
@@ -35,7 +35,7 @@ module Verse
35
35
 
36
36
  locals[:__path__] ||= []
37
37
 
38
- validate_selector(input, error_builder, locals)
38
+ validate_selector(input, error_builder, locals, strict)
39
39
  end
40
40
 
41
41
  def dup
@@ -123,7 +123,7 @@ module Verse
123
123
 
124
124
  protected
125
125
 
126
- def validate_selector(input, error_builder, locals)
126
+ def validate_selector(input, error_builder, locals, strict)
127
127
  output = {}
128
128
 
129
129
  selector = locals.fetch(:selector) do
@@ -154,7 +154,8 @@ module Verse
154
154
  input,
155
155
  fetched_values,
156
156
  nil,
157
- locals:
157
+ locals:,
158
+ strict:
158
159
  )
159
160
 
160
161
  if coalesced_value.is_a?(Result)
@@ -69,7 +69,7 @@ module Verse
69
69
 
70
70
  def valid?(input) = validate(input).success?
71
71
 
72
- def validate(input, error_builder: nil, locals: {})
72
+ def validate(input, error_builder: nil, locals: {}, strict: false)
73
73
  error_builder = \
74
74
  case error_builder
75
75
  when String
@@ -87,7 +87,7 @@ module Verse
87
87
 
88
88
  locals = locals.dup # Ensure they are not modified
89
89
 
90
- validate_hash(input, error_builder, locals)
90
+ validate_hash(input, error_builder, locals, strict)
91
91
  end
92
92
 
93
93
  def dup
@@ -276,7 +276,7 @@ module Verse
276
276
 
277
277
  protected
278
278
 
279
- def validate_hash(input, error_builder, locals)
279
+ def validate_hash(input, error_builder, locals, strict)
280
280
  locals[:__path__] ||= []
281
281
 
282
282
  input = input.transform_keys(&:to_sym)
@@ -295,14 +295,25 @@ module Verse
295
295
  end
296
296
 
297
297
  if exists
298
- field.apply(value, output, error_builder, locals)
298
+ field.apply(value, output, error_builder, locals, strict)
299
299
  elsif field.default?
300
- field.apply(field.default, output, error_builder, locals)
300
+ field.apply(field.default, output, error_builder, locals, strict)
301
301
  elsif field.required?
302
302
  error_builder.add(field.key, "is required")
303
303
  end
304
304
  end
305
305
 
306
+ # If strict mode is enabled, check for extra fields
307
+ # that are not defined in the schema.
308
+ if !@extra_fields && strict
309
+ extra_keys = input.keys - @cache_field_name
310
+ if extra_keys.any?
311
+ extra_keys.each do |key|
312
+ error_builder.add(key, "is not allowed")
313
+ end
314
+ end
315
+ end
316
+
306
317
  if @post_processors && error_builder.errors.empty?
307
318
  output = @post_processors.call(output, nil, error_builder, **locals)
308
319
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Verse
4
4
  module Schema
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
@@ -68,6 +68,15 @@ These examples are extracted directly from the gem's specs, ensuring they are ac
68
68
  <% end %>
69
69
  <% end %>
70
70
 
71
+ ## License
72
+
73
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
74
+
75
+ ## Sponsorship
76
+
77
+ This gem was made possible thanks to the support of [Ingedata](https://ingedata.ai).
78
+ In Ingedata, we build expert teams to support and enhance delivery of your data projects.
79
+
71
80
  ## Contributing
72
81
 
73
82
  Bug reports and pull requests are welcome on GitHub at https://github.com/verse-rb/verse-schema.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verse-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yacine Petitprez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-04-16 00:00:00.000000000 Z
10
+ date: 2025-04-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: attr_chainable
@@ -33,6 +33,7 @@ files:
33
33
  - ".rspec"
34
34
  - ".rubocop-https---relaxed-ruby-style-rubocop-yml"
35
35
  - ".rubocop.yml"
36
+ - CHANGELOG.md
36
37
  - Gemfile
37
38
  - Gemfile.lock
38
39
  - README.md