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.
Files changed (141) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +249 -13
  4. data/Rakefile +7 -3
  5. data/lib/konstruo/mapper.rb +193 -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/nullable_mapper.rbi +38 -0
  22. data/sorbet/rbi/dsl/person.rbi +62 -0
  23. data/sorbet/rbi/dsl/strict_person_mapper.rbi +14 -0
  24. data/sorbet/rbi/gems/action_text-trix@2.1.18.rbi +15 -0
  25. data/sorbet/rbi/gems/{actioncable@7.2.1.rbi → actioncable@8.1.3.rbi} +640 -573
  26. data/sorbet/rbi/gems/actionmailbox@8.1.3.rbi +1024 -0
  27. data/sorbet/rbi/gems/{actionmailer@7.2.1.rbi → actionmailer@8.1.3.rbi} +613 -439
  28. data/sorbet/rbi/gems/{actionpack@7.2.1.rbi → actionpack@8.1.3.rbi} +5304 -3864
  29. data/sorbet/rbi/gems/actiontext@8.1.3.rbi +1477 -0
  30. data/sorbet/rbi/gems/{actionview@7.2.1.rbi → actionview@8.1.3.rbi} +2853 -2572
  31. data/sorbet/rbi/gems/{activejob@7.2.1.rbi → activejob@8.1.3.rbi} +1187 -525
  32. data/sorbet/rbi/gems/{activemodel@7.2.1.rbi → activemodel@8.1.3.rbi} +1285 -840
  33. data/sorbet/rbi/gems/{activerecord@7.2.1.rbi → activerecord@8.1.3.rbi} +9711 -7801
  34. data/sorbet/rbi/gems/activestorage@8.1.3.rbi +2296 -0
  35. data/sorbet/rbi/gems/{activesupport@7.2.1.rbi → activesupport@8.1.3.rbi} +5045 -3293
  36. data/sorbet/rbi/gems/{ast@2.4.2.rbi → ast@2.4.3.rbi} +36 -35
  37. data/sorbet/rbi/gems/{base64@0.2.0.rbi → base64@0.3.0.rbi} +77 -41
  38. data/sorbet/rbi/gems/benchmark@0.5.0.rbi +637 -0
  39. data/sorbet/rbi/gems/bigdecimal@4.1.0.rbi +434 -0
  40. data/sorbet/rbi/gems/bundler-audit@0.9.3.rbi +317 -0
  41. data/sorbet/rbi/gems/{concurrent-ruby@1.3.4.rbi → concurrent-ruby@1.3.6.rbi} +1711 -1602
  42. data/sorbet/rbi/gems/date@3.5.1.rbi +403 -0
  43. data/sorbet/rbi/gems/{drb@2.2.1.rbi → drb@2.2.3.rbi} +518 -204
  44. data/sorbet/rbi/gems/erb@6.0.2.rbi +813 -0
  45. data/sorbet/rbi/gems/{erubi@1.13.0.rbi → erubi@1.13.1.rbi} +29 -22
  46. data/sorbet/rbi/gems/{globalid@1.2.1.rbi → globalid@1.3.0.rbi} +133 -127
  47. data/sorbet/rbi/gems/{i18n@1.14.5.rbi → i18n@1.14.8.rbi} +437 -413
  48. data/sorbet/rbi/gems/{json@2.7.2.rbi → json@2.19.3.rbi} +949 -260
  49. data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +9 -0
  50. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +323 -0
  51. data/sorbet/rbi/gems/{logger@1.6.1.rbi → logger@1.7.0.rbi} +120 -77
  52. data/sorbet/rbi/gems/{loofah@2.22.0.rbi → loofah@2.25.1.rbi} +206 -168
  53. data/sorbet/rbi/gems/{mail@2.8.1.rbi → mail@2.9.0.rbi} +1748 -1539
  54. data/sorbet/rbi/gems/{marcel@1.0.4.rbi → marcel@1.1.0.rbi} +42 -42
  55. data/sorbet/rbi/gems/{minitest@5.25.1.rbi → minitest@6.0.2.rbi} +751 -332
  56. data/sorbet/rbi/gems/{net-imap@0.4.16.rbi → net-imap@0.6.3.rbi} +5227 -2026
  57. data/sorbet/rbi/gems/{net-smtp@0.5.0.rbi → net-smtp@0.5.1.rbi} +148 -136
  58. data/sorbet/rbi/gems/{nio4r@2.7.3.rbi → nio4r@2.7.5.rbi} +112 -5
  59. data/sorbet/rbi/gems/{nokogiri@1.16.7.rbi → nokogiri@1.19.2.rbi} +2579 -1339
  60. data/sorbet/rbi/gems/{parallel@1.26.3.rbi → parallel@1.27.0.rbi} +72 -72
  61. data/sorbet/rbi/gems/{parser@3.3.5.0.rbi → parser@3.3.11.1.rbi} +1112 -1094
  62. data/sorbet/rbi/gems/pp@0.6.3.rbi +388 -0
  63. data/sorbet/rbi/gems/prettyprint@0.2.0.rbi +477 -0
  64. data/sorbet/rbi/gems/{prism@1.0.0.rbi → prism@1.9.0.rbi} +14859 -6935
  65. data/sorbet/rbi/gems/{psych@5.1.2.rbi → psych@5.3.1.rbi} +1054 -267
  66. data/sorbet/rbi/gems/{rack-session@2.0.0.rbi → rack-session@2.1.1.rbi} +145 -150
  67. data/sorbet/rbi/gems/{rack-test@2.1.0.rbi → rack-test@2.2.0.rbi} +141 -159
  68. data/sorbet/rbi/gems/{rack@3.1.7.rbi → rack@3.2.5.rbi} +1254 -1055
  69. data/sorbet/rbi/gems/rackup@2.3.1.rbi +230 -0
  70. data/sorbet/rbi/gems/{rails-dom-testing@2.2.0.rbi → rails-dom-testing@2.3.0.rbi} +160 -128
  71. data/sorbet/rbi/gems/{rails-html-sanitizer@1.6.0.rbi → rails-html-sanitizer@1.7.0.rbi} +118 -253
  72. data/sorbet/rbi/gems/{railties@7.2.1.rbi → railties@8.1.3.rbi} +1066 -748
  73. data/sorbet/rbi/gems/{rake@13.2.1.rbi → rake@13.3.1.rbi} +626 -633
  74. data/sorbet/rbi/gems/{rbi@0.2.0.rbi → rbi@0.3.9.rbi} +2202 -1069
  75. data/sorbet/rbi/gems/rbs@4.0.2.rbi +8624 -0
  76. data/sorbet/rbi/gems/{rdoc@6.7.0.rbi → rdoc@7.2.0.rbi} +3634 -2987
  77. data/sorbet/rbi/gems/regexp_parser@2.11.3.rbi +3883 -0
  78. data/sorbet/rbi/gems/require-hooks@0.2.3.rbi +110 -0
  79. data/sorbet/rbi/gems/rexml@3.4.4.rbi +5258 -0
  80. data/sorbet/rbi/gems/{rubocop-ast@1.32.3.rbi → rubocop-ast@1.49.1.rbi} +2003 -1852
  81. data/sorbet/rbi/gems/{rubocop-rake@0.6.0.rbi → rubocop-rake@0.7.1.rbi} +68 -69
  82. data/sorbet/rbi/gems/{rubocop@1.66.1.rbi → rubocop@1.86.0.rbi} +19926 -11885
  83. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
  84. data/sorbet/rbi/gems/{spoom@1.4.2.rbi → spoom@1.7.11.rbi} +2079 -1133
  85. data/sorbet/rbi/gems/standard-custom@1.0.2.rbi +9 -0
  86. data/sorbet/rbi/gems/standard-performance@1.9.0.rbi +9 -0
  87. data/sorbet/rbi/gems/standard-rails@1.6.0.rbi +9 -0
  88. data/sorbet/rbi/gems/standard-sorbet@0.0.3.rbi +9 -0
  89. data/sorbet/rbi/gems/standard@1.35.0.1.rbi +9 -0
  90. data/sorbet/rbi/gems/{tapioca@0.16.2.rbi → tapioca@0.18.0.rbi} +1121 -1154
  91. data/sorbet/rbi/gems/{thor@1.3.2.rbi → thor@1.5.0.rbi} +747 -649
  92. data/sorbet/rbi/gems/timeout@0.6.1.rbi +193 -0
  93. data/sorbet/rbi/gems/tsort@0.2.0.rbi +393 -0
  94. data/sorbet/rbi/gems/unicode-display_width@3.2.0.rbi +132 -0
  95. data/sorbet/rbi/gems/unicode-emoji@4.2.0.rbi +254 -0
  96. data/sorbet/rbi/gems/uri@1.1.1.rbi +2407 -0
  97. data/sorbet/rbi/gems/websocket-driver@0.8.0.rbi +1069 -0
  98. data/sorbet/rbi/gems/{yard@0.9.37.rbi → yard@0.9.38.rbi} +2980 -3032
  99. data/sorbet/rbi/gems/zeitwerk@2.7.5.rbi +1232 -0
  100. data/sorbet/rbi/shims/bundler.rbi +7 -0
  101. data/sorbet/rbi/shims/erb.rbi +4 -0
  102. data/sorbet/rbi/shims/set.rbi +7 -0
  103. data/sorbet/tapioca/require.rb +5 -1
  104. metadata +110 -88
  105. data/.rspec +0 -3
  106. data/sorbet/rbi/gems/actionmailbox@7.2.1.rbi +0 -1832
  107. data/sorbet/rbi/gems/actiontext@7.2.1.rbi +0 -1697
  108. data/sorbet/rbi/gems/activestorage@7.2.1.rbi +0 -3247
  109. data/sorbet/rbi/gems/bigdecimal@3.1.8.rbi +0 -78
  110. data/sorbet/rbi/gems/date@3.3.4.rbi +0 -75
  111. data/sorbet/rbi/gems/diff-lcs@1.5.1.rbi +0 -1131
  112. data/sorbet/rbi/gems/language_server-protocol@3.17.0.3.rbi +0 -14238
  113. data/sorbet/rbi/gems/rackup@2.1.0.rbi +0 -390
  114. data/sorbet/rbi/gems/regexp_parser@2.9.2.rbi +0 -3772
  115. data/sorbet/rbi/gems/rspec-core@3.13.1.rbi +0 -11012
  116. data/sorbet/rbi/gems/rspec-expectations@3.13.3.rbi +0 -8183
  117. data/sorbet/rbi/gems/rspec-mocks@3.13.1.rbi +0 -5341
  118. data/sorbet/rbi/gems/rspec-support@3.13.1.rbi +0 -1630
  119. data/sorbet/rbi/gems/rspec@3.13.0.rbi +0 -83
  120. data/sorbet/rbi/gems/securerandom@0.3.1.rbi +0 -396
  121. data/sorbet/rbi/gems/timeout@0.4.1.rbi +0 -149
  122. data/sorbet/rbi/gems/unicode-display_width@2.5.0.rbi +0 -66
  123. data/sorbet/rbi/gems/webrick@1.8.1.rbi +0 -2607
  124. data/sorbet/rbi/gems/websocket-driver@0.7.6.rbi +0 -993
  125. data/sorbet/rbi/gems/zeitwerk@2.6.18.rbi +0 -1051
  126. /data/sorbet/rbi/gems/{connection_pool@2.4.1.rbi → connection_pool@3.0.2.rbi} +0 -0
  127. /data/sorbet/rbi/gems/{dashbrains-rubocop-config@1.0.4.rbi → dashbrains-rubocop-config@1.0.8.rbi} +0 -0
  128. /data/sorbet/rbi/gems/{io-console@0.7.2.rbi → io-console@0.8.2.rbi} +0 -0
  129. /data/sorbet/rbi/gems/{rails@7.2.1.rbi → rails@8.1.3.rbi} +0 -0
  130. /data/sorbet/rbi/gems/{reline@0.5.10.rbi → reline@0.6.3.rbi} +0 -0
  131. /data/sorbet/rbi/gems/{rubocop-capybara@2.21.0.rbi → rubocop-capybara@2.22.1.rbi} +0 -0
  132. /data/sorbet/rbi/gems/{rubocop-factory_bot@2.26.1.rbi → rubocop-factory_bot@2.28.0.rbi} +0 -0
  133. /data/sorbet/rbi/gems/{rubocop-graphql@1.5.4.rbi → rubocop-graphql@1.6.0.rbi} +0 -0
  134. /data/sorbet/rbi/gems/{rubocop-minitest@0.36.0.rbi → rubocop-minitest@0.39.1.rbi} +0 -0
  135. /data/sorbet/rbi/gems/{rubocop-performance@1.21.1.rbi → rubocop-performance@1.26.1.rbi} +0 -0
  136. /data/sorbet/rbi/gems/{rubocop-rails@2.26.1.rbi → rubocop-rails@2.34.3.rbi} +0 -0
  137. /data/sorbet/rbi/gems/{rubocop-rspec@3.0.5.rbi → rubocop-rspec@3.9.0.rbi} +0 -0
  138. /data/sorbet/rbi/gems/{rubocop-rspec_rails@2.30.0.rbi → rubocop-rspec_rails@2.32.0.rbi} +0 -0
  139. /data/sorbet/rbi/gems/{rubocop-sorbet@0.8.5.rbi → rubocop-sorbet@0.9.0.rbi} +0 -0
  140. /data/sorbet/rbi/gems/{stringio@3.1.1.rbi → stringio@3.2.0.rbi} +0 -0
  141. /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: 54c20c9b4279be4b434685e57738654da55fbb75fe21e5bc8d4286bdd292aaf4
4
+ data.tar.gz: e924f4facbf95273bc16585e994c455877d28723763c05ce223e38e05a451ece
5
5
  SHA512:
6
- metadata.gz: a250f9b1ee78620f4baac110fd5bd8c7a788a24c5994b7d4b15adfd9c0e2a38069208a6273bac05a933d7e8af91ae23eecaae36bda9f2af2523842eddafd6fd0
7
- data.tar.gz: 25f74a3f3d3e2c9154e966ca75e92ec3a89f3428b1a3c9287829fe279891147cc1fe912404e25ddd2d7eb6a4aac1362258dfda4dbf2a376c4847627339d86ec6
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
- 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, 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
- 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.
253
+ ```bash
254
+ bin/setup
255
+ bundle exec rake test
256
+ ```
257
+
258
+ Useful commands:
26
259
 
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).
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
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/konstruo.
266
+ Issues and pull requests are welcome:
267
+ https://github.com/DashBrains/konstruo
32
268
 
33
269
  ## License
34
270
 
35
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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 '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,214 @@ 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 :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[T::Hash[Symbol, T.untyped]]) }
18
- attr_reader :fields
19
- end
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
- 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)
33
+ initialized = T.let([], T::Array[FieldDefinition])
34
+ T.unsafe(self).instance_variable_set(:@fields, initialized)
35
+ initialized
36
+ end
29
37
 
30
- @fields ||= [] if @fields.nil?
31
- @fields << { name:, type:, required:, custom_name: custom_name || name.to_s, mapper:, error_message: }
32
- end
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
- 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
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
- 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
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
- sig { params(hash: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
47
- def self.from_hash(hash)
48
- new.from_hash(hash)
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: T::Hash[Symbol, T.untyped]).returns(T.self_type) }
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[:custom_name]
55
- value = hash[key.to_s] || hash[key.to_sym]
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[:error_message] || "Missing required field: #{key}") if field[:required]
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[:name], field[:type], value, field[:mapper], field[:error_message])
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(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
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
- # Validate each element in the array
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 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)
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
- # Validate individual element types
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
- send(:"#{field_name}=", field_type.new.from_hash(value))
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: T.any(T.class_of(Object), T::Array[T.class_of(Object)]),
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
- raise Konstruo::ValidationError, (error_message || "Expected Boolean for field: #{field_name}, got #{value.class}") unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
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
- # 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)
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
@@ -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.3'
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