ruby_reactor 0.4.1 → 0.5.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.rubocop.yml +1 -0
  4. data/CHANGELOG.md +14 -0
  5. data/README.md +153 -18
  6. data/lib/ruby_reactor/configuration.rb +13 -1
  7. data/lib/ruby_reactor/context.rb +2 -1
  8. data/lib/ruby_reactor/context_serializer.rb +1 -3
  9. data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
  10. data/lib/ruby_reactor/dsl/lockable.rb +19 -28
  11. data/lib/ruby_reactor/dsl/reactor.rb +44 -9
  12. data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
  13. data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
  14. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
  16. data/lib/ruby_reactor/executor/result_handler.rb +35 -8
  17. data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
  18. data/lib/ruby_reactor/executor/step_executor.rb +46 -23
  19. data/lib/ruby_reactor/executor.rb +188 -49
  20. data/lib/ruby_reactor/map/collector.rb +4 -4
  21. data/lib/ruby_reactor/map/element_executor.rb +15 -1
  22. data/lib/ruby_reactor/map/helpers.rb +17 -4
  23. data/lib/ruby_reactor/middleware.rb +13 -0
  24. data/lib/ruby_reactor/middleware_runner.rb +29 -0
  25. data/lib/ruby_reactor/open_telemetry.rb +647 -0
  26. data/lib/ruby_reactor/rate_limit.rb +28 -0
  27. data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
  28. data/lib/ruby_reactor/reactor.rb +1 -0
  29. data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
  30. data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
  31. data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
  32. data/lib/ruby_reactor/step/map_step.rb +25 -33
  33. data/lib/ruby_reactor/validation/base.rb +4 -1
  34. data/lib/ruby_reactor/validation/input_validator.rb +4 -2
  35. data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
  36. data/lib/ruby_reactor/version.rb +1 -1
  37. data/lib/ruby_reactor/web/coordination_serializer.rb +12 -18
  38. data/teley/Dockerfile +60 -0
  39. metadata +6 -3
  40. data/lib/ruby_reactor/map/execution.rb +0 -101
  41. data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +0 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 729d8c7da4954534a2775a360d79fc96326d76b5ca69c7361ca0433e4bd6571e
4
- data.tar.gz: e9e545d2ea937135f19c7c04697993125fdfc38d57fc71d6a7b0aede9bf5efa9
3
+ metadata.gz: 54ecb36ac72eedf48af0025dff29d83986449586683300ce3fe8fd874c1412d5
4
+ data.tar.gz: 5e3565e3e238bad746d93982ca7b01560893a68755be18fbfce95df6f54ce5b3
5
5
  SHA512:
6
- metadata.gz: 23aac29ebf4c4e018fc3ab4bffbc46d79fa68ea74d783034fb984094cb7d97e791fc194c767ab59fb4992be267c171f6cbcc1897fb6160df4bedeb88faa3f080
7
- data.tar.gz: edffc8600293e035e1d318a4f4b7f7240ec67aa06704e941465e06c6eadda16e1939c36e2a826bd0b92183728360cb3babab4b26b877548559f61a64f2977c4a
6
+ metadata.gz: 708acdb0c74582cea4c33210ea1bac055080c7dd19c69d166db4b2c22705de2935346d6b09d4fc6c9cbb1bda5ba235cade92a88f04fe791e364bb78bda256138
7
+ data.tar.gz: e3f03e46d71babe276224571eaad3e794c7d8c695e2865333f5021e680feb21a12cd3b33527035e1349dc600d01faec87b573b691d7e50521c4f0d81610c8b8f
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.4.1"
2
+ ".": "0.5.1"
3
3
  }
data/.rubocop.yml CHANGED
@@ -14,6 +14,7 @@ AllCops:
14
14
  - db/**/*
15
15
  - config/**/*
16
16
  - demo_app/**/*
17
+ - .ruby-lsp/**/*
17
18
 
18
19
  Style/StringLiterals:
19
20
  EnforcedStyle: double_quotes
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1](https://github.com/arturictus/ruby_reactor/compare/v0.5.0...v0.5.1) (2026-06-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * streamline input validation DSL and enhance error handling ([#35](https://github.com/arturictus/ruby_reactor/issues/35)) ([e32f3ec](https://github.com/arturictus/ruby_reactor/commit/e32f3ec91d87cf7a5060558ee705089f1dc76ca6))
9
+
10
+ ## [0.5.0](https://github.com/arturictus/ruby_reactor/compare/v0.4.1...v0.5.0) (2026-06-11)
11
+
12
+
13
+ ### Features
14
+
15
+ * Middlewares & OpenTelemetry ([#32](https://github.com/arturictus/ruby_reactor/issues/32)) ([a9e10ce](https://github.com/arturictus/ruby_reactor/commit/a9e10ceb6fa6381ead57a5905931343f8d1182d1))
16
+
3
17
  ## [0.4.1](https://github.com/arturictus/ruby_reactor/compare/v0.4.0...v0.4.1) (2026-05-25)
4
18
 
5
19
 
data/README.md CHANGED
@@ -110,6 +110,10 @@ RubyReactor.configure do |config|
110
110
  config.lock_snooze_jitter = 5
111
111
  config.lock_snooze_max_attempts = 20
112
112
 
113
+ # Named rate limits shared across reactors. Reference them with
114
+ # `with_rate_limit(:stripe)`. See Locks, Semaphores, Rate Limits & Periods.
115
+ config.rate_limits.register(:stripe, limits: { second: 3, minute: 100 })
116
+
113
117
  # Logger configuration
114
118
  config.logger = Logger.new($stdout)
115
119
  end
@@ -361,7 +365,7 @@ Coordinate across processes with Redis-backed primitives:
361
365
 
362
366
  - **`with_lock`** — at-most-one runner per key at a time (concurrency control).
363
367
  - **`with_semaphore`** — cap total concurrent runners per key (capacity control).
364
- - **`with_rate_limit`** — fixed-window rate limit, single or multi-window ("3/sec AND 100/min").
368
+ - **`with_rate_limit`** — fixed-window rate limit, single or multi-window ("3/sec AND 100/min"). Inline per-reactor, or reference a named limit registered once in `RubyReactor.configure` and shared across reactors.
365
369
  - **`with_period`** — run at most once per calendar bucket (dedup / once-per-day, once-per-month, etc).
366
370
 
367
371
  ```ruby
@@ -421,6 +425,28 @@ class ChargeReactor < RubyReactor::Reactor
421
425
  end
422
426
  ```
423
427
 
428
+ **Named global limits.** When several reactors hit the same external service, register the limit once and reference it by name. The name is the shared key base, so every reactor throttles against one bucket:
429
+
430
+ ```ruby
431
+ RubyReactor.configure do |config|
432
+ config.rate_limits.register(:stripe, limits: { second: 3, minute: 100 })
433
+ config.rate_limits.register(:twilio, limit: 10, period: :second)
434
+ end
435
+
436
+ class ChargeReactor < RubyReactor::Reactor
437
+ input :account_id
438
+
439
+ with_rate_limit(:stripe) # shared :stripe quota across every reactor
440
+
441
+ step :charge do
442
+ argument :account_id, input(:account_id)
443
+ run { |args| Stripe.charge(args[:account_id]) }
444
+ end
445
+ end
446
+ ```
447
+
448
+ Referencing an unregistered name raises `RubyReactor::RateLimitRegistry::UnknownLimitError`. Named limits hit the same enforcement path as inline ones, so async snooze behavior is identical.
449
+
424
450
  On contention:
425
451
 
426
452
  - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError`.
@@ -475,6 +501,8 @@ end
475
501
 
476
502
  By using `async true` with `batch_size`, the system applies **Back Pressure** to efficiently manage resources. [Read more about Back Pressure & Resource Management](documentation/data_pipelines.md#back-pressure--resource-management).
477
503
 
504
+ `batch_size` is optional: with `async true` alone, RubyReactor fans out one worker per element (defaulting the batch size to the full source size) and aggregates the outcomes into a `ResultEnumerator` — convenient for small collections, but with no back pressure. See [Async Without `batch_size`](documentation/data_pipelines.md#async-without-batch_size).
505
+
478
506
  #### Map with Dynamic Source (ActiveRecord)
479
507
 
480
508
  You can use a block for `source` to dynamically fetch data, such as from ActiveRecord queries. The result is wrapped in a `ResultEnumerator` for easy access to successes and failures.
@@ -511,26 +539,73 @@ end
511
539
 
512
540
  ### Input Validation
513
541
 
514
- RubyReactor integrates with dry-validation for input validation:
542
+ RubyReactor integrates with dry-validation for input validation. A single
543
+ `input` method escalates from a bare declaration to a full nested schema.
544
+ Name and optionality are declared once.
545
+
546
+ **Form 0 — declaration only** (no validation, value passes through as-is):
515
547
 
516
548
  ```ruby
517
- class ValidatedUserReactor < RubyReactor::Reactor
518
- input :name do
519
- required(:name).filled(:string, min_size?: 2)
520
- end
549
+ input :user
550
+ input :payload, redact: true
551
+ ```
521
552
 
522
- input :email do
523
- required(:email).filled(:string)
524
- end
553
+ **Form 1 — inline scalar** (the common case). The type is an optional
554
+ positional; any keyword ending in `?` is a dry-schema predicate:
525
555
 
526
- input :age do
527
- required(:age).filled(:integer, gteq?: 18)
528
- end
556
+ ```ruby
557
+ input :name, :string, min_size?: 2
558
+ input :email, :string, format?: /\A[^@\s]+@[^@\s]+\z/
559
+ input :age, :integer, gteq?: 18
560
+
561
+ # optional: true flips required(...).filled -> optional(...).maybe
562
+ input :bio, :string, optional: true, max_size?: 100
563
+ ```
529
564
 
530
- # Optional inputs
531
- input :bio, optional: true do
532
- optional(:bio).maybe(:string, max_size?: 100)
565
+ **Form 1b — class / module** maps to a `type?` instance check:
566
+
567
+ ```ruby
568
+ input :user, User # required(:user).filled(type?: User)
569
+ input :items, Array, min_size?: 1 # instance check + predicate
570
+ ```
571
+
572
+ **Form 2 — macro block** for nested / complex schemas. The block receives the
573
+ input's macro (already bound to the name and optionality), and because it is a
574
+ block argument your class constants and helpers stay reachable:
575
+
576
+ ```ruby
577
+ EMAIL = /\A[^@\s]+@[^@\s]+\z/
578
+
579
+ input :order do |i|
580
+ i.hash do
581
+ required(:customer).hash do
582
+ required(:email).filled(:string, format?: EMAIL)
583
+ end
584
+ required(:items).each do
585
+ schema { required(:product_id).filled(:string) }
586
+ end
533
587
  end
588
+ end
589
+ ```
590
+
591
+ **Form 3 — pre-built schema / contract** (also the home for coercing dry-types):
592
+
593
+ ```ruby
594
+ UserSchema = Dry::Schema.Params do
595
+ required(:user).hash { required(:email).filled(:string) }
596
+ end
597
+
598
+ input :user, validate: UserSchema
599
+ ```
600
+
601
+ Putting the inline forms together:
602
+
603
+ ```ruby
604
+ class ValidatedUserReactor < RubyReactor::Reactor
605
+ input :name, :string, min_size?: 2
606
+ input :email, :string
607
+ input :age, :integer, gteq?: 18
608
+ input :bio, :string, optional: true, max_size?: 100
534
609
 
535
610
  step :create_profile do
536
611
  argument :name, input(:name)
@@ -569,6 +644,62 @@ result = ValidatedUserReactor.run(
569
644
  )
570
645
  ```
571
646
 
647
+ All validation failures — inputs, step arguments, step output, and interrupt
648
+ payloads — surface uniformly as a `RubyReactor::Error::InputValidationError`
649
+ with a `field_errors` hash:
650
+
651
+ ```ruby
652
+ result = ValidatedUserReactor.run(name: "A")
653
+ result.error # => RubyReactor::Error::InputValidationError
654
+ result.error.field_errors[:name] # => "size cannot be less than 2"
655
+ ```
656
+
657
+ ### Step Argument & Output Validation
658
+
659
+ Arguments can be validated inline using the same forms as `input`. Inline rules
660
+ compose with a `validate_args` block (used for cross-field rules):
661
+
662
+ ```ruby
663
+ step :charge do
664
+ argument :amount, input(:amount), :decimal, gt?: 0
665
+ argument :currency, input(:currency), :string, included_in?: %w[USD EUR GBP]
666
+ argument :user, input(:user), User # type? instance check
667
+
668
+ # Optional cross-field block (composes with the inline rules above)
669
+ validate_args do
670
+ required(:amount).filled(:decimal, lt?: 10_000)
671
+ end
672
+
673
+ run { |args, _| charge!(args) }
674
+ end
675
+ ```
676
+
677
+ Output validation is scalar-aware — pass a type/predicates for a single value,
678
+ or a block for a hash output:
679
+
680
+ ```ruby
681
+ validate_output :integer, gteq?: 0 # scalar return value
682
+ validate_output do # hash return value
683
+ required(:id).filled(:string)
684
+ end
685
+ ```
686
+
687
+ ### Interrupt Payload Validation
688
+
689
+ Validate the payload supplied on resume with `validate_payload` (the older
690
+ `validate` is kept as a deprecated alias):
691
+
692
+ ```ruby
693
+ interrupt :await_approval do
694
+ wait_for :submit_request
695
+
696
+ validate_payload do
697
+ required(:approved).filled(:bool)
698
+ optional(:note).maybe(:string)
699
+ end
700
+ end
701
+ ```
702
+
572
703
  ### Complex Workflows with Dependencies
573
704
 
574
705
  Steps can depend on results from multiple other steps:
@@ -576,7 +707,7 @@ Steps can depend on results from multiple other steps:
576
707
  ```ruby
577
708
  class OrderProcessingReactor < RubyReactor::Reactor
578
709
  input :user_id
579
- input :product_ids, validate: ->(ids) { ids.is_a?(Array) && ids.any? }
710
+ input :product_ids, Array, min_size?: 1
580
711
 
581
712
  step :validate_user do
582
713
  argument :user_id, input(:user_id)
@@ -859,6 +990,10 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
859
990
 
860
991
  Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), and calendar-bucketed dedup (`with_period`, returning `Skipped` results). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, and once-per-day/month/year scheduling patterns.
861
992
 
993
+ ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
994
+
995
+ Hook into the execution lifecycle with observer middlewares. Covers the full set of lifecycle events (reactor, step, retry, compensation/undo, async hand-off, locks/semaphores), writing and registering custom middlewares (global and per-reactor), and the built-in `RubyReactor::OpenTelemetry` tracing middleware — span structure, input/argument redaction, distributed trace propagation across async/retry boundaries, and custom exporters.
996
+
862
997
  ### Examples
863
998
  - [Order Processing](documentation/examples/order_processing.md) - Complete order processing workflow example
864
999
  - [Payment Processing](documentation/examples/payment_processing.md) - Payment handling with compensation
@@ -871,7 +1006,7 @@ Coordinate access to shared resources across processes with Redis-backed primiti
871
1006
  - [X] `map` step to iterate over arrays in parallel
872
1007
  - [X] `compose` special step to execute reactors as step
873
1008
  - [X] `interrupt` to pause and resume reactors
874
- - [ ] Middlewares
1009
+ - [X] Middlewares
875
1010
  - [ ] Async ruby to parallelize same level steps
876
1011
  - [x] Web dashboard to inspect reactor results and errors
877
1012
  - [ ] Multiple storage adapters
@@ -880,7 +1015,7 @@ Coordinate access to shared resources across processes with Redis-backed primiti
880
1015
  - [ ] Multiple Async adapters
881
1016
  - [X] Sidekiq
882
1017
  - [ ] ActiveJob
883
- - [ ] OpenTelemetry support
1018
+ - [X] OpenTelemetry support
884
1019
  - [X] locks
885
1020
 
886
1021
  ## Development
@@ -8,7 +8,8 @@ module RubyReactor
8
8
  include Singleton
9
9
 
10
10
  attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router,
11
- :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts
11
+ :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts,
12
+ :middlewares
12
13
 
13
14
  def sidekiq_queue
14
15
  @sidekiq_queue ||= :default
@@ -54,5 +55,16 @@ module RubyReactor
54
55
  raise "Unknown storage adapter: #{storage.adapter}"
55
56
  end
56
57
  end
58
+
59
+ def middlewares
60
+ @middlewares ||= []
61
+ end
62
+
63
+ # Registry of named rate limits shared across reactors. Configure entries
64
+ # with `config.rate_limits.register(:name, ...)` and reference them from a
65
+ # reactor via `with_rate_limit(:name)`.
66
+ def rate_limits
67
+ @rate_limits ||= RubyReactor::RateLimitRegistry.new
68
+ end
57
69
  end
58
70
  end
@@ -5,7 +5,8 @@ module RubyReactor
5
5
  attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
6
  :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
7
7
  :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
8
- :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason
8
+ :cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
9
+ :middlewares
9
10
 
10
11
  def initialize(inputs = {}, reactor_class = nil)
11
12
  @context_id = SecureRandom.uuid
@@ -202,9 +202,7 @@ module RubyReactor
202
202
 
203
203
  hash = flatten_typed_failure(hash) if hash["_type"] == "Failure"
204
204
 
205
- if hash["code_snippet"].is_a?(Array) && !hash["code_snippet"].empty?
206
- return hash
207
- end
205
+ return hash if hash["code_snippet"].is_a?(Array) && !hash["code_snippet"].empty?
208
206
 
209
207
  file_path, line_number = resolve_failure_location(hash)
210
208
  return hash unless file_path && line_number
@@ -23,9 +23,25 @@ module RubyReactor
23
23
  @timeout_config = { duration: seconds, strategy: strategy }
24
24
  end
25
25
 
26
- def validate(&block)
26
+ # Validate the resume payload. Accepts a block (schema DSL) or a pre-built
27
+ # dry-schema. Payloads are multi-field hashes, so the block stays primary.
28
+ def validate_payload(schema = nil, &block)
27
29
  check_dry_validation_available!
28
- @validation_schema = build_validation_schema(&block)
30
+ @validation_schema =
31
+ if block
32
+ build_validation_schema(&block)
33
+ elsif schema
34
+ RubyReactor::Validation::SchemaBuilder.schema_for(schema)
35
+ end
36
+ end
37
+
38
+ # Deprecated alias for {#validate_payload}.
39
+ def validate(schema = nil, &block)
40
+ unless @warned_validate
41
+ @warned_validate = true
42
+ warn "[RubyReactor] DEPRECATION: interrupt `validate` is deprecated; use `validate_payload` instead."
43
+ end
44
+ validate_payload(schema, &block)
29
45
  end
30
46
 
31
47
  def build
@@ -86,44 +86,35 @@ module RubyReactor
86
86
  # limits: { second: 3, minute: 100, hour: 5000 }
87
87
  # ) { |i| "stripe:#{i[:account_id]}" }
88
88
  #
89
+ # @example Named global limit (registered in `RubyReactor.configure`)
90
+ # with_rate_limit(:stripe)
91
+ #
92
+ # @param name [Symbol] reference a rate limit registered via
93
+ # `config.rate_limits.register`. When given, the limit is shared
94
+ # across every reactor using that name (the name is the key base);
95
+ # no `limit:`/`period:`/`limits:` or block is accepted.
89
96
  # @param limit [Integer] requests per period (single-window form)
90
97
  # @param period [Symbol, Integer] :second / :minute / :hour / :day /
91
98
  # :week / :month / :year, or integer seconds (single-window form)
92
99
  # @param limits [Hash{Symbol,Integer => Integer}] mapping of period
93
100
  # unit to limit (multi-window form)
94
- # @yield [inputs] Block returning the rate-limit key base.
95
- def with_rate_limit(limit: nil, period: nil, limits: nil, &block)
96
- normalized = normalize_rate_limit_args(limit, period, limits)
101
+ # @yield [inputs] Block returning the rate-limit key base (inline forms).
102
+ def with_rate_limit(name = nil, limit: nil, period: nil, limits: nil, &block)
103
+ if name
104
+ if limit || period || limits || block
105
+ raise ArgumentError, "with_rate_limit(:#{name}) references a registered limit; " \
106
+ "do not also pass :limit/:period/:limits or a block"
107
+ end
108
+
109
+ @rate_limit_config = { name: name.to_sym }
110
+ return
111
+ end
97
112
 
98
113
  @rate_limit_config = {
99
- limits: normalized,
114
+ limits: RubyReactor::RateLimit.normalize_specs(limit: limit, period: period, limits: limits),
100
115
  key_proc: block
101
116
  }
102
117
  end
103
-
104
- private
105
-
106
- def normalize_rate_limit_args(limit, period, limits)
107
- if limits
108
- raise ArgumentError, "with_rate_limit: use either :limits, or :limit + :period, not both" if limit || period
109
-
110
- limits.map do |period_key, limit_val|
111
- {
112
- period_seconds: RubyReactor::Period.period_seconds(period_key),
113
- limit: Integer(limit_val),
114
- name: period_key.to_s
115
- }
116
- end
117
- elsif limit && period
118
- [{
119
- period_seconds: RubyReactor::Period.period_seconds(period),
120
- limit: Integer(limit),
121
- name: period.to_s
122
- }]
123
- else
124
- raise ArgumentError, "with_rate_limit requires :limit + :period, or :limits"
125
- end
126
- end
127
118
  end
128
119
  end
129
120
  end
@@ -62,8 +62,8 @@ module RubyReactor
62
62
  end
63
63
 
64
64
  # rubocop:disable Metrics/ParameterLists
65
- def input(name, transform: nil, description: nil, validate: nil, optional: false, redact: false,
66
- &validation_block)
65
+ def input(name, type = nil, transform: nil, description: nil, validate: nil, optional: false, redact: false,
66
+ **predicates, &block)
67
67
  # rubocop:enable Metrics/ParameterLists
68
68
  inputs[name] = {
69
69
  transform: transform,
@@ -72,12 +72,41 @@ module RubyReactor
72
72
  redact: redact
73
73
  }
74
74
 
75
- # Handle validation
76
- return unless validate || validation_block
75
+ validator = build_input_validator_for(name, type, optional, validate, predicates, &block)
76
+ input_validations[name] = validator if validator
77
+ end
78
+
79
+ # Dispatch across the layered `input` forms:
80
+ # Form 3 — pre-built schema / contract (`validate:`)
81
+ # Form 2 — block bound to the value macro (`do |i| ... end`)
82
+ # legacy — single-key schema block (`do required(:name)... end`)
83
+ # Form 1 / 1b — inline scalar or class type
84
+ # Form 0 — declaration only (no validator)
85
+ def build_input_validator_for(name, type, optional, validate, predicates, &block)
86
+ if validate
87
+ create_input_validator(validate)
88
+ elsif block
89
+ if block.arity.nonzero?
90
+ build_macro_validator(name, optional, &block)
91
+ else
92
+ warn_deprecated_input_block
93
+ create_input_validator(block)
94
+ end
95
+ elsif type || predicates.any?
96
+ build_inline_validator(name, type, optional, predicates)
97
+ end
98
+ end
99
+ private :build_input_validator_for
100
+
101
+ def warn_deprecated_input_block
102
+ return if @warned_input_block
77
103
 
78
- validator = create_input_validator(validation_block || validate)
79
- input_validations[name] = validator
104
+ @warned_input_block = true
105
+ warn "[RubyReactor] DEPRECATION: the single-key `input :name do required(:name)... end` block is " \
106
+ "deprecated. Use the inline form (`input :name, :string, min_size?: 2`) or the macro block " \
107
+ "(`input :name do |i| ... end`) instead."
80
108
  end
109
+ private :warn_deprecated_input_block
81
110
 
82
111
  def step(name, impl = nil, &block)
83
112
  builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
@@ -123,8 +152,12 @@ module RubyReactor
123
152
  @return_step
124
153
  end
125
154
 
126
- def middleware(middleware_class)
127
- middlewares << middleware_class
155
+ def middleware(middleware_class, **options)
156
+ middlewares << if options.empty?
157
+ middleware_class
158
+ else
159
+ [middleware_class, options]
160
+ end
128
161
  end
129
162
 
130
163
  def validate_inputs(inputs_hash)
@@ -145,7 +178,9 @@ module RubyReactor
145
178
  RubyReactor.Success(inputs_hash)
146
179
  else
147
180
  error = RubyReactor::Error::InputValidationError.new(errors)
148
- RubyReactor.Failure(error)
181
+ # Same shape as executor-built validation failures: expose the
182
+ # structured field errors on the Failure itself.
183
+ RubyReactor.Failure(error, validation_errors: errors, reactor_name: name)
149
184
  end
150
185
  end
151
186
 
@@ -20,17 +20,23 @@ module RubyReactor
20
20
  @conditions = []
21
21
  @guards = []
22
22
  @dependencies = []
23
+ @arg_validations = []
24
+ @validate_args_input = nil
23
25
  @args_validator = nil
24
26
  @output_validator = nil
25
27
  @async = false
26
28
  @retry_config = {}
27
29
  end
28
30
 
29
- def argument(name, source, transform: nil)
31
+ def argument(name, source, type = nil, transform: nil, **predicates)
30
32
  @arguments[name] = {
31
33
  source: source,
32
34
  transform: transform
33
35
  }
36
+
37
+ return unless type || predicates.any?
38
+
39
+ @arg_validations << [name, type, false, predicates]
34
40
  end
35
41
 
36
42
  def run(&block)
@@ -57,20 +63,26 @@ module RubyReactor
57
63
  @dependencies.concat(step_names)
58
64
  end
59
65
 
66
+ # Cross-field rules over the whole resolved argument hash. Composes with
67
+ # per-argument inline validations declared via `argument`; the block (or
68
+ # pre-built schema) is applied last and wins on conflicts.
60
69
  def validate_args(schema_or_validator = nil, &block)
61
- if block_given?
62
- @args_validator = build_input_validator(block)
63
- elsif schema_or_validator
64
- @args_validator = build_input_validator(schema_or_validator)
65
- end
70
+ @validate_args_input = block || schema_or_validator
66
71
  end
67
72
 
68
- def validate_output(schema_or_validator = nil, &block)
69
- if block_given?
70
- @output_validator = build_input_validator(block)
71
- elsif schema_or_validator
72
- @output_validator = build_input_validator(schema_or_validator)
73
- end
73
+ # Scalar-aware output validation.
74
+ # validate_output :integer, gteq?: 0 # single value
75
+ # validate_output do ... end # hash output
76
+ # validate_output SomeSchema # pre-built schema
77
+ def validate_output(type = nil, **predicates, &block)
78
+ @output_validator =
79
+ if block
80
+ create_input_validator(block)
81
+ elsif type.is_a?(Symbol) || type.is_a?(Module) || predicates.any?
82
+ build_scalar_validator(type, predicates)
83
+ elsif type
84
+ create_input_validator(type)
85
+ end
74
86
  end
75
87
 
76
88
  def async(async = true)
@@ -96,7 +108,7 @@ module RubyReactor
96
108
  conditions: @conditions,
97
109
  guards: @guards,
98
110
  dependencies: @dependencies,
99
- args_validator: @args_validator,
111
+ args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
100
112
  output_validator: @output_validator,
101
113
  async: @async,
102
114
  retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
@@ -104,32 +116,6 @@ module RubyReactor
104
116
 
105
117
  RubyReactor::Dsl::StepConfig.new(step_config)
106
118
  end
107
-
108
- private
109
-
110
- def build_input_validator(schema_or_block)
111
- check_dry_validation_available!
112
-
113
- schema = case schema_or_block
114
- when Proc
115
- build_validation_schema(&schema_or_block)
116
- else
117
- schema_or_block
118
- end
119
-
120
- RubyReactor::Validation::InputValidator.new(schema)
121
- end
122
-
123
- def build_validation_schema(&block)
124
- RubyReactor::Validation::SchemaBuilder.build_from_block(&block)
125
- end
126
-
127
- def check_dry_validation_available!
128
- return if defined?(Dry::Schema)
129
-
130
- raise LoadError,
131
- "dry-validation gem is required for validation features. Add 'gem \"dry-validation\"' to your Gemfile."
132
- end
133
119
  end
134
120
 
135
121
  class StepConfig
@@ -22,6 +22,40 @@ module RubyReactor
22
22
  RubyReactor::Validation::InputValidator.new(schema)
23
23
  end
24
24
 
25
+ # Form 1 / 1b — inline scalar or class type for a single named value.
26
+ def build_inline_validator(name, type, optional, predicates)
27
+ check_dry_validation_available!
28
+ schema = RubyReactor::Validation::SchemaBuilder.build_inline(name, type, optional, predicates)
29
+ RubyReactor::Validation::InputValidator.new(schema)
30
+ end
31
+
32
+ # Form 2 — block bound to the value's macro (`required`/`optional`).
33
+ def build_macro_validator(name, optional, &block)
34
+ check_dry_validation_available!
35
+ schema = RubyReactor::Validation::SchemaBuilder.build_macro(name, optional, &block)
36
+ RubyReactor::Validation::InputValidator.new(schema)
37
+ end
38
+
39
+ # Compose per-argument inline rules with an optional `validate_args`
40
+ # block / pre-built schema. Returns nil when there is nothing to validate.
41
+ def build_args_validator(inline_rules, validate_input)
42
+ return nil if inline_rules.empty? && validate_input.nil?
43
+
44
+ check_dry_validation_available!
45
+ schema = RubyReactor::Validation::SchemaBuilder.build_args(inline_rules, validate_input)
46
+ return nil unless schema
47
+
48
+ RubyReactor::Validation::InputValidator.new(schema)
49
+ end
50
+
51
+ # Scalar-aware single-value validator (used by `validate_output`). The
52
+ # value is wrapped under `:value` before validation.
53
+ def build_scalar_validator(type, predicates)
54
+ check_dry_validation_available!
55
+ schema = RubyReactor::Validation::SchemaBuilder.build_inline(:value, type, false, predicates)
56
+ RubyReactor::Validation::InputValidator.new(schema, wrap_key: :value)
57
+ end
58
+
25
59
  private
26
60
 
27
61
  def check_dry_validation_available!
@@ -4,6 +4,10 @@ module RubyReactor
4
4
  module Error
5
5
  class InputValidationError < Base
6
6
  attr_reader :field_errors
7
+ # Step attribution, set at the raise site when the failure happened at a
8
+ # step boundary (argument or output validation) rather than at reactor
9
+ # input validation. Nil for reactor-level input failures.
10
+ attr_accessor :step_name, :step_arguments
7
11
 
8
12
  def initialize(field_errors)
9
13
  @field_errors = field_errors