konstruo 1.0.1 → 1.0.3
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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +249 -13
- data/Rakefile +7 -3
- data/lib/konstruo/mapper.rb +193 -50
- data/lib/konstruo/version.rb +1 -1
- data/lib/konstruo.rb +1 -1
- data/lib/tapioca/dsl/compilers/konstruo_mapper.rb +123 -0
- data/sorbet/rbi/annotations/actionpack.rbi +10 -10
- data/sorbet/rbi/annotations/activejob.rbi +2 -2
- data/sorbet/rbi/annotations/activerecord.rbi +11 -0
- data/sorbet/rbi/annotations/activesupport.rbi +42 -1
- data/sorbet/rbi/annotations/minitest.rbi +0 -3
- data/sorbet/rbi/annotations/railties.rbi +9 -2
- data/sorbet/rbi/dsl/active_support/callbacks.rbi +0 -2
- data/sorbet/rbi/dsl/address.rbi +20 -0
- data/sorbet/rbi/dsl/inheritance_base_mapper.rbi +14 -0
- data/sorbet/rbi/dsl/inheritance_child_mapper.rbi +20 -0
- data/sorbet/rbi/dsl/invalid_field_definition_mapper.rbi +8 -0
- data/sorbet/rbi/dsl/loose_person_mapper.rbi +14 -0
- data/sorbet/rbi/dsl/nullable_mapper.rbi +38 -0
- data/sorbet/rbi/dsl/person.rbi +62 -0
- data/sorbet/rbi/dsl/strict_person_mapper.rbi +14 -0
- data/sorbet/rbi/gems/action_text-trix@2.1.18.rbi +15 -0
- data/sorbet/rbi/gems/{actioncable@7.2.1.rbi → actioncable@8.1.3.rbi} +640 -573
- data/sorbet/rbi/gems/actionmailbox@8.1.3.rbi +1024 -0
- data/sorbet/rbi/gems/{actionmailer@7.2.1.rbi → actionmailer@8.1.3.rbi} +613 -439
- data/sorbet/rbi/gems/{actionpack@7.2.1.rbi → actionpack@8.1.3.rbi} +5304 -3864
- data/sorbet/rbi/gems/actiontext@8.1.3.rbi +1477 -0
- data/sorbet/rbi/gems/{actionview@7.2.1.rbi → actionview@8.1.3.rbi} +2853 -2572
- data/sorbet/rbi/gems/{activejob@7.2.1.rbi → activejob@8.1.3.rbi} +1187 -525
- data/sorbet/rbi/gems/{activemodel@7.2.1.rbi → activemodel@8.1.3.rbi} +1285 -840
- data/sorbet/rbi/gems/{activerecord@7.2.1.rbi → activerecord@8.1.3.rbi} +9711 -7801
- data/sorbet/rbi/gems/activestorage@8.1.3.rbi +2296 -0
- data/sorbet/rbi/gems/{activesupport@7.2.1.rbi → activesupport@8.1.3.rbi} +5045 -3293
- data/sorbet/rbi/gems/{ast@2.4.2.rbi → ast@2.4.3.rbi} +36 -35
- data/sorbet/rbi/gems/{base64@0.2.0.rbi → base64@0.3.0.rbi} +77 -41
- data/sorbet/rbi/gems/benchmark@0.5.0.rbi +637 -0
- data/sorbet/rbi/gems/bigdecimal@4.1.0.rbi +434 -0
- data/sorbet/rbi/gems/bundler-audit@0.9.3.rbi +317 -0
- data/sorbet/rbi/gems/{concurrent-ruby@1.3.4.rbi → concurrent-ruby@1.3.6.rbi} +1711 -1602
- data/sorbet/rbi/gems/date@3.5.1.rbi +403 -0
- data/sorbet/rbi/gems/{drb@2.2.1.rbi → drb@2.2.3.rbi} +518 -204
- data/sorbet/rbi/gems/erb@6.0.2.rbi +813 -0
- data/sorbet/rbi/gems/{erubi@1.13.0.rbi → erubi@1.13.1.rbi} +29 -22
- data/sorbet/rbi/gems/{globalid@1.2.1.rbi → globalid@1.3.0.rbi} +133 -127
- data/sorbet/rbi/gems/{i18n@1.14.5.rbi → i18n@1.14.8.rbi} +437 -413
- data/sorbet/rbi/gems/{json@2.7.2.rbi → json@2.19.3.rbi} +949 -260
- data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +9 -0
- data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +323 -0
- data/sorbet/rbi/gems/{logger@1.6.1.rbi → logger@1.7.0.rbi} +120 -77
- data/sorbet/rbi/gems/{loofah@2.22.0.rbi → loofah@2.25.1.rbi} +206 -168
- data/sorbet/rbi/gems/{mail@2.8.1.rbi → mail@2.9.0.rbi} +1748 -1539
- data/sorbet/rbi/gems/{marcel@1.0.4.rbi → marcel@1.1.0.rbi} +42 -42
- data/sorbet/rbi/gems/{minitest@5.25.1.rbi → minitest@6.0.2.rbi} +751 -332
- data/sorbet/rbi/gems/{net-imap@0.4.16.rbi → net-imap@0.6.3.rbi} +5227 -2026
- data/sorbet/rbi/gems/{net-smtp@0.5.0.rbi → net-smtp@0.5.1.rbi} +148 -136
- data/sorbet/rbi/gems/{nio4r@2.7.3.rbi → nio4r@2.7.5.rbi} +112 -5
- data/sorbet/rbi/gems/{nokogiri@1.16.7.rbi → nokogiri@1.19.2.rbi} +2579 -1339
- data/sorbet/rbi/gems/{parallel@1.26.3.rbi → parallel@1.27.0.rbi} +72 -72
- data/sorbet/rbi/gems/{parser@3.3.5.0.rbi → parser@3.3.11.1.rbi} +1112 -1094
- data/sorbet/rbi/gems/pp@0.6.3.rbi +388 -0
- data/sorbet/rbi/gems/prettyprint@0.2.0.rbi +477 -0
- data/sorbet/rbi/gems/{prism@1.0.0.rbi → prism@1.9.0.rbi} +14859 -6935
- data/sorbet/rbi/gems/{psych@5.1.2.rbi → psych@5.3.1.rbi} +1054 -267
- data/sorbet/rbi/gems/{rack-session@2.0.0.rbi → rack-session@2.1.1.rbi} +145 -150
- data/sorbet/rbi/gems/{rack-test@2.1.0.rbi → rack-test@2.2.0.rbi} +141 -159
- data/sorbet/rbi/gems/{rack@3.1.7.rbi → rack@3.2.5.rbi} +1254 -1055
- data/sorbet/rbi/gems/rackup@2.3.1.rbi +230 -0
- data/sorbet/rbi/gems/{rails-dom-testing@2.2.0.rbi → rails-dom-testing@2.3.0.rbi} +160 -128
- data/sorbet/rbi/gems/{rails-html-sanitizer@1.6.0.rbi → rails-html-sanitizer@1.7.0.rbi} +118 -253
- data/sorbet/rbi/gems/{railties@7.2.1.rbi → railties@8.1.3.rbi} +1066 -748
- data/sorbet/rbi/gems/{rake@13.2.1.rbi → rake@13.3.1.rbi} +626 -633
- data/sorbet/rbi/gems/{rbi@0.2.0.rbi → rbi@0.3.9.rbi} +2202 -1069
- data/sorbet/rbi/gems/rbs@4.0.2.rbi +8624 -0
- data/sorbet/rbi/gems/{rdoc@6.7.0.rbi → rdoc@7.2.0.rbi} +3634 -2987
- data/sorbet/rbi/gems/regexp_parser@2.11.3.rbi +3883 -0
- data/sorbet/rbi/gems/require-hooks@0.2.3.rbi +110 -0
- data/sorbet/rbi/gems/rexml@3.4.4.rbi +5258 -0
- data/sorbet/rbi/gems/{rubocop-ast@1.32.3.rbi → rubocop-ast@1.49.1.rbi} +2003 -1852
- data/sorbet/rbi/gems/{rubocop-rake@0.6.0.rbi → rubocop-rake@0.7.1.rbi} +68 -69
- data/sorbet/rbi/gems/{rubocop@1.66.1.rbi → rubocop@1.86.0.rbi} +19926 -11885
- data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
- data/sorbet/rbi/gems/{spoom@1.4.2.rbi → spoom@1.7.11.rbi} +2079 -1133
- data/sorbet/rbi/gems/standard-custom@1.0.2.rbi +9 -0
- data/sorbet/rbi/gems/standard-performance@1.9.0.rbi +9 -0
- data/sorbet/rbi/gems/standard-rails@1.6.0.rbi +9 -0
- data/sorbet/rbi/gems/standard-sorbet@0.0.3.rbi +9 -0
- data/sorbet/rbi/gems/standard@1.35.0.1.rbi +9 -0
- data/sorbet/rbi/gems/{tapioca@0.16.2.rbi → tapioca@0.18.0.rbi} +1121 -1154
- data/sorbet/rbi/gems/{thor@1.3.2.rbi → thor@1.5.0.rbi} +747 -649
- data/sorbet/rbi/gems/timeout@0.6.1.rbi +193 -0
- data/sorbet/rbi/gems/tsort@0.2.0.rbi +393 -0
- data/sorbet/rbi/gems/unicode-display_width@3.2.0.rbi +132 -0
- data/sorbet/rbi/gems/unicode-emoji@4.2.0.rbi +254 -0
- data/sorbet/rbi/gems/uri@1.1.1.rbi +2407 -0
- data/sorbet/rbi/gems/websocket-driver@0.8.0.rbi +1069 -0
- data/sorbet/rbi/gems/{yard@0.9.37.rbi → yard@0.9.38.rbi} +2980 -3032
- data/sorbet/rbi/gems/zeitwerk@2.7.5.rbi +1232 -0
- data/sorbet/rbi/shims/bundler.rbi +7 -0
- data/sorbet/rbi/shims/erb.rbi +4 -0
- data/sorbet/rbi/shims/set.rbi +7 -0
- data/sorbet/tapioca/require.rb +5 -1
- metadata +110 -88
- data/.rspec +0 -3
- data/sorbet/rbi/gems/actionmailbox@7.2.1.rbi +0 -1832
- data/sorbet/rbi/gems/actiontext@7.2.1.rbi +0 -1697
- data/sorbet/rbi/gems/activestorage@7.2.1.rbi +0 -3247
- data/sorbet/rbi/gems/bigdecimal@3.1.8.rbi +0 -78
- data/sorbet/rbi/gems/date@3.3.4.rbi +0 -75
- data/sorbet/rbi/gems/diff-lcs@1.5.1.rbi +0 -1131
- data/sorbet/rbi/gems/language_server-protocol@3.17.0.3.rbi +0 -14238
- data/sorbet/rbi/gems/rackup@2.1.0.rbi +0 -390
- data/sorbet/rbi/gems/regexp_parser@2.9.2.rbi +0 -3772
- data/sorbet/rbi/gems/rspec-core@3.13.1.rbi +0 -11012
- data/sorbet/rbi/gems/rspec-expectations@3.13.3.rbi +0 -8183
- data/sorbet/rbi/gems/rspec-mocks@3.13.1.rbi +0 -5341
- data/sorbet/rbi/gems/rspec-support@3.13.1.rbi +0 -1630
- data/sorbet/rbi/gems/rspec@3.13.0.rbi +0 -83
- data/sorbet/rbi/gems/securerandom@0.3.1.rbi +0 -396
- data/sorbet/rbi/gems/timeout@0.4.1.rbi +0 -149
- data/sorbet/rbi/gems/unicode-display_width@2.5.0.rbi +0 -66
- data/sorbet/rbi/gems/webrick@1.8.1.rbi +0 -2607
- data/sorbet/rbi/gems/websocket-driver@0.7.6.rbi +0 -993
- data/sorbet/rbi/gems/zeitwerk@2.6.18.rbi +0 -1051
- /data/sorbet/rbi/gems/{connection_pool@2.4.1.rbi → connection_pool@3.0.2.rbi} +0 -0
- /data/sorbet/rbi/gems/{dashbrains-rubocop-config@1.0.4.rbi → dashbrains-rubocop-config@1.0.8.rbi} +0 -0
- /data/sorbet/rbi/gems/{io-console@0.7.2.rbi → io-console@0.8.2.rbi} +0 -0
- /data/sorbet/rbi/gems/{rails@7.2.1.rbi → rails@8.1.3.rbi} +0 -0
- /data/sorbet/rbi/gems/{reline@0.5.10.rbi → reline@0.6.3.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-capybara@2.21.0.rbi → rubocop-capybara@2.22.1.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-factory_bot@2.26.1.rbi → rubocop-factory_bot@2.28.0.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-graphql@1.5.4.rbi → rubocop-graphql@1.6.0.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-minitest@0.36.0.rbi → rubocop-minitest@0.39.1.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-performance@1.21.1.rbi → rubocop-performance@1.26.1.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-rails@2.26.1.rbi → rubocop-rails@2.34.3.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-rspec@3.0.5.rbi → rubocop-rspec@3.9.0.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-rspec_rails@2.30.0.rbi → rubocop-rspec_rails@2.32.0.rbi} +0 -0
- /data/sorbet/rbi/gems/{rubocop-sorbet@0.8.5.rbi → rubocop-sorbet@0.9.0.rbi} +0 -0
- /data/sorbet/rbi/gems/{stringio@3.1.1.rbi → stringio@3.2.0.rbi} +0 -0
- /data/sorbet/rbi/gems/{useragent@0.16.10.rbi → useragent@0.16.11.rbi} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54c20c9b4279be4b434685e57738654da55fbb75fe21e5bc8d4286bdd292aaf4
|
|
4
|
+
data.tar.gz: e924f4facbf95273bc16585e994c455877d28723763c05ce223e38e05a451ece
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c04a03b2187dc910e08e38fa4a27619d9b0d935a7fb518af14061c8780773c768b3dc435194c3aa14abc60ff94afca2bcdc448e25c2ae14bf0d0fcf052fe484d
|
|
7
|
+
data.tar.gz: 0f03affbf375f28e6fdaa2b7b17638df508cdd7d384631130677ba2e6de57e8b49fd81be59c476974d236391974f40956ea5498386ab0ea16d25ab1a9026ded6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.3](https://github.com/DashBrains/konstruo/compare/v1.0.2...v1.0.3) (2026-04-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add nilable field ([c3422ea](https://github.com/DashBrains/konstruo/commit/c3422ea68611223f77bfcdee2b6497a302bfc975))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Miscellaneous Chores
|
|
12
|
+
|
|
13
|
+
* **release:** 1.0.3 ([86ae552](https://github.com/DashBrains/konstruo/commit/86ae552f0aff66bbdbdda20a06e549f5e38bff51))
|
|
14
|
+
|
|
15
|
+
## [1.0.2](https://github.com/DashBrains/konstruo/compare/v1.0.1...v1.0.2) (2026-03-31)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* dependencies upgrade ([99cf328](https://github.com/DashBrains/konstruo/commit/99cf328777114dbdec699f0855f1028a1cf65c5a))
|
|
21
|
+
* harden mapper validation ([c79db34](https://github.com/DashBrains/konstruo/commit/c79db343a1aeebb7566ca24b23760a0d71fd8808))
|
|
22
|
+
* improve parsing and logic ([6ee7acd](https://github.com/DashBrains/konstruo/commit/6ee7acd6556203e21cadb0a0773319fd5429bc9a))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Miscellaneous Chores
|
|
26
|
+
|
|
27
|
+
* **release:** force version bump ([7380066](https://github.com/DashBrains/konstruo/commit/7380066927747bef07ea2e557155c5e89e35ff93))
|
|
28
|
+
* **release:** force version bump ([41378dc](https://github.com/DashBrains/konstruo/commit/41378dcf047cb5621d685e6764a7d617e547a7f5))
|
|
29
|
+
|
|
3
30
|
## [1.0.1](https://github.com/DashBrains/konstruo/compare/v1.0.0...v1.0.1) (2024-09-07)
|
|
4
31
|
|
|
5
32
|
|
data/README.md
CHANGED
|
@@ -1,35 +1,271 @@
|
|
|
1
1
|
# Konstruo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Konstruo maps JSON, hashes, and Rails params into typed Ruby objects with:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Required field validation
|
|
6
|
+
- Runtime type checks
|
|
7
|
+
- Custom key mapping (for example `userId` -> `user_id`)
|
|
8
|
+
- Value transformation hooks (mappers)
|
|
9
|
+
- Nested object support (including arrays of nested objects)
|
|
10
|
+
- Inherited field definitions for mapper subclasses
|
|
11
|
+
- Optional strict mode to reject unknown input keys
|
|
6
12
|
|
|
7
13
|
## Installation
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
Add to your `Gemfile`:
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
```ruby
|
|
18
|
+
gem 'konstruo'
|
|
19
|
+
```
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Then install:
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
```bash
|
|
24
|
+
bundle install
|
|
25
|
+
```
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
Or install directly:
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
```bash
|
|
30
|
+
gem install konstruo
|
|
31
|
+
```
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require 'konstruo'
|
|
37
|
+
|
|
38
|
+
class Address < Konstruo::Mapper
|
|
39
|
+
field :street, String, required: true
|
|
40
|
+
field :city, String, required: true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Person < Konstruo::Mapper
|
|
44
|
+
field :name, String, required: true
|
|
45
|
+
field :age, Integer
|
|
46
|
+
field :is_active, Konstruo::Boolean, required: true
|
|
47
|
+
field :address, Address, required: true
|
|
48
|
+
field :tags, [String]
|
|
49
|
+
|
|
50
|
+
# Map external keys to ruby-style attribute names
|
|
51
|
+
field :user_id, Integer, required: true, custom_name: 'userId'
|
|
52
|
+
|
|
53
|
+
# Transform value before type validation/assignment
|
|
54
|
+
field :signup_date, Date, custom_name: 'signupDate', mapper: ->(v) { Date.parse(v) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
json = <<~JSON
|
|
58
|
+
{
|
|
59
|
+
"name": "John Doe",
|
|
60
|
+
"age": 30,
|
|
61
|
+
"is_active": true,
|
|
62
|
+
"address": { "street": "123 Main St", "city": "New York" },
|
|
63
|
+
"tags": ["ruby", "rails"],
|
|
64
|
+
"userId": 42,
|
|
65
|
+
"signupDate": "2023-08-31"
|
|
66
|
+
}
|
|
67
|
+
JSON
|
|
68
|
+
|
|
69
|
+
person = Person.from_json(json)
|
|
70
|
+
person.name # => "John Doe"
|
|
71
|
+
person.user_id # => 42
|
|
72
|
+
person.signup_date # => #<Date: 2023-08-31 ...>
|
|
73
|
+
person.address.city # => "New York"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Inheritance
|
|
77
|
+
|
|
78
|
+
Mapper fields are inherited by subclasses:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class BasePayload < Konstruo::Mapper
|
|
82
|
+
field :request_id, String, required: true, custom_name: 'requestId'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class CreateUserPayload < BasePayload
|
|
86
|
+
field :name, String, required: true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
payload = CreateUserPayload.from_hash(requestId: 'abc-123', name: 'Jane')
|
|
90
|
+
payload.request_id # => "abc-123"
|
|
91
|
+
payload.name # => "Jane"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Defining Fields
|
|
95
|
+
|
|
96
|
+
Field API:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
field(name, type, required: false, nullable: nil, custom_name: nil, mapper: nil, error_message: nil)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Options:
|
|
103
|
+
|
|
104
|
+
- `name` (`Symbol`): Ruby attribute name.
|
|
105
|
+
- `type` (`Class` or `[Class]`): Expected value type.
|
|
106
|
+
- `required` (`Boolean`): Raises `Konstruo::ValidationError` when missing.
|
|
107
|
+
- `nullable` (`Boolean`, optional): Controls whether a present key can have `nil` value.
|
|
108
|
+
- `custom_name` (`String`): External key name to read from input.
|
|
109
|
+
- `mapper` (`Proc`): Converts raw input value before assignment.
|
|
110
|
+
- `error_message` (`String`): Custom validation error message.
|
|
111
|
+
|
|
112
|
+
Supported type patterns:
|
|
113
|
+
|
|
114
|
+
- Primitive/class values: `String`, `Integer`, `Date`, etc.
|
|
115
|
+
- Boolean: `Konstruo::Boolean`
|
|
116
|
+
- Nested mapper: any subclass of `Konstruo::Mapper`
|
|
117
|
+
- Array of primitives: `[String]`, `[Integer]`, etc.
|
|
118
|
+
- Array of nested mappers: `[Address]`
|
|
119
|
+
|
|
120
|
+
Array type declarations must contain exactly one element class (for example `[String]`).
|
|
121
|
+
|
|
122
|
+
`nullable` default behavior:
|
|
123
|
+
|
|
124
|
+
- If `required: true` and `nullable` is omitted, `nullable` defaults to `false`.
|
|
125
|
+
- If `required: false` and `nullable` is omitted, `nullable` defaults to `true`.
|
|
126
|
+
|
|
127
|
+
## Parsing Input
|
|
128
|
+
|
|
129
|
+
Konstruo supports three entry points:
|
|
130
|
+
|
|
131
|
+
- `YourMapper.from_json(json_string)`
|
|
132
|
+
- `YourMapper.from_hash(hash)`
|
|
133
|
+
- `YourMapper.from_params(action_controller_params)`
|
|
134
|
+
|
|
135
|
+
All return an instance of your mapper class.
|
|
136
|
+
|
|
137
|
+
Notes:
|
|
138
|
+
|
|
139
|
+
- `from_hash` accepts string or symbol keys.
|
|
140
|
+
- `from_json` expects a JSON object at the root and raises `Konstruo::ValidationError` otherwise.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
person = Person.from_hash(
|
|
144
|
+
name: 'Jane',
|
|
145
|
+
is_active: true,
|
|
146
|
+
address: { street: '42 Broadway', city: 'NYC' },
|
|
147
|
+
userId: 7
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Validation Behavior
|
|
152
|
+
|
|
153
|
+
### Required fields
|
|
154
|
+
|
|
155
|
+
Missing required fields raise:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
Konstruo::ValidationError
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Default message format:
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
Missing required field: field_name
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Nullability
|
|
168
|
+
|
|
169
|
+
When a key is present with `nil` value:
|
|
170
|
+
|
|
171
|
+
- `nullable: true` allows it.
|
|
172
|
+
- `nullable: false` raises:
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
Field cannot be nil: field_name
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Type errors
|
|
179
|
+
|
|
180
|
+
Type mismatches raise `Konstruo::ValidationError` with details like:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
Expected Integer for field: age, got String
|
|
184
|
+
Expected String for field: friends[0], got Integer
|
|
185
|
+
Expected Boolean for field: is_active, got String
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`Konstruo::Boolean` only accepts real booleans (`true` or `false`).
|
|
189
|
+
|
|
190
|
+
### Nested error paths
|
|
191
|
+
|
|
192
|
+
Errors coming from nested mappers are prefixed with their full path:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
address: Street is required.
|
|
196
|
+
addresses[0]: Street is required.
|
|
197
|
+
Missing required field: address.city
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Strict unknown key mode
|
|
201
|
+
|
|
202
|
+
Enable strict mode in a mapper to reject keys that are not declared with `field`:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
class StrictPerson < Konstruo::Mapper
|
|
206
|
+
strict_unknown_keys
|
|
207
|
+
field :name, String, required: true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
StrictPerson.from_hash(name: 'Jane', extra: 'value')
|
|
211
|
+
# => raises Konstruo::ValidationError: Unknown fields: extra
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
By default, unknown keys are ignored.
|
|
215
|
+
Strict mode is inherited by subclasses.
|
|
216
|
+
You can explicitly disable it with `strict_unknown_keys(false)`.
|
|
217
|
+
|
|
218
|
+
### Custom mappers
|
|
219
|
+
|
|
220
|
+
Mapper lambdas are executed as provided. If they raise (for example `Date.parse`), that error bubbles up.
|
|
221
|
+
|
|
222
|
+
### Custom error messages
|
|
223
|
+
|
|
224
|
+
`error_message:` is used for both missing required fields and type errors on that field.
|
|
225
|
+
|
|
226
|
+
## Sorbet Integration
|
|
227
|
+
|
|
228
|
+
`field` is defined dynamically at runtime, so static typing for generated accessors comes from Tapioca DSL RBIs.
|
|
229
|
+
|
|
230
|
+
Konstruo exports a Tapioca DSL compiler from the gem path `tapioca/dsl/compilers`, so consumer apps pick it up automatically when Konstruo is in the bundle.
|
|
231
|
+
|
|
232
|
+
Run:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
bundle exec tapioca dsl
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The compiler generates typed accessors for mapper fields, so you do not need to manually write repeated `sig + attr_accessor` declarations for each field.
|
|
239
|
+
|
|
240
|
+
## Rails Params Support
|
|
241
|
+
|
|
242
|
+
Use `from_params` when parsing `ActionController::Parameters`:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
def create
|
|
246
|
+
person = Person.from_params(params.require(:person).permit!)
|
|
247
|
+
# ...
|
|
248
|
+
end
|
|
249
|
+
```
|
|
22
250
|
|
|
23
251
|
## Development
|
|
24
252
|
|
|
25
|
-
|
|
253
|
+
```bash
|
|
254
|
+
bin/setup
|
|
255
|
+
bundle exec rake test
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Useful commands:
|
|
26
259
|
|
|
27
|
-
|
|
260
|
+
- `bin/console` for interactive experimentation
|
|
261
|
+
- `bundle exec rake install` to install the gem locally
|
|
262
|
+
- `bundle exec rake release` to tag and publish
|
|
28
263
|
|
|
29
264
|
## Contributing
|
|
30
265
|
|
|
31
|
-
|
|
266
|
+
Issues and pull requests are welcome:
|
|
267
|
+
https://github.com/DashBrains/konstruo
|
|
32
268
|
|
|
33
269
|
## License
|
|
34
270
|
|
|
35
|
-
|
|
271
|
+
MIT: [LICENSE.txt](LICENSE.txt)
|
data/Rakefile
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'bundler/gem_tasks'
|
|
4
|
-
require '
|
|
4
|
+
require 'rake/testtask'
|
|
5
5
|
|
|
6
6
|
Dir['tasks/**/*.rake'].each { |t| load t }
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Rake::TestTask.new(:test) do |t|
|
|
9
|
+
t.libs << 'lib'
|
|
10
|
+
t.libs << 'test'
|
|
11
|
+
t.pattern = 'test/**/*_test.rb'
|
|
12
|
+
end
|
|
9
13
|
|
|
10
|
-
task default: :
|
|
14
|
+
task default: :test
|
data/lib/konstruo/mapper.rb
CHANGED
|
@@ -8,91 +8,214 @@ module Konstruo
|
|
|
8
8
|
class Mapper
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
FieldClass = T.type_alias { T.class_of(Object) }
|
|
12
|
+
FieldType = T.type_alias { T.any(FieldClass, T::Array[FieldClass]) }
|
|
13
|
+
InputHash = T.type_alias { T::Hash[T.any(String, Symbol), T.untyped] }
|
|
14
|
+
|
|
15
|
+
class FieldDefinition < T::Struct
|
|
16
|
+
const :name, Symbol
|
|
17
|
+
const :type, FieldType
|
|
18
|
+
const :required, T::Boolean
|
|
19
|
+
const :nullable, T::Boolean
|
|
20
|
+
const :custom_name, String
|
|
21
|
+
const :mapper, T.nilable(T.proc.params(value: T.untyped).returns(T.untyped))
|
|
22
|
+
const :error_message, T.nilable(String)
|
|
23
|
+
end
|
|
13
24
|
|
|
14
25
|
class << self
|
|
15
26
|
extend T::Sig
|
|
16
27
|
|
|
17
|
-
sig { returns(T::Array[
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
sig { returns(T::Array[FieldDefinition]) }
|
|
29
|
+
def fields
|
|
30
|
+
existing = T.let(T.unsafe(self).instance_variable_get(:@fields), T.nilable(T::Array[FieldDefinition]))
|
|
31
|
+
return existing unless existing.nil?
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
end
|
|
26
|
-
def self.field(name, type, required: false, custom_name: nil, mapper: nil, error_message: nil)
|
|
27
|
-
# Check if the attribute is already defined
|
|
28
|
-
attr_accessor name unless method_defined?(name)
|
|
33
|
+
initialized = T.let([], T::Array[FieldDefinition])
|
|
34
|
+
T.unsafe(self).instance_variable_set(:@fields, initialized)
|
|
35
|
+
initialized
|
|
36
|
+
end
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
sig { returns(T::Array[T.class_of(::Konstruo::Mapper)]) }
|
|
39
|
+
def descendants
|
|
40
|
+
existing = T.let(
|
|
41
|
+
T.unsafe(self).instance_variable_get(:@descendants),
|
|
42
|
+
T.nilable(T::Array[T.class_of(::Konstruo::Mapper)])
|
|
43
|
+
)
|
|
44
|
+
return existing unless existing.nil?
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
46
|
+
initialized = T.let([], T::Array[T.class_of(::Konstruo::Mapper)])
|
|
47
|
+
T.unsafe(self).instance_variable_set(:@descendants, initialized)
|
|
48
|
+
initialized
|
|
49
|
+
end
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
sig do
|
|
52
|
+
params(
|
|
53
|
+
name: Symbol,
|
|
54
|
+
type: FieldType,
|
|
55
|
+
required: T::Boolean,
|
|
56
|
+
nullable: T.nilable(T::Boolean),
|
|
57
|
+
custom_name: T.nilable(String),
|
|
58
|
+
mapper: T.nilable(T.proc.params(value: T.untyped).returns(T.untyped)),
|
|
59
|
+
error_message: T.nilable(String)
|
|
60
|
+
).void
|
|
61
|
+
end
|
|
62
|
+
def field(name, type, required: false, nullable: nil, custom_name: nil, mapper: nil, error_message: nil)
|
|
63
|
+
attr_accessor name unless method_defined?(name)
|
|
64
|
+
|
|
65
|
+
validate_field_type!(type)
|
|
66
|
+
resolved_nullable = nullable.nil? ? !required : nullable
|
|
67
|
+
|
|
68
|
+
fields << FieldDefinition.new(
|
|
69
|
+
name: name,
|
|
70
|
+
type: type,
|
|
71
|
+
required: required,
|
|
72
|
+
nullable: resolved_nullable,
|
|
73
|
+
custom_name: custom_name || name.to_s,
|
|
74
|
+
mapper: mapper,
|
|
75
|
+
error_message: error_message
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { params(value: T::Boolean).void }
|
|
80
|
+
def strict_unknown_keys(value = true)
|
|
81
|
+
T.unsafe(self).instance_variable_set(:@strict_unknown_keys, value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { returns(T::Boolean) }
|
|
85
|
+
def strict_unknown_keys?
|
|
86
|
+
value = T.let(T.unsafe(self).instance_variable_get(:@strict_unknown_keys), T.nilable(T::Boolean))
|
|
87
|
+
value == true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
sig { params(subclass: T.class_of(Konstruo::Mapper)).void }
|
|
91
|
+
def inherited(subclass)
|
|
92
|
+
super
|
|
93
|
+
subclass.instance_variable_set(:@fields, fields.dup)
|
|
94
|
+
subclass.instance_variable_set(:@strict_unknown_keys, strict_unknown_keys?)
|
|
95
|
+
::Konstruo::Mapper.send(:register_descendant, subclass)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { params(json_string: String).returns(T.attached_class) }
|
|
99
|
+
def from_json(json_string)
|
|
100
|
+
parsed = JSON.parse(json_string)
|
|
101
|
+
raise Konstruo::ValidationError, 'Expected JSON object at root' unless parsed.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
new.from_hash(parsed)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sig { params(params: ActionController::Parameters).returns(T.attached_class) }
|
|
107
|
+
def from_params(params)
|
|
108
|
+
new.from_hash(params.to_unsafe_h)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { params(hash: InputHash).returns(T.attached_class) }
|
|
112
|
+
def from_hash(hash)
|
|
113
|
+
new.from_hash(hash)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
sig { params(type: FieldType).void }
|
|
119
|
+
def validate_field_type!(type)
|
|
120
|
+
return unless type.is_a?(Array)
|
|
121
|
+
return if type.size == 1 && type.first.is_a?(Class)
|
|
45
122
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
123
|
+
raise ArgumentError, 'Array field type must contain exactly one class'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
sig { params(subclass: T.class_of(::Konstruo::Mapper)).void }
|
|
127
|
+
def register_descendant(subclass)
|
|
128
|
+
root = ::Konstruo::Mapper
|
|
129
|
+
list = root.descendants
|
|
130
|
+
list << subclass unless list.include?(subclass)
|
|
131
|
+
end
|
|
49
132
|
end
|
|
50
133
|
|
|
51
|
-
sig { params(hash:
|
|
134
|
+
sig { params(hash: InputHash).returns(T.self_type) }
|
|
52
135
|
def from_hash(hash)
|
|
136
|
+
validate_unknown_keys!(hash) if self.class.strict_unknown_keys?
|
|
137
|
+
|
|
53
138
|
self.class.fields.each do |field|
|
|
54
|
-
key = field
|
|
55
|
-
|
|
139
|
+
key = field.custom_name
|
|
140
|
+
symbol_key = key.to_sym
|
|
141
|
+
has_string_key = hash.key?(key)
|
|
142
|
+
has_symbol_key = hash.key?(symbol_key)
|
|
143
|
+
|
|
144
|
+
unless has_string_key || has_symbol_key
|
|
145
|
+
raise Konstruo::ValidationError, (field.error_message || "Missing required field: #{key}") if field.required
|
|
146
|
+
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
value = has_string_key ? hash[key] : hash[symbol_key]
|
|
56
151
|
|
|
57
152
|
if value.nil?
|
|
58
|
-
raise Konstruo::ValidationError, (field
|
|
153
|
+
raise Konstruo::ValidationError, (field.error_message || "Field cannot be nil: #{key}") unless field.nullable
|
|
154
|
+
|
|
155
|
+
send(:"#{field.name}=", nil)
|
|
59
156
|
else
|
|
60
|
-
assign_value(field
|
|
157
|
+
assign_value(field.name, field.type, value, field.mapper, field.error_message)
|
|
61
158
|
end
|
|
62
159
|
end
|
|
160
|
+
|
|
63
161
|
self
|
|
64
162
|
end
|
|
65
163
|
|
|
66
164
|
private
|
|
67
165
|
|
|
166
|
+
sig { params(hash: InputHash).void }
|
|
167
|
+
def validate_unknown_keys!(hash)
|
|
168
|
+
allowed_keys = Set.new(self.class.fields.map(&:custom_name))
|
|
169
|
+
unknown_keys = hash.keys.map(&:to_s).reject { |key| allowed_keys.include?(key) }.uniq.sort
|
|
170
|
+
return if unknown_keys.empty?
|
|
171
|
+
|
|
172
|
+
raise Konstruo::ValidationError, "Unknown fields: #{unknown_keys.join(", ")}"
|
|
173
|
+
end
|
|
174
|
+
|
|
68
175
|
sig do
|
|
69
|
-
params(
|
|
70
|
-
|
|
176
|
+
params(
|
|
177
|
+
field_name: Symbol,
|
|
178
|
+
field_type: FieldType,
|
|
179
|
+
value: T.untyped,
|
|
180
|
+
mapper: T.nilable(T.proc.params(value: T.untyped).returns(T.untyped)),
|
|
181
|
+
error_message: T.nilable(String)
|
|
182
|
+
).void
|
|
71
183
|
end
|
|
72
184
|
def assign_value(field_name, field_type, value, mapper = nil, error_message = nil)
|
|
73
185
|
value = mapper.call(value) if mapper
|
|
74
186
|
|
|
75
187
|
if field_type.is_a?(Array)
|
|
76
|
-
# Check if the value is an array
|
|
77
188
|
raise Konstruo::ValidationError, (error_message || "Expected Array for field: #{field_name}, got #{value.class}") unless value.is_a?(Array)
|
|
78
189
|
|
|
79
|
-
|
|
80
|
-
element_type = field_type.first
|
|
190
|
+
element_type = T.must(field_type.first)
|
|
81
191
|
|
|
82
192
|
validated_array = value.map.with_index do |element, index|
|
|
83
|
-
if
|
|
84
|
-
|
|
85
|
-
|
|
193
|
+
if element_type < Konstruo::Mapper
|
|
194
|
+
unless element.is_a?(Hash)
|
|
195
|
+
raise Konstruo::ValidationError, (error_message || "Expected Hash for field: #{field_name}[#{index}], got #{element.class}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
begin
|
|
199
|
+
element_type.new.from_hash(element)
|
|
200
|
+
rescue Konstruo::ValidationError => e
|
|
201
|
+
raise Konstruo::ValidationError, prefix_nested_error(e.message, "#{field_name}[#{index}]")
|
|
202
|
+
end
|
|
86
203
|
else
|
|
87
|
-
|
|
88
|
-
validate_type!(element, T.must(element_type), "#{field_name}[#{index}]", error_message)
|
|
204
|
+
validate_type!(element, element_type, "#{field_name}[#{index}]", error_message)
|
|
89
205
|
element
|
|
90
206
|
end
|
|
91
207
|
end
|
|
92
208
|
|
|
93
209
|
send(:"#{field_name}=", validated_array)
|
|
94
210
|
elsif field_type < Konstruo::Mapper
|
|
95
|
-
|
|
211
|
+
raise Konstruo::ValidationError, (error_message || "Expected Hash for field: #{field_name}, got #{value.class}") unless value.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
mapped_value = begin
|
|
214
|
+
field_type.new.from_hash(value)
|
|
215
|
+
rescue Konstruo::ValidationError => e
|
|
216
|
+
raise Konstruo::ValidationError, prefix_nested_error(e.message, field_name.to_s)
|
|
217
|
+
end
|
|
218
|
+
send(:"#{field_name}=", mapped_value)
|
|
96
219
|
else
|
|
97
220
|
validate_type!(value, field_type, field_name, error_message)
|
|
98
221
|
send(:"#{field_name}=", value)
|
|
@@ -102,19 +225,39 @@ module Konstruo
|
|
|
102
225
|
sig do
|
|
103
226
|
params(
|
|
104
227
|
value: T.untyped,
|
|
105
|
-
expected_type:
|
|
228
|
+
expected_type: FieldClass,
|
|
106
229
|
field_name: T.any(Symbol, String),
|
|
107
230
|
error_message: T.nilable(String)
|
|
108
231
|
).void
|
|
109
232
|
end
|
|
110
233
|
def validate_type!(value, expected_type, field_name, error_message = nil)
|
|
111
|
-
# Custom handling for Boolean type
|
|
112
234
|
if expected_type == Konstruo::Boolean
|
|
113
|
-
|
|
235
|
+
unless Konstruo::Boolean.boolean?(value)
|
|
236
|
+
raise Konstruo::ValidationError, (error_message || "Expected Boolean for field: #{field_name}, got #{value.class}")
|
|
237
|
+
end
|
|
114
238
|
else
|
|
115
|
-
|
|
116
|
-
|
|
239
|
+
unless value.is_a?(expected_type)
|
|
240
|
+
raise Konstruo::ValidationError, (error_message || "Expected #{expected_type} for field: #{field_name}, got #{value.class}")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
sig { params(message: String, prefix: String).returns(String) }
|
|
246
|
+
def prefix_nested_error(message, prefix)
|
|
247
|
+
missing_field_match = message.match(/\AMissing required field: (.+)\z/)
|
|
248
|
+
if missing_field_match
|
|
249
|
+
return "Missing required field: #{prefix}.#{T.must(missing_field_match[1])}"
|
|
117
250
|
end
|
|
251
|
+
|
|
252
|
+
type_error_match = message.match(/\AExpected (.+) for field: (.+), got (.+)\z/)
|
|
253
|
+
if type_error_match
|
|
254
|
+
expected = T.must(type_error_match[1])
|
|
255
|
+
field_path = T.must(type_error_match[2])
|
|
256
|
+
actual = T.must(type_error_match[3])
|
|
257
|
+
return "Expected #{expected} for field: #{prefix}.#{field_path}, got #{actual}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
"#{prefix}: #{message}"
|
|
118
261
|
end
|
|
119
262
|
end
|
|
120
263
|
end
|
data/lib/konstruo/version.rb
CHANGED
data/lib/konstruo.rb
CHANGED