senro_usecaser 0.2.0 → 0.3.0

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: 91d516c2b304a836d4b476c177083ddff38bf8344ddbba14293aa60bee3e07f5
4
- data.tar.gz: 0167aac25683c97feeb378123294073025c86d405307facb5e0392f5bf8eec35
3
+ metadata.gz: cbfc07ede41cc295ceb0a019774a480744b226e88edc46f72a6447d59375f4e4
4
+ data.tar.gz: b86aaade8ccc9f2479f18d0f3bd5204203718ce26740eb3a51b5cccf1ed06798
5
5
  SHA512:
6
- metadata.gz: 9592f2b84a831c56b80010cd207588e171407f07a143f19bb25f6cb4bb103bfa23b449c98517c887b4a0d5ff571b5e666afa82877df887978881121c01943996
7
- data.tar.gz: f4cdbec706095b338c537eec61179ec9a2d9e251bd570d5ddceef2d0b2503deede7be514501e395c47e191a6e1c5c97f49144c0eb0fc728b3741d98b47ecc0e3
6
+ metadata.gz: 2ce95671ffefc951140c3625db940f1b077fe8bf40138d790b25067ab010a087d9f96b6c2e31bab2819ea89f0b2e6e905c70288f9409e24782cd73a0bca832cd
7
+ data.tar.gz: aa3b27b6eff694929f8cfc33ca47e5d7fb450eeb4ac5da9fecba2023cc70393a2edfa1b033e89d0fe0f0e1abe5cda3817841e8b92e04294062c0cd4a02ef8879
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-01-31
9
+
10
+ ### Added
11
+
12
+ - **Runtime type validation for input**
13
+ - `input` now accepts Module(s) for interface-based validation
14
+ - Single module: `input HasUserId` - validates that input's class includes the module
15
+ - Multiple modules: `input HasUserId, HasEmail` - validates that input's class includes all modules
16
+ - Class validation remains supported for backwards compatibility
17
+ - **Runtime type validation for output**
18
+ - When `output` is declared with a Class, the success result's value is validated
19
+ - Raises `TypeError` if the output value is not an instance of the declared class
20
+ - Hash schema (`output({ key: Type })`) skips validation for backwards compatibility
21
+ - **New class methods**
22
+ - `input_types` - Returns an array of declared input types (Module/Class)
23
+ - `input_class` - Backwards compatible method, returns Class if specified or first type
24
+
25
+ ### Changed
26
+
27
+ - Type validation errors raise exceptions with `.call` and return `Result.failure` with `.call!`
28
+ - Input validation: `ArgumentError`
29
+ - Output validation: `TypeError`
30
+
8
31
  ## [0.2.0] - 2026-01-31
9
32
 
10
33
  ### Added
data/README.md CHANGED
@@ -442,6 +442,179 @@ end
442
442
 
443
443
  The `**_rest` parameter in Input's initialize allows extra fields to be passed through pipeline steps without errors.
444
444
 
445
+ ### Runtime Type Validation
446
+
447
+ In addition to static type checking with RBS, SenroUsecaser provides runtime type validation for Input and Output. This ensures that the actual values passed at runtime match the expected types.
448
+
449
+ #### Input Type Validation
450
+
451
+ The `input` declaration supports three patterns:
452
+
453
+ ##### 1. Class Validation (Traditional)
454
+
455
+ When a Class is specified, input must be an instance of that class:
456
+
457
+ ```ruby
458
+ class CreateUserUseCase < SenroUsecaser::Base
459
+ input CreateUserInput # Class
460
+
461
+ def call(input)
462
+ # input must be a CreateUserInput instance
463
+ success(input.name)
464
+ end
465
+ end
466
+
467
+ # OK
468
+ CreateUserUseCase.call(CreateUserInput.new(name: "Taro"))
469
+
470
+ # ArgumentError: Input must be an instance of CreateUserInput, got String
471
+ CreateUserUseCase.call("invalid")
472
+ ```
473
+
474
+ ##### 2. Interface Validation (Single Module)
475
+
476
+ When a Module is specified, input's class must include that module. This enables duck-typing with explicit interface contracts:
477
+
478
+ ```ruby
479
+ # Define interface
480
+ module HasUserId
481
+ def user_id
482
+ raise NotImplementedError
483
+ end
484
+ end
485
+
486
+ # UseCase expects input that includes HasUserId
487
+ class FindUserUseCase < SenroUsecaser::Base
488
+ input HasUserId
489
+
490
+ #: (HasUserId) -> SenroUsecaser::Result[User]
491
+ def call(input)
492
+ user = User.find(input.user_id)
493
+ success(user)
494
+ end
495
+ end
496
+
497
+ # Input class that implements the interface
498
+ class UserQuery
499
+ include HasUserId
500
+
501
+ attr_reader :user_id
502
+
503
+ def initialize(user_id:)
504
+ @user_id = user_id
505
+ end
506
+ end
507
+
508
+ # OK - UserQuery includes HasUserId
509
+ FindUserUseCase.call(UserQuery.new(user_id: 123))
510
+
511
+ # ArgumentError: Input UserQuery must include HasUserId
512
+ class InvalidInput
513
+ attr_reader :user_id
514
+ def initialize(user_id:) = @user_id = user_id
515
+ end
516
+ FindUserUseCase.call(InvalidInput.new(user_id: 123))
517
+ ```
518
+
519
+ ##### 3. Multiple Interfaces Validation
520
+
521
+ Multiple Modules can be specified. The input must include ALL of them:
522
+
523
+ ```ruby
524
+ module HasUserId
525
+ def user_id = raise NotImplementedError
526
+ end
527
+
528
+ module HasEmail
529
+ def email = raise NotImplementedError
530
+ end
531
+
532
+ # UseCase requires both interfaces
533
+ class NotifyUserUseCase < SenroUsecaser::Base
534
+ input HasUserId, HasEmail
535
+
536
+ #: ((HasUserId & HasEmail)) -> SenroUsecaser::Result[bool]
537
+ def call(input)
538
+ notify(input.user_id, input.email)
539
+ success(true)
540
+ end
541
+ end
542
+
543
+ # Input class must include both modules
544
+ class NotificationRequest
545
+ include HasUserId
546
+ include HasEmail
547
+
548
+ attr_reader :user_id, :email
549
+
550
+ def initialize(user_id:, email:)
551
+ @user_id = user_id
552
+ @email = email
553
+ end
554
+ end
555
+
556
+ # OK
557
+ NotifyUserUseCase.call(NotificationRequest.new(user_id: 123, email: "test@example.com"))
558
+ ```
559
+
560
+ ##### Interface Pattern in Pipelines
561
+
562
+ Interface validation is especially useful for sub-UseCases in pipelines. A parent UseCase's Input can include multiple interfaces, and each step only requires the interfaces it needs:
563
+
564
+ ```ruby
565
+ # Parent UseCase - Input includes both interfaces
566
+ class ProcessOrderUseCase < SenroUsecaser::Base
567
+ class Input
568
+ include HasUserId
569
+ include HasEmail
570
+
571
+ attr_reader :user_id, :email, :order_items
572
+
573
+ def initialize(user_id:, email:, order_items:)
574
+ @user_id = user_id
575
+ @email = email
576
+ @order_items = order_items
577
+ end
578
+ end
579
+
580
+ input Input
581
+
582
+ organize do
583
+ step FindUserUseCase # Only needs HasUserId
584
+ step NotifyUserUseCase # Needs HasUserId and HasEmail
585
+ step CreateOrderUseCase
586
+ end
587
+ end
588
+ ```
589
+
590
+ #### Output Type Validation
591
+
592
+ When `output` is declared with a Class, the success result's value is validated:
593
+
594
+ ```ruby
595
+ class UserOutput
596
+ attr_reader :user
597
+ def initialize(user:) = @user = user
598
+ end
599
+
600
+ class FindUserUseCase < SenroUsecaser::Base
601
+ input FindUserInput
602
+ output UserOutput # Class declaration enables validation
603
+
604
+ def call(input)
605
+ user = User.find(input.user_id)
606
+ success(UserOutput.new(user: user)) # OK
607
+
608
+ # TypeError: Output must be an instance of UserOutput, got User
609
+ # success(user) # Wrong! Must wrap in UserOutput
610
+ end
611
+ end
612
+ ```
613
+
614
+ **Note:** When `output` is a Hash schema (e.g., `output({ user: User })`), validation is skipped for backwards compatibility.
615
+
616
+ **Note:** Type validation errors raise exceptions (`ArgumentError` for input, `TypeError` for output). See [`.call` vs `.call!`](#call-vs-call-1) for how exceptions are handled.
617
+
445
618
  ### Simplicity
446
619
 
447
620
  Define UseCases with minimal boilerplate. Avoids over-abstraction and provides an intuitive API.
@@ -1007,6 +1180,28 @@ end
1007
1180
 
1008
1181
  Use `.call!` when you want to ensure all exceptions are captured as `Result.failure` without explicit rescue blocks in your UseCase.
1009
1182
 
1183
+ **Type validation errors** (from `input` and `output` declarations) also follow this pattern:
1184
+
1185
+ ```ruby
1186
+ # With .call - type validation errors raise exceptions
1187
+ begin
1188
+ UseCase.call(invalid_input)
1189
+ rescue ArgumentError => e
1190
+ puts e.message # "Input SomeClass must include HasUserId"
1191
+ end
1192
+
1193
+ # With .call! - type validation errors become Result.failure
1194
+ result = UseCase.call!(invalid_input)
1195
+ result.failure? # => true
1196
+ result.errors.first.code # => :exception
1197
+ result.errors.first.message # => "Input SomeClass must include HasUserId"
1198
+ ```
1199
+
1200
+ | Validation | Exception type | With `.call` | With `.call!` |
1201
+ |------------|---------------|--------------|---------------|
1202
+ | Input type | `ArgumentError` | Raises | `Result.failure` |
1203
+ | Output type | `TypeError` | Raises | `Result.failure` |
1204
+
1010
1205
  #### Exception Handling in Pipelines
1011
1206
 
1012
1207
  When using `.call!` with `organize` pipelines, the exception capture behavior is **chained** to all steps. This is especially useful with `on_failure: :collect`:
@@ -276,17 +276,41 @@ module SenroUsecaser
276
276
  @around_hooks ||= []
277
277
  end
278
278
 
279
- # Declares the expected input type for this UseCase
279
+ # Declares the expected input type(s) for this UseCase
280
+ # Accepts a Class or one or more Modules that input must include
280
281
  #
281
- #: (Class) -> void
282
- def input(type)
283
- @input_class = type
282
+ # @example Single class
283
+ # input UserInput
284
+ #
285
+ # @example Single module (interface)
286
+ # input HasUserId
287
+ #
288
+ # @example Multiple modules (interfaces)
289
+ # input HasUserId, HasEmail
290
+ #
291
+ #: (*Module) -> void
292
+ def input(*types)
293
+ @input_types = types
294
+ end
295
+
296
+ # Returns the input types as an array
297
+ #
298
+ #: () -> Array[Module]
299
+ def input_types
300
+ @input_types || []
284
301
  end
285
302
 
286
- # Returns the input class
303
+ # Returns the input class (for backwards compatibility)
304
+ # If a Class is specified, returns it. Otherwise returns the first type.
287
305
  #
288
- #: () -> Class?
289
- attr_reader :input_class
306
+ #: () -> Module?
307
+ def input_class
308
+ types = input_types
309
+ return nil if types.empty?
310
+
311
+ # Class があればそれを返す(単一 Class 指定の後方互換)
312
+ types.find { |t| t.is_a?(Class) } || types.first
313
+ end
290
314
 
291
315
  # Declares the expected output type for this UseCase
292
316
  #
@@ -340,7 +364,7 @@ module SenroUsecaser
340
364
  subclass.instance_variable_set(:@use_case_namespace, @use_case_namespace)
341
365
  subclass.instance_variable_set(:@organized_steps, @organized_steps&.dup)
342
366
  subclass.instance_variable_set(:@on_failure_strategy, @on_failure_strategy)
343
- subclass.instance_variable_set(:@input_class, @input_class)
367
+ subclass.instance_variable_set(:@input_types, @input_types&.dup)
344
368
  subclass.instance_variable_set(:@output_schema, @output_schema)
345
369
  end
346
370
 
@@ -372,6 +396,8 @@ module SenroUsecaser
372
396
  raise ArgumentError, "#{self.class.name} must define `input` class"
373
397
  end
374
398
 
399
+ validate_input!(input)
400
+
375
401
  execute_with_hooks(input) do
376
402
  call(input)
377
403
  end
@@ -416,6 +442,48 @@ module SenroUsecaser
416
442
  Result.capture(*exception_classes, code: code, &)
417
443
  end
418
444
 
445
+ # Validates that input satisfies all declared input types
446
+ # For Modules: checks if input's class includes the module
447
+ # For Classes: checks if input is an instance of the class
448
+ #
449
+ #: (untyped) -> void
450
+ def validate_input!(input)
451
+ types = self.class.input_types
452
+ return if types.empty?
453
+
454
+ types.each do |expected_type|
455
+ if expected_type.is_a?(Module) && !expected_type.is_a?(Class)
456
+ # Module の場合: include しているかを検査
457
+ unless input.class.include?(expected_type)
458
+ raise ArgumentError,
459
+ "Input #{input.class} must include #{expected_type}"
460
+ end
461
+ elsif !input.is_a?(expected_type)
462
+ # Class の場合: インスタンスかを検査
463
+ raise ArgumentError,
464
+ "Input must be an instance of #{expected_type}, got #{input.class}"
465
+ end
466
+ end
467
+ end
468
+
469
+ # Validates that the result's value satisfies the declared output type
470
+ # Only validates if result is success and output_schema is a Class
471
+ #
472
+ #: (Result[untyped]) -> void
473
+ def validate_output!(result)
474
+ return unless result.success?
475
+
476
+ expected_type = self.class.output_schema
477
+ return if expected_type.nil?
478
+ return unless expected_type.is_a?(Class)
479
+
480
+ value = result.value
481
+ return if value.is_a?(expected_type)
482
+
483
+ raise TypeError,
484
+ "Output must be an instance of #{expected_type}, got #{value.class}"
485
+ end
486
+
419
487
  # Executes the core logic with before/after/around hooks
420
488
  #
421
489
  #: (untyped) { () -> Result[untyped] } -> Result[untyped]
@@ -423,6 +491,7 @@ module SenroUsecaser
423
491
  execution = build_around_chain(input, core_block)
424
492
  run_before_hooks(input)
425
493
  result = execution.call
494
+ validate_output!(result)
426
495
  run_after_hooks(input, result)
427
496
  result
428
497
  end
@@ -692,12 +761,12 @@ module SenroUsecaser
692
761
  end
693
762
 
694
763
  # Calls a single UseCase in the pipeline
695
- # Requires input_class to be defined for pipeline steps
764
+ # Requires input type(s) to be defined for pipeline steps
696
765
  #
697
766
  #: (singleton(Base), untyped) -> Result[untyped]
698
767
  def call_use_case(use_case_class, input)
699
- unless use_case_class.input_class
700
- raise ArgumentError, "#{use_case_class.name} must define `input` class to be used in a pipeline"
768
+ if use_case_class.input_types.empty?
769
+ raise ArgumentError, "#{use_case_class.name} must define `input` type(s) to be used in a pipeline"
701
770
  end
702
771
 
703
772
  call_method = @_capture_exceptions || false ? :call! : :call #: Symbol
@@ -72,7 +72,7 @@ module SenroUsecaser
72
72
  # enabled_if { Rails.env.development? }
73
73
  # end
74
74
  #
75
- #: () { () -> bool } -> void
75
+ #: () { () -> boolish } -> void
76
76
  def enabled_if(&block)
77
77
  @enabled_condition = block
78
78
  end
@@ -4,5 +4,5 @@
4
4
 
5
5
  module SenroUsecaser
6
6
  #: String
7
- VERSION = "0.2.0"
7
+ VERSION = "0.3.0"
8
8
  end
@@ -188,15 +188,31 @@ module SenroUsecaser
188
188
  # : () -> Array[Proc]
189
189
  def self.around_hooks: () -> Array[Proc]
190
190
 
191
- # Declares the expected input type for this UseCase
191
+ # Declares the expected input type(s) for this UseCase
192
+ # Accepts a Class or one or more Modules that input must include
192
193
  #
193
- # : (Class) -> void
194
- def self.input: (Class) -> void
194
+ # @example Single class
195
+ # input UserInput
196
+ #
197
+ # @example Single module (interface)
198
+ # input HasUserId
199
+ #
200
+ # @example Multiple modules (interfaces)
201
+ # input HasUserId, HasEmail
202
+ #
203
+ # : (*Module) -> void
204
+ def self.input: (*Module) -> void
205
+
206
+ # Returns the input types as an array
207
+ #
208
+ # : () -> Array[Module]
209
+ def self.input_types: () -> Array[Module]
195
210
 
196
- # Returns the input class
211
+ # Returns the input class (for backwards compatibility)
212
+ # If a Class is specified, returns it. Otherwise returns the first type.
197
213
  #
198
- # : () -> Class?
199
- attr_reader input_class: untyped
214
+ # : () -> Module?
215
+ def self.input_class: () -> Module?
200
216
 
201
217
  # Declares the expected output type for this UseCase
202
218
  #
@@ -267,6 +283,19 @@ module SenroUsecaser
267
283
  # : [T] (*Class, ?code: Symbol) { () -> T } -> Result[T]
268
284
  def capture: [T] (*Class, ?code: Symbol) { () -> T } -> Result[T]
269
285
 
286
+ # Validates that input satisfies all declared input types
287
+ # For Modules: checks if input's class includes the module
288
+ # For Classes: checks if input is an instance of the class
289
+ #
290
+ # : (untyped) -> void
291
+ def validate_input!: (untyped) -> void
292
+
293
+ # Validates that the result's value satisfies the declared output type
294
+ # Only validates if result is success and output_schema is a Class
295
+ #
296
+ # : (Result[untyped]) -> void
297
+ def validate_output!: (Result[untyped]) -> void
298
+
270
299
  # Executes the core logic with before/after/around hooks
271
300
  #
272
301
  # : (untyped) { () -> Result[untyped] } -> Result[untyped]
@@ -378,7 +407,7 @@ module SenroUsecaser
378
407
  def step_should_stop?: (Step) -> bool
379
408
 
380
409
  # Calls a single UseCase in the pipeline
381
- # Requires input_class to be defined for pipeline steps
410
+ # Requires input type(s) to be defined for pipeline steps
382
411
  #
383
412
  # : (singleton(Base), untyped) -> Result[untyped]
384
413
  def call_use_case: (singleton(Base), untyped) -> Result[untyped]
@@ -62,7 +62,7 @@ module SenroUsecaser
62
62
  # enabled_if { Rails.env.development? }
63
63
  # end
64
64
  #
65
- # : () { () -> bool } -> void
65
+ # : () { () -> boolish } -> void
66
66
  def self.enabled_if: () { () -> boolish } -> void
67
67
 
68
68
  # Returns whether this provider is enabled
data/sig/overrides.rbs CHANGED
@@ -11,6 +11,6 @@ module SenroUsecaser
11
11
  # Class methods that rbs-inline doesn't generate correctly
12
12
  def self.organized_steps: () -> Array[Step]?
13
13
  def self.use_case_namespace: () -> (Symbol | String)?
14
- def self.input_class: () -> Class?
14
+ def self.output_schema: () -> (Class | Hash[Symbol, Class])?
15
15
  end
16
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: senro_usecaser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shogo Kawahara