philiprehberger-schema_validator 0.2.2 → 0.3.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: 55525c7f988aea6bd1d007871569d66a11b8b79f328d85b33244b86f4e5b66c5
4
- data.tar.gz: 8d7c5f754c183e5b55c82d2560d2c1af39427ca20a73fd0d639ecb047166cedd
3
+ metadata.gz: 5d514d5f95607aabc375a5890cce18f6c7db4832830f280dff18409c745a04cc
4
+ data.tar.gz: 75b430ee553c1cd579de30d76d4ffdceec7a3ce733b016edbee8457d8ec17484
5
5
  SHA512:
6
- metadata.gz: 21c96da7a71f69d774eafe5c3e6bfd05aec1303643ed7c615a5083bf1cd73067b82a484a4efa5b71b52c253e87e499fac6d3368c2268d93e885b8dec8cf59683
7
- data.tar.gz: 4e4f524265420757023b069ec79cfc091b71f6e097627e6905bfc003d19a74883051bb04330273818de3524986d8b52b09fde5a39b765ee6afd1efced00f1be4
6
+ metadata.gz: 30d7cbc99c870665fbc4267df522c2f9d8e111224cbf830a169a62b3071d975aaf7c8c25c9c3f7307ec5e6e29161a22cfb85dca4f79944f43f87f41216965b9a
7
+ data.tar.gz: 3731586da1bd4fbc31de7cd699c5279bb8effc6b39cfd627910f1f9a2d72428604b1191a14fb8b173c4ea898e7ba96700b4db8c5408893aad05afa8ec8db1a4f
data/CHANGELOG.md CHANGED
@@ -1,23 +1,44 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.2
3
+ All notable changes to this gem will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+ n## [0.3.1] - 2026-03-22
10
+
11
+ ### Changed
12
+ - Remove extra Supported Types section from README for template compliance
13
+
14
+ ## [0.3.1] - 2026-03-21
15
+
16
+ ### Fixed
17
+ - Standardize Installation section in README
18
+
19
+ ## [0.3.0] - 2026-03-17
4
20
 
21
+ ### Added
22
+ - Nested schema validation via `nested` DSL method with recursive error prefixing
23
+ - Array element validation with `of:` option for type-checked elements
24
+ - Array of objects validation with `schema:` option
25
+ - Built-in format presets: `:email`, `:url`, `:uuid`, `:iso8601`, `:phone`
26
+ - Cross-field validation via `validate` block at schema level
27
+ - Schema composition via `merge` method to combine and extend schemas
28
+
29
+ ## [0.2.2] - 2026-03-16
30
+
31
+ ### Fixed
5
32
  - Fix rubocop: string interpolation style, ParameterLists cop
6
33
 
7
- ## 0.2.1
34
+ ## [0.2.1] - 2026-03-16
8
35
 
9
- - Add License badge to README
36
+ ### Changed
37
+ - Remove extra Supported Types section from README for template compliance
10
38
  - Add bug_tracker_uri to gemspec
11
39
  - Add Development section to README
12
40
  - Add Requirements section to README
13
41
 
14
- All notable changes to this gem will be documented in this file.
15
-
16
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
17
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
18
-
19
- ## [Unreleased]
20
-
21
42
  ## [0.2.0] - 2026-03-13
22
43
 
23
44
  ### Added
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
- # Philiprehberger::SchemaValidator
1
+ # philiprehberger-schema_validator
2
2
 
3
- Lightweight schema validation for Ruby hashes with type checking and coercion.
3
+ [![Tests](https://github.com/philiprehberger/rb-schema-validator/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-schema-validator/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-schema_validator.svg)](https://rubygems.org/gems/philiprehberger-schema_validator)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-schema-validator)](LICENSE)
6
+
7
+ Lightweight schema validation for Ruby hashes with type checking and coercion
4
8
 
5
9
  ## Requirements
6
10
 
@@ -8,7 +12,7 @@ Lightweight schema validation for Ruby hashes with type checking and coercion.
8
12
 
9
13
  ## Installation
10
14
 
11
- Add this line to your Gemfile:
15
+ Add to your Gemfile:
12
16
 
13
17
  ```ruby
14
18
  gem "philiprehberger-schema_validator"
@@ -80,6 +84,32 @@ schema = Philiprehberger::SchemaValidator.define do
80
84
  end
81
85
  ```
82
86
 
87
+ #### Format Presets
88
+
89
+ Use built-in format presets instead of writing regex patterns:
90
+
91
+ ```ruby
92
+ schema = Philiprehberger::SchemaValidator.define do
93
+ string :email, format: :email
94
+ string :website, format: :url
95
+ string :id, format: :uuid
96
+ string :created_at, format: :iso8601
97
+ string :phone, format: :phone
98
+ end
99
+ ```
100
+
101
+ Available presets:
102
+
103
+ | Preset | Matches |
104
+ |------------|--------------------------------------------------|
105
+ | `:email` | Basic email format (`user@domain.tld`) |
106
+ | `:url` | HTTP/HTTPS URLs |
107
+ | `:uuid` | UUID v4 format |
108
+ | `:iso8601` | ISO 8601 date and datetime |
109
+ | `:phone` | International phone numbers |
110
+
111
+ Both symbol presets and `Regexp` values are supported for `format:`.
112
+
83
113
  #### `in:` — Allowlist Validation
84
114
 
85
115
  ```ruby
@@ -97,6 +127,104 @@ schema = Philiprehberger::SchemaValidator.define do
97
127
  end
98
128
  ```
99
129
 
130
+ ### Nested Schema Validation
131
+
132
+ Validate objects within objects using the `nested` DSL method:
133
+
134
+ ```ruby
135
+ schema = Philiprehberger::SchemaValidator.define do
136
+ string :name, required: true
137
+ nested :address, required: true do
138
+ string :city, required: true
139
+ string :zip, required: true
140
+ end
141
+ end
142
+
143
+ result = schema.validate({ name: "Alice", address: { city: "Vienna" } })
144
+ result.errors # => ["address.zip is required"]
145
+ ```
146
+
147
+ Nested schemas support all the same field types and options. Nesting can go arbitrarily deep:
148
+
149
+ ```ruby
150
+ schema = Philiprehberger::SchemaValidator.define do
151
+ nested :config, required: true do
152
+ nested :database, required: true do
153
+ string :host, required: true
154
+ integer :port, required: true
155
+ end
156
+ end
157
+ end
158
+ ```
159
+
160
+ ### Array Element Validation
161
+
162
+ Validate the type of each element in an array with `of:`:
163
+
164
+ ```ruby
165
+ schema = Philiprehberger::SchemaValidator.define do
166
+ array :tags, of: :string
167
+ array :scores, of: :integer
168
+ end
169
+
170
+ result = schema.validate({ tags: ["a", "b"], scores: [1, "bad", 3] })
171
+ result.errors # => ["scores[1] must be a integer"]
172
+ ```
173
+
174
+ Validate arrays of objects with `schema:`:
175
+
176
+ ```ruby
177
+ address_schema = Philiprehberger::SchemaValidator.define do
178
+ string :city, required: true
179
+ string :zip, required: true
180
+ end
181
+
182
+ schema = Philiprehberger::SchemaValidator.define do
183
+ array :addresses, schema: address_schema
184
+ end
185
+
186
+ result = schema.validate({ addresses: [{ city: "Vienna" }] })
187
+ result.errors # => ["addresses[0].zip is required"]
188
+ ```
189
+
190
+ ### Cross-Field Validation
191
+
192
+ Add schema-level validation blocks for relational checks between fields:
193
+
194
+ ```ruby
195
+ schema = Philiprehberger::SchemaValidator.define do
196
+ integer :min_age, required: false
197
+ integer :max_age, required: false
198
+
199
+ validate do |data, errors|
200
+ if data[:min_age] && data[:max_age] && data[:min_age] > data[:max_age]
201
+ errors << "min_age must be less than or equal to max_age"
202
+ end
203
+ end
204
+ end
205
+ ```
206
+
207
+ Multiple `validate` blocks can be defined on the same schema.
208
+
209
+ ### Schema Composition
210
+
211
+ Combine and extend schemas using `merge`:
212
+
213
+ ```ruby
214
+ base = Philiprehberger::SchemaValidator.define do
215
+ string :name, required: true
216
+ end
217
+
218
+ extended = base.merge do
219
+ integer :age, required: false
220
+ string :email, required: true
221
+ end
222
+
223
+ extended.fields # => [:name, :age, :email]
224
+ ```
225
+
226
+ The original schema is not modified. Nested schemas and cross-field validators are preserved in the merged schema.
227
+
100
228
  ### Schema Introspection
101
229
 
102
230
  ```ruby
@@ -119,17 +247,6 @@ schema = Philiprehberger::SchemaValidator.define do
119
247
  end
120
248
  ```
121
249
 
122
- ## Supported Types
123
-
124
- | DSL Method | Ruby Type | Coerces From |
125
- |-------------|-----------|-------------------|
126
- | `string` | String | any (via `to_s`) |
127
- | `integer` | Integer | String |
128
- | `float` | Float | String |
129
- | `boolean` | Boolean | String (true/false/yes/no/1/0/on/off) |
130
- | `array` | Array | - |
131
- | `hash_field`| Hash | - |
132
-
133
250
  ## API
134
251
 
135
252
  ### `Philiprehberger::SchemaValidator.define(&block)` -> `Schema`
@@ -148,6 +265,10 @@ Validates a hash against the schema. Returns a `Result` object.
148
265
 
149
266
  Validates and raises `ValidationError` if invalid.
150
267
 
268
+ ### `Schema#merge(&block)` -> `Schema`
269
+
270
+ Creates a new schema combining the current schema with additional definitions from the block.
271
+
151
272
  ### `Result#valid?` -> `Boolean`
152
273
 
153
274
  Returns `true` if there are no errors.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module SchemaValidator
5
+ module Constraints
6
+ private
7
+
8
+ def check_type(field, value, errors, field_label)
9
+ coerced = Coercer.coerce(value, field.type)
10
+ if coerced.nil?
11
+ errors << "#{field_label} must be #{field.type}"
12
+ else
13
+ check_constraints(field, coerced, errors, field_label)
14
+ end
15
+ end
16
+
17
+ def check_constraints(field, value, errors, field_label)
18
+ check_format(field, value, errors, field_label)
19
+ check_inclusion(field, value, errors, field_label)
20
+ check_range(field, value, errors, field_label)
21
+ check_array_elements(field, value, errors, field_label)
22
+ run_custom_validator(field, value, errors, field_label)
23
+ end
24
+
25
+ def check_format(field, value, errors, field_label)
26
+ return unless field.format
27
+
28
+ pattern = resolve_format(field.format)
29
+ errors << "#{field_label} does not match expected format" unless value.match?(pattern)
30
+ end
31
+
32
+ def resolve_format(fmt)
33
+ return fmt if fmt.is_a?(Regexp)
34
+
35
+ Formats::FORMATS.fetch(fmt) do
36
+ raise ArgumentError, "unknown format preset: #{fmt}"
37
+ end
38
+ end
39
+
40
+ def check_inclusion(field, value, errors, field_label)
41
+ return unless field.in
42
+
43
+ errors << "#{field_label} must be one of: #{field.in.join(', ')}" unless field.in.include?(value)
44
+ end
45
+
46
+ def check_range(field, value, errors, field_label)
47
+ errors << "#{field_label} must be >= #{field.min}" if field.min && value < field.min
48
+ errors << "#{field_label} must be <= #{field.max}" if field.max && value > field.max
49
+ end
50
+
51
+ def check_array_elements(field, value, errors, field_label)
52
+ return unless field.type == :array && value.is_a?(Array)
53
+
54
+ if field.of
55
+ validate_array_of_type(field, value, errors, field_label)
56
+ elsif field.schema
57
+ validate_array_of_schema(field, value, errors, field_label)
58
+ end
59
+ end
60
+
61
+ def validate_array_of_type(field, value, errors, field_label)
62
+ value.each_with_index do |elem, idx|
63
+ coerced = Coercer.coerce(elem, field.of)
64
+ errors << "#{field_label}[#{idx}] must be a #{field.of}" if coerced.nil?
65
+ end
66
+ end
67
+
68
+ def validate_array_of_schema(field, value, errors, field_label)
69
+ value.each_with_index do |elem, idx|
70
+ unless elem.is_a?(Hash)
71
+ errors << "#{field_label}[#{idx}] must be a hash"
72
+ next
73
+ end
74
+
75
+ field.schema.validate(elem).errors.each do |err|
76
+ errors << "#{field_label}[#{idx}].#{err}"
77
+ end
78
+ end
79
+ end
80
+
81
+ def run_custom_validator(field, value, errors, field_label)
82
+ return unless field.validator
83
+
84
+ msg = field.validator.call(value)
85
+ errors << "#{field_label} #{msg}" if msg.is_a?(String)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -3,22 +3,34 @@
3
3
  module Philiprehberger
4
4
  module SchemaValidator
5
5
  class Field
6
- attr_reader :name, :type, :default, :validator, :format, :in, :min, :max
6
+ attr_reader :name, :type, :default, :validator, :format, :in, :min, :max, :of, :schema
7
7
 
8
- def initialize(name, type, required: true, default: nil, format: nil, in: nil, min: nil, max: nil, &validator) # rubocop:disable Metrics/ParameterLists
8
+ def initialize(name, type, required: true, default: nil, format: nil, in: nil, min: nil, max: nil, of: nil, schema: nil, &validator) # rubocop:disable Metrics/ParameterLists,Layout/LineLength
9
+ assign_basic_attrs(name, type, required, default, format)
10
+ assign_constraint_attrs(binding.local_variable_get(:in), min, max, of, schema)
11
+ @validator = validator
12
+ end
13
+
14
+ def required?
15
+ @required
16
+ end
17
+
18
+ private
19
+
20
+ def assign_basic_attrs(name, type, required, default, format)
9
21
  @name = name
10
22
  @type = type
11
23
  @required = required
12
24
  @default = default
13
25
  @format = format
14
- @in = binding.local_variable_get(:in)
15
- @min = min
16
- @max = max
17
- @validator = validator
18
26
  end
19
27
 
20
- def required?
21
- @required
28
+ def assign_constraint_attrs(inclusion, min, max, of, schema)
29
+ @in = inclusion
30
+ @min = min
31
+ @max = max
32
+ @of = of
33
+ @schema = schema
22
34
  end
23
35
  end
24
36
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module SchemaValidator
5
+ module Formats
6
+ FORMATS = {
7
+ email: /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/,
8
+ url: %r{\Ahttps?://[^\s]+\z},
9
+ uuid: /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i,
10
+ iso8601: /\A\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(.\d+)?(Z|[+-]\d{2}:\d{2})?)?\z/,
11
+ phone: /\A\+?[0-9\s\-().]{7,}\z/
12
+ }.freeze
13
+ end
14
+ end
15
+ end
@@ -5,10 +5,14 @@ module Philiprehberger
5
5
  class ValidationError < StandardError; end
6
6
 
7
7
  class Schema
8
+ include Constraints
9
+
8
10
  TYPES = %i[string integer float boolean array hash].freeze
9
11
 
10
12
  def initialize(&block)
11
13
  @fields = {}
14
+ @nested_schemas = {}
15
+ @cross_validators = []
12
16
  instance_eval(&block) if block
13
17
  end
14
18
 
@@ -19,13 +23,21 @@ module Philiprehberger
19
23
  end
20
24
  end
21
25
 
22
- def fields
23
- @fields.keys
26
+ def nested(name, **opts, &)
27
+ sub_schema = Schema.new(&)
28
+ @nested_schemas[name] = { schema: sub_schema, required: opts.fetch(:required, false) }
24
29
  end
25
30
 
26
- def validate(data)
31
+ def validate(data = nil, &block)
32
+ if block
33
+ @cross_validators << block
34
+ return
35
+ end
36
+
27
37
  errors = []
28
38
  @fields.each_value { |field| validate_field(field, data, errors) }
39
+ @nested_schemas.each { |name, config| validate_nested(name, config, data, errors) }
40
+ @cross_validators.each { |cv| cv.call(data, errors) }
29
41
  Result.new(errors: errors)
30
42
  end
31
43
 
@@ -36,13 +48,31 @@ module Philiprehberger
36
48
  result
37
49
  end
38
50
 
51
+ def fields
52
+ @fields.keys
53
+ end
54
+
55
+ def merge(&block)
56
+ new_schema = Schema.new
57
+ new_schema.instance_variable_set(:@fields, @fields.dup)
58
+ new_schema.instance_variable_set(:@nested_schemas, @nested_schemas.dup)
59
+ new_schema.instance_variable_set(:@cross_validators, @cross_validators.dup)
60
+ new_schema.instance_eval(&block) if block
61
+ new_schema
62
+ end
63
+
39
64
  private
40
65
 
41
66
  def validate_field(field, data, errors)
42
67
  value = fetch_value(field, data)
43
- return check_required(field, errors) if value.nil?
68
+ field_label = field.name.to_s
44
69
 
45
- check_type(field, value, errors)
70
+ if value.nil?
71
+ errors << "#{field_label} is required" if field.required?
72
+ return
73
+ end
74
+
75
+ check_type(field, value, errors, field_label)
46
76
  end
47
77
 
48
78
  def fetch_value(field, data)
@@ -51,48 +81,21 @@ module Philiprehberger
51
81
  field.default
52
82
  end
53
83
 
54
- def check_required(field, errors)
55
- errors << "#{field.name} is required" if field.required?
56
- end
84
+ def validate_nested(name, config, data, errors)
85
+ value = data[name]
57
86
 
58
- def check_type(field, value, errors)
59
- coerced = Coercer.coerce(value, field.type)
60
- if coerced.nil?
61
- errors << "#{field.name} must be #{field.type}"
62
- else
63
- check_constraints(field, coerced, errors)
87
+ if value.nil?
88
+ errors << "#{name} is required" if config[:required]
89
+ return
64
90
  end
65
- end
66
91
 
67
- def check_constraints(field, value, errors)
68
- check_format(field, value, errors)
69
- check_inclusion(field, value, errors)
70
- check_range(field, value, errors)
71
- run_custom_validator(field, value, errors)
72
- end
73
-
74
- def check_format(field, value, errors)
75
- return unless field.format
92
+ return errors << "#{name} must be a hash" unless value.is_a?(Hash)
76
93
 
77
- errors << "#{field.name} does not match expected format" unless value.match?(field.format)
94
+ collect_nested_errors(name, config[:schema], value, errors)
78
95
  end
79
96
 
80
- def check_inclusion(field, value, errors)
81
- return unless field.in
82
-
83
- errors << "#{field.name} must be one of: #{field.in.join(', ')}" unless field.in.include?(value)
84
- end
85
-
86
- def check_range(field, value, errors)
87
- errors << "#{field.name} must be >= #{field.min}" if field.min && value < field.min
88
- errors << "#{field.name} must be <= #{field.max}" if field.max && value > field.max
89
- end
90
-
91
- def run_custom_validator(field, value, errors)
92
- return unless field.validator
93
-
94
- msg = field.validator.call(value)
95
- errors << "#{field.name} #{msg}" if msg.is_a?(String)
97
+ def collect_nested_errors(name, schema, value, errors)
98
+ schema.validate(value).errors.each { |err| errors << "#{name}.#{err}" }
96
99
  end
97
100
  end
98
101
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module SchemaValidator
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
@@ -4,6 +4,8 @@ require_relative "schema_validator/version"
4
4
  require_relative "schema_validator/field"
5
5
  require_relative "schema_validator/result"
6
6
  require_relative "schema_validator/coercer"
7
+ require_relative "schema_validator/formats"
8
+ require_relative "schema_validator/constraints"
7
9
  require_relative "schema_validator/schema"
8
10
 
9
11
  module Philiprehberger
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-schema_validator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-16 00:00:00.000000000 Z
11
+ date: 2026-03-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A zero-dependency Ruby gem for validating hash data against schemas with
14
14
  type checking, coercion, required/optional fields, and custom validators.
@@ -23,7 +23,9 @@ files:
23
23
  - README.md
24
24
  - lib/philiprehberger/schema_validator.rb
25
25
  - lib/philiprehberger/schema_validator/coercer.rb
26
+ - lib/philiprehberger/schema_validator/constraints.rb
26
27
  - lib/philiprehberger/schema_validator/field.rb
28
+ - lib/philiprehberger/schema_validator/formats.rb
27
29
  - lib/philiprehberger/schema_validator/result.rb
28
30
  - lib/philiprehberger/schema_validator/schema.rb
29
31
  - lib/philiprehberger/schema_validator/version.rb
@@ -35,6 +37,7 @@ metadata:
35
37
  source_code_uri: https://github.com/philiprehberger/rb-schema-validator
36
38
  changelog_uri: https://github.com/philiprehberger/rb-schema-validator/blob/main/CHANGELOG.md
37
39
  rubygems_mfa_required: 'true'
40
+ bug_tracker_uri: https://github.com/philiprehberger/rb-schema-validator/issues
38
41
  post_install_message:
39
42
  rdoc_options: []
40
43
  require_paths:
@@ -43,7 +46,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
43
46
  requirements:
44
47
  - - ">="
45
48
  - !ruby/object:Gem::Version
46
- version: '3.1'
49
+ version: 3.1.0
47
50
  required_rubygems_version: !ruby/object:Gem::Requirement
48
51
  requirements:
49
52
  - - ">="