light-services 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f1c7874fc2ac069ac686f0b12895567c76c7005523dc0c1b841781f73ab3988
4
- data.tar.gz: 4b41b830919ac007ec7740b1a84ca7d8a39682d9623fd9fd9085546e384391a3
3
+ metadata.gz: 814307f260796e4439cd7e9602159281b4457003c9b9a70f5596ecb81a80c7bc
4
+ data.tar.gz: e9ac19b493f3559b0e2ba1b978c7dd3e51615614c1c1f239f1326412e6126950
5
5
  SHA512:
6
- metadata.gz: eac0742e9f253e03b5e1cb58619798fc81b4f3139df62311c9350d4f3f1a13264dc4ad74af14e84033fa66137fbe31b390f95056783a83151ec76527c0590bc3
7
- data.tar.gz: 45a9a709c76c57032c8ae71bc8fa31e32fee3aebe8e82a8c1265480c400ab2a724cfc9dc9d3572a830ff78a03235cfd94a95f782c4c92750db5d5f0d9f83bd6f
6
+ metadata.gz: 3ba524199e7078ad1dbbf0a4df34ef4bb50f59c8950695e74e8a2b9f6691ae0ca673b16127700e2a7d0bdc66a1762fd886d4a89efe4e531840a092a49b0490b3
7
+ data.tar.gz: ecacfe8dd84b3fb1b1ac944b46e4ec6ed08d15eb6857f9fec82f070f90a87a51c97855d7ab46cc62f007f32c77f528fb178b9829c46603121978a5752905cb49
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.3.1 (2025-12-16)
4
+
5
+ ### Added
6
+
7
+ - Sorbet runtime type support for `arg` and `output` (validation only, no coercion)
8
+
3
9
  ## 3.3.0 (2025-12-15)
4
10
 
5
11
  ### Added
data/Gemfile CHANGED
@@ -7,6 +7,7 @@ gemspec
7
7
  group :test do
8
8
  # Optional type system support
9
9
  gem "dry-types", "~> 1.0"
10
+ gem "sorbet-runtime"
10
11
 
11
12
  gem "activerecord", "< 8"
12
13
  gem "connection_pool", "< 3"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- light-services (3.3.0)
4
+ light-services (3.3.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -126,6 +126,7 @@ GEM
126
126
  simplecov_json_formatter (~> 0.1)
127
127
  simplecov-html (0.13.2)
128
128
  simplecov_json_formatter (0.1.4)
129
+ sorbet-runtime (0.6.12848)
129
130
  sqlite3 (2.8.1)
130
131
  mini_portile2 (~> 2.8.0)
131
132
  timeout (0.5.0)
@@ -154,6 +155,7 @@ DEPENDENCIES
154
155
  rubocop-rake
155
156
  rubocop-rspec
156
157
  simplecov
158
+ sorbet-runtime
157
159
  sqlite3
158
160
 
159
161
  BUNDLED WITH
data/docs/SUMMARY.md CHANGED
@@ -19,6 +19,7 @@
19
19
  * [Rails Generators](generators.md)
20
20
  * [RuboCop Integration](rubocop.md)
21
21
  * [Ruby LSP Integration](ruby-lsp.md)
22
+ * [Sorbet Runtime Types](sorbet-runtime.md)
22
23
  * [Tapioca / Sorbet Integration](tapioca.md)
23
24
 
24
25
  ## Examples
data/docs/arguments.md CHANGED
@@ -124,6 +124,38 @@ service = User::Create.run(name: "John", age: "25")
124
124
  service.age # => 25 (Integer, not String)
125
125
  ```
126
126
 
127
+ ### Sorbet Runtime Types
128
+
129
+ Light Services also supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation. Unlike dry-types, Sorbet types **only validate** and do not coerce values.
130
+
131
+ ```ruby
132
+ require "sorbet-runtime"
133
+
134
+ class User::Create < ApplicationService
135
+ # Basic types using T::Utils.coerce
136
+ arg :name, type: T::Utils.coerce(String)
137
+ arg :age, type: T::Utils.coerce(Integer)
138
+
139
+ # Nilable types
140
+ arg :email, type: T.nilable(String), optional: true
141
+
142
+ # Union types
143
+ arg :status, type: T.any(String, Symbol)
144
+
145
+ # Typed arrays
146
+ arg :tags, type: T::Array[String]
147
+
148
+ # Boolean type
149
+ arg :active, type: T::Boolean, default: true
150
+ end
151
+ ```
152
+
153
+ {% hint style="warning" %}
154
+ **Sorbet types do NOT coerce values.** If you pass `"25"` where an `Integer` is expected, it will raise an error instead of converting the string to an integer. Use dry-types if you need automatic coercion.
155
+ {% endhint %}
156
+
157
+ See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
158
+
127
159
  ## Required Arguments
128
160
 
129
161
  By default, arguments are required. You can make them optional by setting `optional` to `true`.
data/docs/outputs.md CHANGED
@@ -124,6 +124,22 @@ class AI::Chat < ApplicationService
124
124
  end
125
125
  ```
126
126
 
127
+ ### Sorbet Runtime Types
128
+
129
+ Outputs also support [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation:
130
+
131
+ ```ruby
132
+ require "sorbet-runtime"
133
+
134
+ class AI::Chat < ApplicationService
135
+ output :messages, type: T::Array[Hash]
136
+ output :total_tokens, type: T::Utils.coerce(Integer)
137
+ output :metadata, type: T.nilable(Hash), optional: true
138
+ end
139
+ ```
140
+
141
+ See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
142
+
127
143
  ## Default Values
128
144
 
129
145
  Set default values for outputs using the `default` option. The default value will be automatically set before the execution of steps.
@@ -0,0 +1,277 @@
1
+ # Sorbet Runtime Types
2
+
3
+ Light Services supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation of arguments and outputs. This provides runtime type checking using Sorbet's type system.
4
+
5
+ {% hint style="info" %}
6
+ This page covers **runtime type checking** with `sorbet-runtime`. For **static type analysis** with Sorbet and RBI file generation, see [Tapioca / Sorbet Integration](tapioca.md).
7
+ {% endhint %}
8
+
9
+ ## Installation
10
+
11
+ Add `sorbet-runtime` to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "sorbet-runtime"
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Basic Usage
24
+
25
+ When `sorbet-runtime` is loaded, plain Ruby classes are automatically validated using Sorbet's type system:
26
+
27
+ ```ruby
28
+ require "sorbet-runtime"
29
+
30
+ class User::Create < ApplicationService
31
+ # Basic types - plain Ruby classes work directly!
32
+ arg :name, type: String
33
+ arg :age, type: Integer
34
+
35
+ # Nilable types (allows nil)
36
+ arg :email, type: T.nilable(String), optional: true
37
+
38
+ # Union types (multiple allowed types)
39
+ arg :status, type: T.any(String, Symbol), default: "pending"
40
+
41
+ # Typed arrays
42
+ arg :tags, type: T::Array[String], optional: true
43
+
44
+ # Boolean type
45
+ arg :active, type: T::Boolean, default: true
46
+
47
+ # Outputs with Sorbet types - plain classes work here too
48
+ output :user, type: User
49
+ output :metadata, type: Hash
50
+
51
+ step :create_user
52
+ step :build_metadata
53
+
54
+ private
55
+
56
+ def create_user
57
+ self.user = User.create!(
58
+ name: name,
59
+ age: age,
60
+ email: email,
61
+ status: status,
62
+ tags: tags || [],
63
+ active: active
64
+ )
65
+ end
66
+
67
+ def build_metadata
68
+ self.metadata = { created_at: Time.current }
69
+ end
70
+ end
71
+ ```
72
+
73
+ ## Type Reference
74
+
75
+ ### Basic Types
76
+
77
+ When `sorbet-runtime` is loaded, plain Ruby classes are automatically coerced to Sorbet types:
78
+
79
+ ```ruby
80
+ arg :name, type: String
81
+ arg :count, type: Integer
82
+ arg :price, type: Float
83
+ arg :data, type: Hash
84
+ arg :items, type: Array
85
+ ```
86
+
87
+ {% hint style="info" %}
88
+ You can also use `T::Utils.coerce(String)` explicitly, but it's not required - Light Services handles the coercion automatically.
89
+ {% endhint %}
90
+
91
+ ### Nilable Types
92
+
93
+ Allow `nil` values with `T.nilable`:
94
+
95
+ ```ruby
96
+ arg :nickname, type: T.nilable(String), optional: true
97
+ ```
98
+
99
+ ### Union Types
100
+
101
+ Allow multiple types with `T.any`:
102
+
103
+ ```ruby
104
+ arg :identifier, type: T.any(String, Integer)
105
+ arg :status, type: T.any(String, Symbol)
106
+ ```
107
+
108
+ ### Typed Arrays
109
+
110
+ Validate array element types with `T::Array`:
111
+
112
+ ```ruby
113
+ arg :tags, type: T::Array[String]
114
+ arg :numbers, type: T::Array[Integer]
115
+ arg :users, type: T::Array[User]
116
+ ```
117
+
118
+ {% hint style="warning" %}
119
+ **Generic Type Erasure:** Sorbet's `T::Array[String]` only validates that the value is an `Array` at runtime. The type parameter (`String`) is **erased** and not checked. For strict array element validation, use dry-types: `Types::Array.of(Types::Strict::String)`.
120
+ {% endhint %}
121
+
122
+ ### Boolean Type
123
+
124
+ Use `T::Boolean` for true/false values:
125
+
126
+ ```ruby
127
+ arg :active, type: T::Boolean
128
+ arg :verified, type: T::Boolean, default: false
129
+ ```
130
+
131
+ ### Complex Types
132
+
133
+ Combine types for more complex validations:
134
+
135
+ ```ruby
136
+ # Nilable array
137
+ arg :tags, type: T.nilable(T::Array[String]), optional: true
138
+
139
+ # Array of union types
140
+ arg :identifiers, type: T::Array[T.any(String, Integer)]
141
+ ```
142
+
143
+ ## Key Differences from dry-types
144
+
145
+ {% hint style="warning" %}
146
+ **Sorbet types do NOT coerce values.** Unlike dry-types which can automatically convert values (e.g., `"123"` → `123`), Sorbet runtime types only validate that values match the expected type.
147
+ {% endhint %}
148
+
149
+ ### Coercion Behavior Comparison
150
+
151
+ **With dry-types (coerces):**
152
+
153
+ ```ruby
154
+ # dry-types with coercible integer
155
+ arg :age, type: Types::Coercible::Integer
156
+
157
+ service = MyService.run(age: "25")
158
+ service.age # => 25 (Integer - coerced from String)
159
+ ```
160
+
161
+ **With Sorbet runtime (validates only):**
162
+
163
+ ```ruby
164
+ # Sorbet runtime type (plain class or T::Utils.coerce)
165
+ arg :age, type: Integer
166
+
167
+ service = MyService.run(age: "25")
168
+ # => Raises ArgTypeError: expected Integer, got String
169
+ ```
170
+
171
+ ### When to Use Each
172
+
173
+ | Use Case | Recommended |
174
+ |----------|-------------|
175
+ | Strict type validation | Sorbet runtime |
176
+ | Automatic value coercion | dry-types |
177
+ | Static type analysis | Sorbet + Tapioca |
178
+ | Input from external sources (APIs, forms) | dry-types (for coercion) |
179
+ | Internal service-to-service calls | Sorbet runtime |
180
+
181
+ ## Combining with Tapioca
182
+
183
+ For full Sorbet support, you can use both:
184
+
185
+ 1. **Runtime types** (`sorbet-runtime`) - Validates types at runtime
186
+ 2. **Static types** (Tapioca) - Generates RBI files for static analysis
187
+
188
+ See [Tapioca / Sorbet Integration](tapioca.md) for setting up static type analysis.
189
+
190
+ ```ruby
191
+ # typed: strict
192
+
193
+ class User::Create < ApplicationService
194
+ # Runtime validation with Sorbet types
195
+ arg :name, type: String
196
+ arg :age, type: Integer
197
+
198
+ output :user, type: User
199
+
200
+ # ...
201
+ end
202
+ ```
203
+
204
+ With Tapioca configured, you get:
205
+ - **Runtime validation** from `sorbet-runtime`
206
+ - **IDE autocompletion** from generated RBI files
207
+ - **Static type checking** from `srb tc`
208
+
209
+ ## Error Messages
210
+
211
+ When type validation fails, Light Services raises `ArgTypeError` with a descriptive message:
212
+
213
+ ```ruby
214
+ service = User::Create.run(name: 123, age: 25)
215
+ # => Light::Services::ArgTypeError: User::Create argument `name` expected String, but got Integer with value: 123
216
+ ```
217
+
218
+ ## Full Example
219
+
220
+ ```ruby
221
+ require "sorbet-runtime"
222
+
223
+ class Order::Create < ApplicationService
224
+ # Required arguments - plain classes work!
225
+ arg :customer, type: Customer
226
+ arg :items, type: T::Array[OrderItem]
227
+ arg :total, type: T.any(Integer, Float)
228
+
229
+ # Optional arguments
230
+ arg :notes, type: T.nilable(String), optional: true
231
+ arg :priority, type: T::Boolean, default: false
232
+ arg :tags, type: T::Array[String], optional: true
233
+
234
+ # Outputs
235
+ output :order, type: Order
236
+ output :confirmation_number, type: String
237
+
238
+ step :validate_items
239
+ step :create_order
240
+ step :generate_confirmation
241
+
242
+ private
243
+
244
+ def validate_items
245
+ fail!("Order must have at least one item") if items.empty?
246
+ end
247
+
248
+ def create_order
249
+ self.order = Order.create!(
250
+ customer: customer,
251
+ items: items,
252
+ total: total,
253
+ notes: notes,
254
+ priority: priority,
255
+ tags: tags || []
256
+ )
257
+ end
258
+
259
+ def generate_confirmation
260
+ self.confirmation_number = "ORD-#{order.id}-#{SecureRandom.hex(4).upcase}"
261
+ end
262
+ end
263
+
264
+ # Usage
265
+ result = Order::Create.run(
266
+ customer: current_user,
267
+ items: [item1, item2],
268
+ total: 99.99,
269
+ priority: true
270
+ )
271
+
272
+ if result.success?
273
+ puts "Order created: #{result.confirmation_number}"
274
+ else
275
+ puts "Failed: #{result.errors.full_messages}"
276
+ end
277
+ ```
@@ -46,7 +46,7 @@ module Light
46
46
  end
47
47
 
48
48
  # Validate a value against the field's type definition.
49
- # Supports both Ruby class types and dry-types.
49
+ # Supports Ruby class types, dry-types, and Sorbet runtime types.
50
50
  #
51
51
  # @param value [Object] the value to validate
52
52
  # @return [Object] the value (possibly coerced by dry-types)
@@ -56,6 +56,8 @@ module Light
56
56
 
57
57
  if dry_type?(@type)
58
58
  coerce_and_validate_dry_type!(value)
59
+ elsif sorbet_type?(@type) || (sorbet_available? && plain_class_type?(@type))
60
+ validate_sorbet_type!(value)
59
61
  else
60
62
  validate_ruby_type!(value)
61
63
  value
@@ -64,6 +66,16 @@ module Light
64
66
 
65
67
  private
66
68
 
69
+ # Check if sorbet-runtime is available
70
+ def sorbet_available?
71
+ defined?(T::Types::Base)
72
+ end
73
+
74
+ # Check if the type is a plain Ruby class (not dry-types or Sorbet type)
75
+ def plain_class_type?(type)
76
+ type.is_a?(Class) || type.is_a?(Module)
77
+ end
78
+
67
79
  # Check if the type is a dry-types type
68
80
  def dry_type?(type)
69
81
  return false unless defined?(Dry::Types::Type)
@@ -71,6 +83,13 @@ module Light
71
83
  type.is_a?(Dry::Types::Type)
72
84
  end
73
85
 
86
+ # Check if the type is a Sorbet runtime type
87
+ def sorbet_type?(type)
88
+ return false unless defined?(T::Types::Base)
89
+
90
+ type.is_a?(T::Types::Base)
91
+ end
92
+
74
93
  # Validate and coerce value against dry-types
75
94
  # Returns the coerced value
76
95
  def coerce_and_validate_dry_type!(value)
@@ -80,6 +99,20 @@ module Light
80
99
  "#{@service_class} #{@field_type} `#{@name}` #{e.message}"
81
100
  end
82
101
 
102
+ # Validate value against Sorbet runtime types
103
+ # Note: Sorbet types only validate, they do not coerce values
104
+ # Automatically coerces plain Ruby classes to Sorbet types when needed
105
+ # @return [Object] the original value if valid
106
+ # @raise [ArgTypeError] if the value doesn't match the expected type
107
+ def validate_sorbet_type!(value)
108
+ sorbet_type = sorbet_type?(@type) ? @type : T::Utils.coerce(@type)
109
+ return value if sorbet_type.valid?(value)
110
+
111
+ raise Light::Services::ArgTypeError,
112
+ "#{@service_class} #{@field_type} `#{@name}` expected #{sorbet_type.name}, " \
113
+ "but got #{value.class} with value: #{value.inspect}"
114
+ end
115
+
83
116
  # Validate value against Ruby class types
84
117
  def validate_ruby_type!(value)
85
118
  return if [*@type].any? { |type| value.is_a?(type) }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Light
4
4
  module Services
5
- VERSION = "3.3.0"
5
+ VERSION = "3.3.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: light-services
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kodkod
@@ -54,6 +54,7 @@ files:
54
54
  - docs/rubocop.md
55
55
  - docs/ruby-lsp.md
56
56
  - docs/service-rendering.md
57
+ - docs/sorbet-runtime.md
57
58
  - docs/steps.md
58
59
  - docs/tapioca.md
59
60
  - docs/testing.md