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