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 +4 -4
- data/CHANGELOG.md +6 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/docs/SUMMARY.md +1 -0
- data/docs/arguments.md +32 -0
- data/docs/outputs.md +16 -0
- data/docs/sorbet-runtime.md +277 -0
- data/lib/light/services/settings/field.rb +34 -1
- data/lib/light/services/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 814307f260796e4439cd7e9602159281b4457003c9b9a70f5596ecb81a80c7bc
|
|
4
|
+
data.tar.gz: e9ac19b493f3559b0e2ba1b978c7dd3e51615614c1c1f239f1326412e6126950
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ba524199e7078ad1dbbf0a4df34ef4bb50f59c8950695e74e8a2b9f6691ae0ca673b16127700e2a7d0bdc66a1762fd886d4a89efe4e531840a092a49b0490b3
|
|
7
|
+
data.tar.gz: ecacfe8dd84b3fb1b1ac944b46e4ec6ed08d15eb6857f9fec82f070f90a87a51c97855d7ab46cc62f007f32c77f528fb178b9829c46603121978a5752905cb49
|
data/CHANGELOG.md
CHANGED
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
light-services (3.3.
|
|
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
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
|
|
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) }
|
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.
|
|
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
|