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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +14 -0
- data/README.md +153 -18
- data/lib/ruby_reactor/configuration.rb +13 -1
- data/lib/ruby_reactor/context.rb +2 -1
- data/lib/ruby_reactor/context_serializer.rb +1 -3
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +19 -28
- data/lib/ruby_reactor/dsl/reactor.rb +44 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
- data/lib/ruby_reactor/executor/step_executor.rb +46 -23
- data/lib/ruby_reactor/executor.rb +188 -49
- data/lib/ruby_reactor/map/collector.rb +4 -4
- data/lib/ruby_reactor/map/element_executor.rb +15 -1
- data/lib/ruby_reactor/map/helpers.rb +17 -4
- data/lib/ruby_reactor/middleware.rb +13 -0
- data/lib/ruby_reactor/middleware_runner.rb +29 -0
- data/lib/ruby_reactor/open_telemetry.rb +647 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
- data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
- data/lib/ruby_reactor/step/map_step.rb +25 -33
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/coordination_serializer.rb +12 -18
- data/teley/Dockerfile +60 -0
- metadata +6 -3
- data/lib/ruby_reactor/map/execution.rb +0 -101
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54ecb36ac72eedf48af0025dff29d83986449586683300ce3fe8fd874c1412d5
|
|
4
|
+
data.tar.gz: 5e3565e3e238bad746d93982ca7b01560893a68755be18fbfce95df6f54ce5b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 708acdb0c74582cea4c33210ea1bac055080c7dd19c69d166db4b2c22705de2935346d6b09d4fc6c9cbb1bda5ba235cade92a88f04fe791e364bb78bda256138
|
|
7
|
+
data.tar.gz: e3f03e46d71babe276224571eaad3e794c7d8c695e2865333f5021e680feb21a12cd3b33527035e1349dc600d01faec87b573b691d7e50521c4f0d81610c8b8f
|
data/.rubocop.yml
CHANGED
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
end
|
|
549
|
+
input :user
|
|
550
|
+
input :payload, redact: true
|
|
551
|
+
```
|
|
521
552
|
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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,
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
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
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
&
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 <<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|