senro_usecaser 0.1.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: da485bb1bbc5f14668b4e79b4e3a1e840e4e749a2925cc80ad286e2df33732b7
4
- data.tar.gz: 5313ff78401d64d823d6691f9c659c58bcf357843e9ee97c3636a584cdf99635
3
+ metadata.gz: cbfc07ede41cc295ceb0a019774a480744b226e88edc46f72a6447d59375f4e4
4
+ data.tar.gz: b86aaade8ccc9f2479f18d0f3bd5204203718ce26740eb3a51b5cccf1ed06798
5
5
  SHA512:
6
- metadata.gz: 31e26879fc113e25048d9fde410066837502697308ca6973fe313417fe6cd6be198ef9917c9e5110ae51085032f683d66f920642557e2dd30a179af4aecaa1d7
7
- data.tar.gz: 3957e5c91c088dde9e692101ca8db16840289caedb22bb9bf15a242a31da07898124841582ed8ec8eb9c4699e0b64d2805d04c52683245f51341d64cbc57b768
6
+ metadata.gz: 2ce95671ffefc951140c3625db940f1b077fe8bf40138d790b25067ab010a087d9f96b6c2e31bab2819ea89f0b2e6e905c70288f9409e24782cd73a0bca832cd
7
+ data.tar.gz: aa3b27b6eff694929f8cfc33ca47e5d7fb450eeb4ac5da9fecba2023cc70393a2edfa1b033e89d0fe0f0e1abe5cda3817841e8b92e04294062c0cd4a02ef8879
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
31
+ ## [0.2.0] - 2026-01-31
32
+
33
+ ### Added
34
+
35
+ - **Hook class with dependency injection** - New `SenroUsecaser::Hook` base class for creating hooks with `depends_on` support
36
+ - Supports `namespace` declaration for scoped dependency resolution
37
+ - Supports `infer_namespace_from_module` configuration
38
+ - Can inherit namespace from the UseCase when not explicitly declared
39
+ - **Block hooks can access dependencies**
40
+ - `before` and `after` blocks now run in instance context via `instance_exec`
41
+ - Dependencies declared with `depends_on` are directly accessible in block hooks
42
+
43
+ ### Changed
44
+
45
+ - **Around block signature** - Changed from `|input, &block|` to `|input, use_case, &block|`
46
+ - The `use_case` argument provides access to dependencies
47
+ - **Input class is now mandatory** - All UseCases must define an `input` class
48
+ - **Renamed `context` to `input`** - Hook parameters renamed for clarity
49
+
50
+ ## [0.1.0] - 2026-01-30
51
+
52
+ - Initial release
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.
@@ -659,20 +832,30 @@ end
659
832
 
660
833
  ##### Block Syntax
661
834
 
835
+ Block hooks are executed in the UseCase instance context, allowing access to `depends_on` dependencies.
836
+
662
837
  ```ruby
663
838
  class CreateUserUseCase < SenroUsecaser::Base
839
+ depends_on :logger
840
+ depends_on :metrics
841
+ input Input
842
+
843
+ # before/after blocks can access dependencies directly
664
844
  before do |input|
665
- # runs before call
845
+ logger.info("Starting with #{input.class.name}")
666
846
  end
667
847
 
668
848
  after do |input, result|
669
- # runs after call
849
+ logger.info("Finished: #{result.success? ? 'success' : 'failure'}")
850
+ metrics.increment(:use_case_completed)
670
851
  end
671
852
 
672
- around do |input, &block|
673
- ActiveRecord::Base.transaction do
674
- block.call
675
- end
853
+ # around block receives use_case as second argument for dependency access
854
+ around do |input, use_case, &block|
855
+ use_case.logger.info("Transaction start")
856
+ result = ActiveRecord::Base.transaction { block.call }
857
+ use_case.logger.info("Transaction end")
858
+ result
676
859
  end
677
860
 
678
861
  def call(input)
@@ -681,6 +864,77 @@ class CreateUserUseCase < SenroUsecaser::Base
681
864
  end
682
865
  ```
683
866
 
867
+ ##### Hook Classes
868
+
869
+ For more complex hooks with their own dependencies, use `SenroUsecaser::Hook` class:
870
+
871
+ ```ruby
872
+ class LoggingHook < SenroUsecaser::Hook
873
+ depends_on :logger
874
+ depends_on :metrics
875
+
876
+ def before(input)
877
+ logger.info("Starting with #{input.class.name}")
878
+ end
879
+
880
+ def after(input, result)
881
+ logger.info("Finished: #{result.success? ? 'success' : 'failure'}")
882
+ metrics.increment(:use_case_completed)
883
+ end
884
+
885
+ def around(input)
886
+ logger.info("Around start")
887
+ result = yield
888
+ logger.info("Around end")
889
+ result
890
+ end
891
+ end
892
+
893
+ class CreateUserUseCase < SenroUsecaser::Base
894
+ extend_with LoggingHook
895
+
896
+ def call(input)
897
+ # main logic
898
+ end
899
+ end
900
+ ```
901
+
902
+ Hook classes support:
903
+ - `depends_on` for dependency injection
904
+ - `namespace` for scoped dependency resolution
905
+ - Automatic namespace inference from module structure (when `infer_namespace_from_module` is enabled)
906
+ - Inheriting namespace from the UseCase if not explicitly declared
907
+
908
+ ```ruby
909
+ # Hook with explicit namespace
910
+ class Admin::AuditHook < SenroUsecaser::Hook
911
+ namespace :admin
912
+ depends_on :audit_logger
913
+
914
+ def after(input, result)
915
+ audit_logger.log(action: "create", success: result.success?)
916
+ end
917
+ end
918
+
919
+ # Hook inheriting namespace from UseCase
920
+ class MetricsHook < SenroUsecaser::Hook
921
+ depends_on :metrics # resolved from UseCase's namespace
922
+
923
+ def after(input, result)
924
+ metrics.increment(:completed)
925
+ end
926
+ end
927
+
928
+ class Admin::CreateUserUseCase < SenroUsecaser::Base
929
+ namespace :admin
930
+ extend_with MetricsHook # metrics resolved from :admin namespace
931
+
932
+ def call(input)
933
+ # ...
934
+ end
935
+ end
936
+ ```
937
+
684
938
  ##### Input/Output Validation
685
939
 
686
940
  Use `extend_with` to integrate validation libraries like ActiveModel::Validations:
@@ -926,6 +1180,28 @@ end
926
1180
 
927
1181
  Use `.call!` when you want to ensure all exceptions are captured as `Result.failure` without explicit rescue blocks in your UseCase.
928
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
+
929
1205
  #### Exception Handling in Pipelines
930
1206
 
931
1207
  When using `.call!` with `organize` pipelines, the exception capture behavior is **chained** to all steps. This is especially useful with `on_failure: :collect`:
@@ -262,8 +262,9 @@ module SenroUsecaser
262
262
  end
263
263
 
264
264
  # Adds an around hook
265
+ # Block receives (input, use_case, &block) where use_case allows access to dependencies
265
266
  #
266
- #: () { (untyped) { () -> Result[untyped] } -> Result[untyped] } -> void
267
+ #: () { (untyped, Base) { () -> Result[untyped] } -> Result[untyped] } -> void
267
268
  def around(&block)
268
269
  around_hooks << block if block
269
270
  end
@@ -275,17 +276,41 @@ module SenroUsecaser
275
276
  @around_hooks ||= []
276
277
  end
277
278
 
278
- # 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
279
281
  #
280
- #: (Class) -> void
281
- def input(type)
282
- @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
283
294
  end
284
295
 
285
- # Returns the input class
296
+ # Returns the input types as an array
286
297
  #
287
- #: () -> Class?
288
- attr_reader :input_class
298
+ #: () -> Array[Module]
299
+ def input_types
300
+ @input_types || []
301
+ end
302
+
303
+ # Returns the input class (for backwards compatibility)
304
+ # If a Class is specified, returns it. Otherwise returns the first type.
305
+ #
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
289
314
 
290
315
  # Declares the expected output type for this UseCase
291
316
  #
@@ -339,7 +364,7 @@ module SenroUsecaser
339
364
  subclass.instance_variable_set(:@use_case_namespace, @use_case_namespace)
340
365
  subclass.instance_variable_set(:@organized_steps, @organized_steps&.dup)
341
366
  subclass.instance_variable_set(:@on_failure_strategy, @on_failure_strategy)
342
- subclass.instance_variable_set(:@input_class, @input_class)
367
+ subclass.instance_variable_set(:@input_types, @input_types&.dup)
343
368
  subclass.instance_variable_set(:@output_schema, @output_schema)
344
369
  end
345
370
 
@@ -371,6 +396,8 @@ module SenroUsecaser
371
396
  raise ArgumentError, "#{self.class.name} must define `input` class"
372
397
  end
373
398
 
399
+ validate_input!(input)
400
+
374
401
  execute_with_hooks(input) do
375
402
  call(input)
376
403
  end
@@ -415,6 +442,48 @@ module SenroUsecaser
415
442
  Result.capture(*exception_classes, code: code, &)
416
443
  end
417
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
+
418
487
  # Executes the core logic with before/after/around hooks
419
488
  #
420
489
  #: (untyped) { () -> Result[untyped] } -> Result[untyped]
@@ -422,6 +491,7 @@ module SenroUsecaser
422
491
  execution = build_around_chain(input, core_block)
423
492
  run_before_hooks(input)
424
493
  result = execution.call
494
+ validate_output!(result)
425
495
  run_after_hooks(input, result)
426
496
  result
427
497
  end
@@ -440,22 +510,39 @@ module SenroUsecaser
440
510
  #: (untyped, Proc) -> Proc
441
511
  def build_around_chain(input, core_block)
442
512
  wrapped_core = -> { wrap_result(core_block.call) }
443
- all_around_hooks = collect_around_hooks
513
+ chain = wrap_extension_around_hooks(input, wrapped_core)
514
+ wrap_block_around_hooks(input, chain)
515
+ end
444
516
 
445
- all_around_hooks.reverse.reduce(wrapped_core) do |inner, hook|
517
+ # Wraps extension/module around hooks
518
+ #
519
+ #: (untyped, Proc) -> Proc
520
+ def wrap_extension_around_hooks(input, chain)
521
+ collect_extension_around_hooks.reverse.reduce(chain) do |inner, hook|
446
522
  -> { wrap_result(hook.call(input) { inner.call }) }
447
523
  end
448
524
  end
449
525
 
450
- # Collects all around hooks from extensions and block-based hooks
526
+ # Wraps block-based around hooks (pass self as second argument)
527
+ #
528
+ #: (untyped, Proc) -> Proc
529
+ def wrap_block_around_hooks(input, chain)
530
+ use_case_instance = self
531
+ self.class.around_hooks.reverse.reduce(chain) do |inner, hook|
532
+ -> { wrap_result(hook.call(input, use_case_instance) { inner.call }) }
533
+ end
534
+ end
535
+
536
+ # Collects around hooks from Hook classes and extension modules (not block-based)
451
537
  #
452
538
  #: () -> Array[Proc]
453
- def collect_around_hooks
454
- hooks = [] #: Array[Proc]
539
+ def collect_extension_around_hooks
540
+ hooks = hook_instances.map { |hook_instance| hook_instance.method(:around).to_proc }
455
541
  self.class.extensions.each do |ext|
542
+ next if hook_class?(ext)
543
+
456
544
  hooks << ext.method(:around).to_proc if ext.respond_to?(:around)
457
545
  end
458
- hooks.concat(self.class.around_hooks)
459
546
  hooks
460
547
  end
461
548
 
@@ -463,20 +550,49 @@ module SenroUsecaser
463
550
  #
464
551
  #: (untyped) -> void
465
552
  def run_before_hooks(input)
553
+ hook_instances.each do |hook_instance|
554
+ hook_instance.before(input)
555
+ end
466
556
  self.class.extensions.each do |ext|
557
+ next if hook_class?(ext)
558
+
467
559
  ext.send(:before, input) if ext.respond_to?(:before)
468
560
  end
469
- self.class.before_hooks.each { |hook| hook.call(input) }
561
+ self.class.before_hooks.each { |hook| instance_exec(input, &hook) } # steep:ignore BlockTypeMismatch
470
562
  end
471
563
 
472
564
  # Runs all after hooks
473
565
  #
474
566
  #: (untyped, Result[untyped]) -> void
475
567
  def run_after_hooks(input, result)
568
+ hook_instances.each do |hook_instance|
569
+ hook_instance.after(input, result)
570
+ end
476
571
  self.class.extensions.each do |ext|
572
+ next if hook_class?(ext)
573
+
477
574
  ext.send(:after, input, result) if ext.respond_to?(:after)
478
575
  end
479
- self.class.after_hooks.each { |hook| hook.call(input, result) }
576
+ self.class.after_hooks.each { |hook| instance_exec(input, result, &hook) } # steep:ignore BlockTypeMismatch
577
+ end
578
+
579
+ # Returns instantiated hook objects
580
+ #
581
+ #: () -> Array[Hook]
582
+ def hook_instances
583
+ @hook_instances ||= self.class.extensions.filter_map do |ext|
584
+ next unless hook_class?(ext)
585
+
586
+ hook_class = ext #: singleton(Hook)
587
+ hook_class.new(container: @_container, use_case_namespace: effective_namespace)
588
+ end
589
+ end
590
+
591
+ # Checks if the extension is a Hook class
592
+ #
593
+ #: (untyped) -> bool
594
+ def hook_class?(ext)
595
+ ext.is_a?(Class) && ext < Hook
480
596
  end
481
597
 
482
598
  # Resolves dependencies from the container
@@ -645,12 +761,12 @@ module SenroUsecaser
645
761
  end
646
762
 
647
763
  # Calls a single UseCase in the pipeline
648
- # Requires input_class to be defined for pipeline steps
764
+ # Requires input type(s) to be defined for pipeline steps
649
765
  #
650
766
  #: (singleton(Base), untyped) -> Result[untyped]
651
767
  def call_use_case(use_case_class, input)
652
- unless use_case_class.input_class
653
- 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"
654
770
  end
655
771
 
656
772
  call_method = @_capture_exceptions || false ? :call! : :call #: Symbol
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module SenroUsecaser
6
+ # Base class for hooks with dependency injection support
7
+ #
8
+ # Hook classes provide a way to define before/after/around hooks
9
+ # with access to the DI container and automatic dependency resolution.
10
+ #
11
+ # @example Basic hook
12
+ # class LoggingHook < SenroUsecaser::Hook
13
+ # depends_on :logger, Logger
14
+ #
15
+ # def before(input)
16
+ # logger.info("Starting with #{input.class.name}")
17
+ # end
18
+ #
19
+ # def after(input, result)
20
+ # logger.info("Finished: #{result.success? ? 'success' : 'failure'}")
21
+ # end
22
+ # end
23
+ #
24
+ # @example Hook with namespace
25
+ # class Admin::AuditHook < SenroUsecaser::Hook
26
+ # namespace :admin
27
+ # depends_on :audit_logger, AuditLogger
28
+ #
29
+ # def after(input, result)
30
+ # audit_logger.log(input: input, result: result)
31
+ # end
32
+ # end
33
+ #
34
+ # @example Hook with around
35
+ # class TransactionHook < SenroUsecaser::Hook
36
+ # def around(input)
37
+ # ActiveRecord::Base.transaction { yield }
38
+ # end
39
+ # end
40
+ class Hook
41
+ class << self
42
+ # Declares a dependency to be injected from the container
43
+ #
44
+ #: (Symbol, ?Class) -> void
45
+ def depends_on(name, type = nil)
46
+ dependencies << name unless dependencies.include?(name)
47
+ dependency_types[name] = type if type
48
+
49
+ define_method(name) do
50
+ @_dependencies[name]
51
+ end
52
+ end
53
+
54
+ # Returns the list of declared dependencies
55
+ #
56
+ #: () -> Array[Symbol]
57
+ def dependencies
58
+ @dependencies ||= []
59
+ end
60
+
61
+ # Returns the dependency type mapping
62
+ #
63
+ #: () -> Hash[Symbol, Class]
64
+ def dependency_types
65
+ @dependency_types ||= {}
66
+ end
67
+
68
+ # Sets or returns the namespace for dependency resolution
69
+ #
70
+ #: (?(Symbol | String)) -> (Symbol | String)?
71
+ def namespace(name = nil)
72
+ if name
73
+ @hook_namespace = name
74
+ else
75
+ @hook_namespace
76
+ end
77
+ end
78
+
79
+ # Alias for namespace() without arguments
80
+ #
81
+ #: () -> (Symbol | String)?
82
+ def hook_namespace # rubocop:disable Style/TrivialAccessors
83
+ @hook_namespace
84
+ end
85
+
86
+ # @api private
87
+ def inherited(subclass)
88
+ super
89
+ subclass.instance_variable_set(:@dependencies, dependencies.dup)
90
+ subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
91
+ subclass.instance_variable_set(:@hook_namespace, @hook_namespace)
92
+ end
93
+ end
94
+
95
+ # Initializes the hook with dependencies resolved from the container
96
+ #
97
+ #: (container: Container, ?use_case_namespace: (Symbol | String)?) -> void
98
+ def initialize(container:, use_case_namespace: nil)
99
+ @_container = container
100
+ @_use_case_namespace = use_case_namespace
101
+ @_dependencies = {} #: Hash[Symbol, untyped]
102
+
103
+ resolve_dependencies
104
+ end
105
+
106
+ # Called before the UseCase executes
107
+ # Override in subclass to add before logic
108
+ #
109
+ #: (untyped) -> void
110
+ def before(input)
111
+ # Override in subclass
112
+ end
113
+
114
+ # Called after the UseCase executes
115
+ # Override in subclass to add after logic
116
+ #
117
+ #: (untyped, Result[untyped]) -> void
118
+ def after(input, result)
119
+ # Override in subclass
120
+ end
121
+
122
+ # Wraps the UseCase execution
123
+ # Override in subclass to add around logic
124
+ #
125
+ #: (untyped) { () -> Result[untyped] } -> Result[untyped]
126
+ def around(_input)
127
+ yield
128
+ end
129
+
130
+ private
131
+
132
+ # Returns the effective namespace for dependency resolution
133
+ #
134
+ #: () -> (Symbol | String)?
135
+ def effective_namespace
136
+ return self.class.hook_namespace if self.class.hook_namespace
137
+ return @_use_case_namespace if @_use_case_namespace
138
+ return nil unless SenroUsecaser.configuration.infer_namespace_from_module
139
+
140
+ infer_namespace_from_class
141
+ end
142
+
143
+ # Infers namespace from the class's module structure
144
+ #
145
+ #: () -> String?
146
+ def infer_namespace_from_class
147
+ class_name = self.class.name
148
+ return nil unless class_name
149
+
150
+ parts = class_name.split("::")
151
+ return nil if parts.length <= 1
152
+
153
+ module_parts = parts[0...-1] || [] #: Array[String]
154
+ return nil if module_parts.empty?
155
+
156
+ module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
157
+ end
158
+
159
+ # Resolves dependencies from the container
160
+ #
161
+ #: () -> void
162
+ def resolve_dependencies
163
+ self.class.dependencies.each do |name|
164
+ @_dependencies[name] = resolve_from_container(name)
165
+ end
166
+ end
167
+
168
+ # Resolves a single dependency from the container
169
+ #
170
+ #: (Symbol) -> untyped
171
+ def resolve_from_container(name)
172
+ namespace = effective_namespace
173
+ if namespace
174
+ @_container.resolve_in(namespace, name)
175
+ else
176
+ @_container.resolve(name)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -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.1.0"
7
+ VERSION = "0.3.0"
8
8
  end
@@ -8,6 +8,7 @@ require_relative "senro_usecaser/result"
8
8
  require_relative "senro_usecaser/container"
9
9
  require_relative "senro_usecaser/configuration"
10
10
  require_relative "senro_usecaser/provider"
11
+ require_relative "senro_usecaser/hook"
11
12
  require_relative "senro_usecaser/base"
12
13
 
13
14
  # SenroUsecaser is a type-safe UseCase pattern implementation library for Ruby.
@@ -178,8 +178,9 @@ module SenroUsecaser
178
178
  def self.after_hooks: () -> Array[Proc]
179
179
 
180
180
  # Adds an around hook
181
+ # Block receives (input, use_case, &block) where use_case allows access to dependencies
181
182
  #
182
- # : () { (untyped) { () -> Result[untyped] } -> Result[untyped] } -> void
183
+ # : () { (untyped, Base) { () -> Result[untyped] } -> Result[untyped] } -> void
183
184
  def self.around: () ?{ (?) -> untyped } -> untyped
184
185
 
185
186
  # Returns the list of around hooks
@@ -187,15 +188,31 @@ module SenroUsecaser
187
188
  # : () -> Array[Proc]
188
189
  def self.around_hooks: () -> Array[Proc]
189
190
 
190
- # 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
191
193
  #
192
- # : (Class) -> void
193
- 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
194
205
 
195
- # Returns the input class
206
+ # Returns the input types as an array
196
207
  #
197
- # : () -> Class?
198
- attr_reader input_class: untyped
208
+ # : () -> Array[Module]
209
+ def self.input_types: () -> Array[Module]
210
+
211
+ # Returns the input class (for backwards compatibility)
212
+ # If a Class is specified, returns it. Otherwise returns the first type.
213
+ #
214
+ # : () -> Module?
215
+ def self.input_class: () -> Module?
199
216
 
200
217
  # Declares the expected output type for this UseCase
201
218
  #
@@ -266,6 +283,19 @@ module SenroUsecaser
266
283
  # : [T] (*Class, ?code: Symbol) { () -> T } -> Result[T]
267
284
  def capture: [T] (*Class, ?code: Symbol) { () -> T } -> Result[T]
268
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
+
269
299
  # Executes the core logic with before/after/around hooks
270
300
  #
271
301
  # : (untyped) { () -> Result[untyped] } -> Result[untyped]
@@ -281,10 +311,20 @@ module SenroUsecaser
281
311
  # : (untyped, Proc) -> Proc
282
312
  def build_around_chain: (untyped, Proc) -> Proc
283
313
 
284
- # Collects all around hooks from extensions and block-based hooks
314
+ # Wraps extension/module around hooks
315
+ #
316
+ # : (untyped, Proc) -> Proc
317
+ def wrap_extension_around_hooks: (untyped, Proc) -> Proc
318
+
319
+ # Wraps block-based around hooks (pass self as second argument)
320
+ #
321
+ # : (untyped, Proc) -> Proc
322
+ def wrap_block_around_hooks: (untyped, Proc) -> Proc
323
+
324
+ # Collects around hooks from Hook classes and extension modules (not block-based)
285
325
  #
286
326
  # : () -> Array[Proc]
287
- def collect_around_hooks: () -> Array[Proc]
327
+ def collect_extension_around_hooks: () -> Array[Proc]
288
328
 
289
329
  # Runs all before hooks
290
330
  #
@@ -296,6 +336,16 @@ module SenroUsecaser
296
336
  # : (untyped, Result[untyped]) -> void
297
337
  def run_after_hooks: (untyped, Result[untyped]) -> void
298
338
 
339
+ # Returns instantiated hook objects
340
+ #
341
+ # : () -> Array[Hook]
342
+ def hook_instances: () -> Array[Hook]
343
+
344
+ # Checks if the extension is a Hook class
345
+ #
346
+ # : (untyped) -> bool
347
+ def hook_class?: (untyped) -> bool
348
+
299
349
  # Resolves dependencies from the container
300
350
  #
301
351
  # : (Container, Hash[Symbol, untyped]) -> void
@@ -357,7 +407,7 @@ module SenroUsecaser
357
407
  def step_should_stop?: (Step) -> bool
358
408
 
359
409
  # Calls a single UseCase in the pipeline
360
- # Requires input_class to be defined for pipeline steps
410
+ # Requires input type(s) to be defined for pipeline steps
361
411
  #
362
412
  # : (singleton(Base), untyped) -> Result[untyped]
363
413
  def call_use_case: (singleton(Base), untyped) -> Result[untyped]
@@ -0,0 +1,112 @@
1
+ # Generated from lib/senro_usecaser/hook.rb with RBS::Inline
2
+
3
+ module SenroUsecaser
4
+ # Base class for hooks with dependency injection support
5
+ #
6
+ # Hook classes provide a way to define before/after/around hooks
7
+ # with access to the DI container and automatic dependency resolution.
8
+ #
9
+ # @example Basic hook
10
+ # class LoggingHook < SenroUsecaser::Hook
11
+ # depends_on :logger, Logger
12
+ #
13
+ # def before(input)
14
+ # logger.info("Starting with #{input.class.name}")
15
+ # end
16
+ #
17
+ # def after(input, result)
18
+ # logger.info("Finished: #{result.success? ? 'success' : 'failure'}")
19
+ # end
20
+ # end
21
+ #
22
+ # @example Hook with namespace
23
+ # class Admin::AuditHook < SenroUsecaser::Hook
24
+ # namespace :admin
25
+ # depends_on :audit_logger, AuditLogger
26
+ #
27
+ # def after(input, result)
28
+ # audit_logger.log(input: input, result: result)
29
+ # end
30
+ # end
31
+ #
32
+ # @example Hook with around
33
+ # class TransactionHook < SenroUsecaser::Hook
34
+ # def around(input)
35
+ # ActiveRecord::Base.transaction { yield }
36
+ # end
37
+ # end
38
+ class Hook
39
+ # Declares a dependency to be injected from the container
40
+ #
41
+ # : (Symbol, ?Class) -> void
42
+ def self.depends_on: (Symbol, ?Class) -> void
43
+
44
+ # Returns the list of declared dependencies
45
+ #
46
+ # : () -> Array[Symbol]
47
+ def self.dependencies: () -> Array[Symbol]
48
+
49
+ # Returns the dependency type mapping
50
+ #
51
+ # : () -> Hash[Symbol, Class]
52
+ def self.dependency_types: () -> Hash[Symbol, Class]
53
+
54
+ # Sets or returns the namespace for dependency resolution
55
+ #
56
+ # : (?(Symbol | String)) -> (Symbol | String)?
57
+ def self.namespace: (?Symbol | String) -> (Symbol | String)?
58
+
59
+ # Alias for namespace() without arguments
60
+ #
61
+ # : () -> (Symbol | String)?
62
+ def self.hook_namespace: () -> (Symbol | String)?
63
+
64
+ # @api private
65
+ def self.inherited: (untyped subclass) -> untyped
66
+
67
+ # Initializes the hook with dependencies resolved from the container
68
+ #
69
+ # : (container: Container, ?use_case_namespace: (Symbol | String)?) -> void
70
+ def initialize: (container: Container, ?use_case_namespace: (Symbol | String)?) -> void
71
+
72
+ # Called before the UseCase executes
73
+ # Override in subclass to add before logic
74
+ #
75
+ # : (untyped) -> void
76
+ def before: (untyped) -> void
77
+
78
+ # Called after the UseCase executes
79
+ # Override in subclass to add after logic
80
+ #
81
+ # : (untyped, Result[untyped]) -> void
82
+ def after: (untyped, Result[untyped]) -> void
83
+
84
+ # Wraps the UseCase execution
85
+ # Override in subclass to add around logic
86
+ #
87
+ # : (untyped) { () -> Result[untyped] } -> Result[untyped]
88
+ def around: (untyped) { () -> Result[untyped] } -> Result[untyped]
89
+
90
+ private
91
+
92
+ # Returns the effective namespace for dependency resolution
93
+ #
94
+ # : () -> (Symbol | String)?
95
+ def effective_namespace: () -> (Symbol | String)?
96
+
97
+ # Infers namespace from the class's module structure
98
+ #
99
+ # : () -> String?
100
+ def infer_namespace_from_class: () -> String?
101
+
102
+ # Resolves dependencies from the container
103
+ #
104
+ # : () -> void
105
+ def resolve_dependencies: () -> void
106
+
107
+ # Resolves a single dependency from the container
108
+ #
109
+ # : (Symbol) -> untyped
110
+ def resolve_from_container: (Symbol) -> untyped
111
+ end
112
+ end
@@ -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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shogo Kawahara
@@ -22,6 +22,7 @@ extra_rdoc_files: []
22
22
  files:
23
23
  - ".rspec"
24
24
  - ".rubocop.yml"
25
+ - CHANGELOG.md
25
26
  - LICENSE
26
27
  - README.md
27
28
  - Rakefile
@@ -37,6 +38,7 @@ files:
37
38
  - lib/senro_usecaser/configuration.rb
38
39
  - lib/senro_usecaser/container.rb
39
40
  - lib/senro_usecaser/error.rb
41
+ - lib/senro_usecaser/hook.rb
40
42
  - lib/senro_usecaser/provider.rb
41
43
  - lib/senro_usecaser/result.rb
42
44
  - lib/senro_usecaser/version.rb
@@ -45,6 +47,7 @@ files:
45
47
  - sig/generated/senro_usecaser/configuration.rbs
46
48
  - sig/generated/senro_usecaser/container.rbs
47
49
  - sig/generated/senro_usecaser/error.rbs
50
+ - sig/generated/senro_usecaser/hook.rbs
48
51
  - sig/generated/senro_usecaser/provider.rbs
49
52
  - sig/generated/senro_usecaser/result.rbs
50
53
  - sig/generated/senro_usecaser/version.rbs