diff --git a/.gitignore b/.gitignore index 7020e048..32fb0cfb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /.bundle/ /.yardoc /_yardoc/ -/coverage/ +coverage/ /doc/ /pkg/ /builds/ diff --git a/0.6.0-announcement.md b/0.6.0-announcement.md new file mode 100644 index 00000000..07f032c7 --- /dev/null +++ b/0.6.0-announcement.md @@ -0,0 +1,73 @@ +# Servus v0.6.0 — Named async jobs + per-service `async` DSL + +## TL;DR + +`.call_async` now enqueues a **named ActiveJob class per service** instead of one generic +`Servus::Extensions::Async::Job` for everything. Your job dashboards (Sidekiq, GoodJob, …) will now +show the actual service that ran. There's also a new `async(...)` DSL to configure a service's job +(queue, priority, retries). **The way you call services is unchanged.** + +## What's new + +### Named job per service + +`Treasury::TransferGold::Service` now gets its own `Treasury::TransferGold::ServiceJob`, generated +automatically — you never write or reference it. Background runners display a meaningful per-service +name, so per-queue metrics, retries, and log filtering finally line up with the service that ran. + +### `async(...)` DSL for per-service job config + +Declare a service's ActiveJob options right in the class. Keyword shortcuts for the common cases, +plus a block (evaluated in the job's context) for the full ActiveJob API: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + async queue: :critical, priority: 10 + + async do + retry_on Gringotts::Timeout, wait: 5.seconds, attempts: 3 + discard_on ActiveJob::DeserializationError + end +end +``` + +These are **class-level defaults**. Options passed inline to `.call_async` layer on top per enqueue +and win for that one call: + +```ruby +# uses :critical by default… +Treasury::TransferGold::Service.call_async(from_account: 1, to_account: 2, gold_dragons: 50) + +# …but this single call goes to :low_priority +Treasury::TransferGold::Service.call_async(from_account: 1, to_account: 2, gold_dragons: 50, queue: :low_priority) +``` + +## Nothing changes in your calling code + +`Service.call_async(user_id: 123, queue: :critical, wait: 5.minutes)` works exactly as before — same +method, same signature, same option handling. A service that declares no `async` block still gets a +named job with ActiveJob defaults. + +## Upgrade considerations + +Two operational notes — neither requires code changes: + +1. **Drain your queues before deploying.** The enqueue payload changed from + `perform_later(name:, args:)` to `perform_later(**args)` (the job class now identifies the + service). Jobs enqueued by 0.5.x use the old shape and **will not run** after the upgrade. Let + queues empty before rolling out. + +2. **Workers must eager-load their services.** A job is resolved on the worker by its class name; + Servus defines each service's job when the service class loads, so Rails' production eager-loading + (the default) covers this. If you run workers with eager-loading off, make sure services get + referenced before their jobs run. + +Only relevant if you reached into extension internals: `Job#perform` now takes `(**args)` instead of +`(name:, args:)`, and `Servus::Extensions::Async::Errors::ServiceNotFoundError` was removed (services +are no longer resolved from a serialized name string). + +## Verified + +Full test suite green across Ruby 3.2–3.4, and exercised end-to-end in a real Rails 8.1 app — +including confirming that a production worker (`eager_load!`) can resolve each named job from its +serialized class name. diff --git a/1.0.0-announcement.md b/1.0.0-announcement.md new file mode 100644 index 00000000..bf528336 --- /dev/null +++ b/1.0.0-announcement.md @@ -0,0 +1,118 @@ +# Servus 1.0.0 — explicit over flexible + +PR: https://github.com/zarpay/servus/pull/54 + +## Why this release exists + +Servus accumulated flexibility over three years, and most of it was good judgement +at the time. Schemas could live in three places. Two helpers wrapped `.call` so you +didn't have to write `return result unless result.success?`. The `emits` DSL had +opinions about how many events you should emit. + +What changed isn't the code — it's who writes the code. When a model generates a +service, the thing that makes the output correct is **the contract being stated +where the work happens**. A schema resolved reflectively from a constant name, or +sitting in a sibling JSON file, isn't visible at the point of generation. Neither +is a helper that looks like a method call but is actually a non-local jump out of +the method. + +So 1.0 has a single thesis: **a service's behaviour should be readable from the +file that implements it.** Everything below follows from that. The flexibility +we're removing wasn't wrong — it just stopped paying for itself. + +The second principle, which shows up in the fixes more than the features: **fail +loudly, never silently.** Almost every bug found while doing this was the same +shape — something that *looked* declared but validated nothing. + +## What changed + +**Schemas are inline-only.** `ARGUMENTS_SCHEMA` constants and `app/schemas/**.json` +files are no longer resolved. Only the `schema` DSL. + +**Shared fragments replace the duplication that would otherwise cause.** Register a +fragment once, reference it with a standard JSON Schema `$ref`: + +```ruby +Servus::Schema.register('core', { '$defs' => { 'amount' => { 'type' => 'integer' } } }) + +schema arguments: { properties: { fee: { '$ref' => '#/core/$defs/amount' } } } +``` + +Lazy, memoized, and lookups never return `nil` — an unregistered key raises, naming +the key, the service, the ref chain, and the nearest match. The registry is +standalone, so controller request/response contracts can live in it too, and +`Servus::Schema.compile_all` emits the whole thing as one JSON asset. + +**`call!` and `run_service!` are removed.** Both read like ordinary method calls +while hiding a jump. + +**`emits` gained no syntax.** Multiple events per trigger, each with its own +payload, already worked — the DSL's own documentation just told people not to. That +note is gone and the pattern is documented. + +**Correctness fixes**, all the same failure class: + +- `schema arguments: nil` silently left a service unvalidated +- subclasses didn't inherit schemas +- the validator cache was keyed so `A::B::Service` and `A::B::Other` could share one +- `emits` skipped validation entirely when no Event class was registered, bypassing + `require_event_payload_schema` on exactly the events with no schema + +## What this means for core + +Numbers below are measured against core, not estimated. + +**The schema work costs core nothing.** `ApplicationService.schema_key` already +compiles into the native DSL, so we are pure-inline today. Zero legacy constants, +zero file-based schemas. + +**The real work is `call!`: 33 sites across 28 files.** Mechanical, but note the +shape change — `call!` returned `data`, `.call` returns the `Response`: + +```ruby +# before +transfer = call!(Treasury::TransferGold::Service, **args) +use(transfer.id) + +# after +result = Treasury::TransferGold::Service.call(**args) +return result unless result.success? +use(result.data.id) +``` + +A blind find-and-replace gives you `NoMethodError` on `Response` — loud, which is +the point. `run_service!` is unused in core, so no work there. + +**Seven services will start being validated that weren't before**, because they +subclass a concrete service whose schema they now inherit: + +``` +digital_cash/exchange_note/prepare_customer_claim +digital_cash/exchange_note/prepare_reclaim +product/enrollments/growth_partner_program_onboarding/skip +zar_gold/swaps/complete +zar_gold/swaps/create_pending_transaction +zar_gold/swaps/create_transaction +zar_gold/swaps/fail +``` + +If any of those take or return a different shape than their parent declares, they +will raise on the first call. Worth exercising before we ship. + +Related: **we subclass concrete services in about 24 places.** That is worth +revisiting independently — services are meant to be composed, not inherited. It is +also why event emissions deliberately do *not* inherit; building that support would +legitimise the pattern. + +**Events are clear.** We run `require_event_payload_schema = true`, so the new raise +for unregistered events was the thing most likely to bite us. All 29 emitted event +names resolve to a registered Event class — no exposure. The one thing to know going +forward: an Event class in a file not matching `*_event.rb` will not auto-register, +and will now raise rather than silently skipping validation. + +## Separately, not caused by this release + +37 of our 236 `schema_key` declarations (~16%) point at keys absent from the compiled +schema, so those services validate nothing today. 32 are a mechanical fix — the key +omits the `::` prefix the compiler prepends. The other 5 have no schema file +at all. This predates 1.0 and deserves its own ticket. diff --git a/CHANGELOG.md b/CHANGELOG.md index a34162dd..acf26e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,342 @@ +## [1.0.0] - 2026-08-26 + +Servus 1.0 makes a service's behaviour readable from the file that implements +it: contracts are declared inline, and there are no helpers that hide control +flow behind something shaped like a method call. + +Servus resolved schemas from three places: the `schema` DSL, `ARGUMENTS_SCHEMA` +/ `RESULT_SCHEMA` / `FAILURE_SCHEMA` constants, and mirror-directory JSON files +under `app/schemas`. Two of those put a service's contract somewhere other than +the service — hidden in a sibling file, or resolved reflectively from a constant +name. Both are gone. A service's inputs and outputs are now stated in the file +that implements it. + +The obvious cost of inline-only declaration is duplication, so this release also +adds a registry of reusable schema fragments that services reference with a +standard JSON Schema `$ref`. A service referencing a shared type still declares +that type explicitly; it just names it once. + +The same reasoning removes `call!` and `run_service!`. Both wrapped `.call` and +diverted on failure — one by throwing, one by raising — so the same operation +had two calling conventions and a jump you had to know about rather than see. + +### Added + +- **Shared schemas**: register a reusable fragment with `Servus::Schema.register` + and reference it from any service or event schema. + + ```ruby + # config/initializers/servus_schemas.rb + Servus::Schema.register('core', { + '$defs' => { 'amount' => { 'type' => 'integer', 'minimum' => 0 } } + }) + ``` + + ```ruby + class Treasury::TransferGold::Service < Servus::Base + schema arguments: { + type: 'object', + properties: { gold_dragons: { '$ref' => '#/core/$defs/amount' } } + } + end + ``` + + Refs take one of two forms — `#/` for a whole fragment, `#//` + for a path within it. Keys beside a `$ref` override the fragment they resolve + to, so a shared shape can be re-described at the site that uses it. + + Compilation is lazy and memoized: a schema is compiled the first time it is + read — by validation, by the test example builders, or by your own code — and + a fragment referenced by two hundred services is expanded once. Registering a + changed fragment invalidates every schema that depends on it. + + Lookups never return nil. An unregistered key raises + `Servus::Schema::UnknownKeyError` naming the key, the schema being compiled, + the chain of refs that led there, and the nearest registered key. Cycles raise + `CircularReferenceError` naming every hop, and are detected on first + recurrence rather than by exhausting a depth budget. + + See [Shared Schemas](https://zarpay.github.io/servus/features/shared-schemas). + +- **`Servus::Schema.ref`** builds ref hashes without hand-writing the prefix and + separator: `Servus::Schema.ref('core', '$defs', 'amount')`. + +- **`Servus::Schema.fetch` reads a path within a fragment**, using the same + addressing a `$ref` uses — `fetch('models::trade', '$defs', 'representation')`. + A missing path raises `RefNotFoundError` listing what was available, rather + than returning nil the way `dig` would. + +- **`Servus::Schema.resolve(key, *path)`** returns a fragment or definition with + all refs resolved — the compiled counterpart to `fetch`, and usually what + application code outside a service wants: + + ```ruby + schema = Servus::Schema.resolve('endpoints::trades::create', '$defs', 'request') + JSON::Validator.fully_validate(schema, params.to_unsafe_h) + ``` + +- **`Servus::Schema.compile_all`** returns every registered fragment with all + refs resolved, keyed by name. The registry has no coupling to services or + events, so an app can register contracts that have no service behind them — + controller request and response shapes, say — and emit the whole thing as one + JSON asset for an API description, docs, or client codegen. + + ```ruby + File.write('schema.json', JSON.pretty_generate(Servus::Schema.compile_all)) + ``` + +- **Multiple events per trigger are documented.** A service has always been able + to declare `emits` several times on the same trigger, each with its own block + or `with:` payload builder, and all of them fire in declaration order. Nothing + in the code changed — but the guidance did. The DSL's own documentation + previously advised against the pattern, so the capability was effectively + hidden. It now has a section in + [Events](https://zarpay.github.io/servus/features/event-bus). + +- **The service generator declares a schema by default.** Generated services now + carry a real `schema arguments:/result:` declaration instead of a commented-out + block. Required arguments are filled in from the generator's parameters, which + is knowable; property types are left empty for you to fill in, which is not. + An empty property schema accepts anything, so a fresh service enforces argument + presence and nothing more until you type it. Unlike the YARD comments, the + declaration survives `--no-docs` — it is code, not documentation. + +- **Schemas are inherited.** A subclass of a schema-bearing service or event now + inherits its contract and can override any part of it. Previously a subclass + silently validated nothing. + +### Changed + +- **`schema` rejects an explicit `nil`.** `schema arguments: nil` now raises + `ArgumentError`. Omitting a keyword still leaves any previously declared + schema in place. An explicit `nil` is almost always a lookup that failed, and + accepting it left the service silently unvalidated. + +- **`schema` rejects unknown keywords.** `schema argument: {...}` used to be a + no-op; it now raises `ArgumentError` listing the valid kinds. + +- **Event payload schemas are compiled and cached.** They were previously read + raw on every emission. + +- **The schema cache is keyed by class and kind.** It was keyed by a file path + derived from the class's namespace, with the final segment dropped — so + `A::B::Service` and `A::B::Other` shared a cache entry and could silently + share a schema. + +- **`have_schema` no longer clears the global schema cache**, which discarded + cache state belonging to unrelated examples. Because the matcher now compiles, + it also fails on a schema that references an unregistered fragment — broken + refs surface in CI rather than in production. + +- **The service generator honours `config.services_dir`.** It hardcoded + `app/services/`, so the one directory setting the generator docs listed for it + did nothing. The event and guard generators already honoured theirs. + +- **Event invocation is always asynchronous.** Services declared on an Event + class are enqueued through ActiveJob; there is no inline option and no way to + ask for one. Running a reaction inline put its latency and its failures back + into the emitting service — an exception in a follow-up propagated through a + service that had already succeeded, and its caller never received the result. + That is the coupling events exist to remove, so the choice is gone rather than + discouraged. The docs already carried a "prefer async invocation" warning; + this makes it the behaviour. + + Consequently **events now require ActiveJob**, which makes them a Rails-only + feature for the moment. Emitting an event whose Event class declares `enqueue` + without ActiveJob loaded raises `Servus::Events::Errors::AsyncBackendMissingError` + rather than the bare `NoMethodError` it used to. Servus's core — services, + schemas, guards, and the bus itself — still works without Rails. A job adapter + for non-Rails hosts is planned. + +- **Enqueueing an anonymous service raises a named error.** ActiveJob resolves a + job on the worker by its serialized class name, so a `Class.new(Servus::Base)` + has nothing to serialize. This previously surfaced as + `NoMethodError: undefined method 'demodulize' for nil`. + +- **`JobEnqueueError` no longer swallows Servus's own errors.** `call_async` + wrapped every exception, and under the `:inline` and `:test` adapters + `perform_later` runs the service — so a service's own `ValidationError` + surfaced as a misleading "Failed to enqueue". Servus errors now propagate + unwrapped. + +- **Emitting an event with no registered Event class now honours + `require_event_payload_schema`.** The `emits` DSL skipped validation entirely + when nothing was registered for the event name, so the one flag that exists to + make a missing payload schema loud was silently bypassed on exactly the events + that had no schema at all. With the flag on this now raises + `SchemaRequiredError` naming the service and the event; with the flag off — + the default — nothing changes. + +- **`required_ruby_version` is now `>= 3.2.0`**, matching the versions actually + tested. It claimed `>= 3.0.0` while CI ran 3.2, 3.3, and 3.4. + +### Removed + +- **Constant-based schemas.** `ARGUMENTS_SCHEMA`, `RESULT_SCHEMA`, and + `FAILURE_SCHEMA` are no longer consulted. + +- **File-based schemas.** JSON files under `app/schemas//` are no + longer loaded, and the service generator no longer creates them. + +- **`config.schemas_dir`, `config.schema_path_for`, and `config.schema_dir_for`**, + which existed only to locate those files. + +- **`Servus::Event.invoke`** — renamed to `enqueue`, which is what it now does. + The old name survives only as a stub that raises pointing at the new one, since + Event classes load at boot and a bare `NoMethodError` would read as a typo + rather than a rename. + +- **The `async:` option on event declarations.** Invocation is always + asynchronous, so the option no longer means anything. Both `async: true` and + `async: false` raise `ArgumentError` at declaration time — `async: false` in + particular asked for behaviour that no longer exists, and silently giving it + the opposite would be worse than refusing. + +- **`Servus::Base#call!`** — the composition helper that returned a + sub-service's `data` and halted the outer service on failure. + +- **`ControllerHelpers#run_service!`** — the same idea at the controller + boundary, returning `data` and raising on failure. + + Both read like ordinary method calls while hiding a non-local jump — `call!` + threw to unwind the outer service, `run_service!` raised — and they gave the + same operation two calling conventions depending on where you stood. Neither + saw much adoption, and both worked against being able to read a service's + control flow off the page. `run_service` and `render_service_error` are + unaffected. + +### Upgrading + +Both removed tiers fail *silently*: a service whose only schema was a constant +or a JSON file will now run with no validation at all, and nothing will say so. +That makes this the one upgrade step worth doing exhaustively rather than +waiting for something to break. + +**1. Find everything still using a removed tier.** + +```bash +grep -rn 'ARGUMENTS_SCHEMA\|RESULT_SCHEMA\|FAILURE_SCHEMA' app/ lib/ +find app/schemas -name '*.json' +``` + +**2. Move each one inline.** A constant becomes the DSL argument directly: + +```ruby +# before +class Treasury::TransferGold::Service < Servus::Base + ARGUMENTS_SCHEMA = { type: 'object', required: ['gold_dragons'] }.freeze +end + +# after +class Treasury::TransferGold::Service < Servus::Base + schema arguments: { type: 'object', required: ['gold_dragons'] } +end +``` + +A JSON file's contents become the same thing. Where several services shared a +file, register it as a fragment instead and `$ref` it from each — that is what +shared schemas are for. + +**3. Make the gap impossible to miss.** Once migrated, turn on enforcement so a +service without a schema fails loudly instead of quietly validating nothing: + +```ruby +# config/initializers/servus.rb +Servus.configure do |config| + config.require_service_arguments_schema = true + config.require_service_result_schema = true + config.require_event_payload_schema = true +end +``` + +One precondition if you enable `require_event_payload_schema`: every emitted +event name must resolve to a **loaded** Event class, because that's how a class +registers itself. Rails' railtie loads `app/events/**/*_event.rb` at boot, so +following that naming convention is enough. An Event class in a file that +doesn't match will look unregistered at emission time and raise even though it +has a schema. + +If you would rather not enforce at runtime, the `have_schema` matcher does the +same job in CI: + +```ruby +it { expect(described_class).to have_schema(:arguments) } +``` + +**Replacing `call!` and `run_service!`.** Both are mechanical. Find them with: + +```bash +grep -rn 'call!\|run_service!' app/ lib/ +``` + +`call!` returned the sub-service's data and passed its failure through, so: + +```ruby +# before +transfer = call!(Treasury::TransferGold::Service, **args) +use(transfer.id) + +# after +result = Treasury::TransferGold::Service.call(**args) +return result unless result.success? +use(result.data.id) +``` + +Note the shape change: `call!` handed you `data`, so anywhere you used its +return value now reads `result.data`. If the outer service wants to *handle* the +failure rather than pass it on, branch on `result.error` instead of returning. + +`run_service!` raised on failure: + +```ruby +# before +data = run_service!(Payments::RecordWebhook::Service, event: event) + +# after +result = Payments::RecordWebhook::Service.call(event: event) +raise result.error unless result.success? +data = result.data +``` + +One behavioural difference worth knowing: `run_service!` also assigned +`@result`. If a view or an after-action hook reads `@result`, assign it +yourself, or use `run_service`, which still does. + +**Migrating Event classes.** Every Event class with a declaration is affected. +The two errors chain, so following them is the whole migration: + +```bash +grep -rn 'invoke ' app/events/ engines/*/app/events/ +``` + +```ruby +# before +invoke Ledger::RecordEntry::Service, async: true do |payload| + { amount: payload[:transferred] } +end + +# after +enqueue Ledger::RecordEntry::Service do |payload| + { amount: payload[:transferred] } +end +``` + +Any declaration that was *not* async now runs in a job instead of inline. That is +the point of the change, but it is a real behaviour difference: the emitting +service no longer waits for the reaction, and no longer fails when the reaction +fails. If something downstream depended on that ordering, it needs to move into +the emitting service, where it was really a step rather than a reaction. + +Tests that assert on an Event class's effects need updating too — `call_service` +asserts a synchronous `.call` by default, and an Event class never makes one. +Add `.async`, or run jobs inline. + +**Two smaller things to check.** If any code passes a possibly-nil value to +`schema` — for example from a lookup helper — that now raises instead of being +dropped; fix the lookup rather than restoring the nil. And if you subclass a +service that declares schemas, the subclass now inherits them, where before it +had none. + ## [0.7.0] - 2026-08-15 ### Added diff --git a/gem/Gemfile b/gem/Gemfile index b23a77ea..4b13a3ef 100644 --- a/gem/Gemfile +++ b/gem/Gemfile @@ -18,6 +18,7 @@ group :development, :test do gem 'rspec', '~> 3.0' gem 'rspec-rails' # gives you `have_enqueued_job` matcher + gem 'simplecov', require: false # Yard gem 'redcarpet', require: false diff --git a/gem/Gemfile.lock b/gem/Gemfile.lock index d22e2b64..84d0d0b7 100644 --- a/gem/Gemfile.lock +++ b/gem/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - servus (0.7.0) + servus (1.0.0) activesupport (>= 8.0) json-schema (~> 5) @@ -177,6 +177,7 @@ GEM yard ruby-progressbar (1.13.0) securerandom (0.4.1) + simplecov (1.1.1) sqlite3 (2.9.2) mini_portile2 (~> 2.8.0) sqlite3 (2.9.2-x86_64-linux-gnu) @@ -215,6 +216,7 @@ DEPENDENCIES rubocop rubocop-yard servus! + simplecov sqlite3 webrick yard diff --git a/gem/lib/generators/servus/event/templates/event.rb.erb b/gem/lib/generators/servus/event/templates/event.rb.erb index 99f962f0..21b32162 100644 --- a/gem/lib/generators/servus/event/templates/event.rb.erb +++ b/gem/lib/generators/servus/event/templates/event.rb.erb @@ -11,23 +11,26 @@ # <%= event_class_name %>.emit({ user_id: 123 }) # # @example Invoke a service when this event fires -# invoke SendEmail::Service, async: true do |payload| +# enqueue SendEmail::Service do |payload| # { user_id: payload[:user_id] } # end # # @example Pass full payload through (no mapper block) -# invoke AuditLogger::Service, async: true +# enqueue AuditLogger::Service # -# @example Conditional invocation -# invoke GrantRewards::Service, if: ->(payload) { payload[:premium] } do |payload| +# @example Conditional +# enqueue GrantRewards::Service, if: ->(payload) { payload[:premium] } do |payload| # { user_id: payload[:user_id] } # end # -# Available options for `invoke`: -# - async: true - Invoke service asynchronously via ActiveJob -# - queue: :queue_name - Specify ActiveJob queue (requires async: true) -# - if: ->(payload) {} - Condition that must be true to invoke -# - unless: ->(payload) {} - Condition that must be false to invoke +# Services are always enqueued via ActiveJob, never run inline. +# +# Available options for `enqueue`: +# - queue: :queue_name - Route the job to a queue +# - wait: 5.minutes - Delay before the job runs +# - priority: 10 - Job priority (adapter-dependent) +# - if: ->(payload) {} - Condition that must be true to enqueue +# - unless: ->(payload) {} - Condition that must be false to enqueue # # @see Servus::Event # @see Servus::Events::Bus @@ -38,7 +41,7 @@ class <%= event_class_name %> < Servus::Event description: '<%= event_class_name %> event payload', } - # invoke YourService, async: true do |payload| + # enqueue YourService do |payload| # { example_arg: payload[:example_field] } # end end diff --git a/gem/lib/generators/servus/service/service_generator.rb b/gem/lib/generators/servus/service/service_generator.rb index aa4eab37..7a1bfa77 100644 --- a/gem/lib/generators/servus/service/service_generator.rb +++ b/gem/lib/generators/servus/service/service_generator.rb @@ -7,7 +7,6 @@ module Generators # Generates a complete service structure including: # - Service class file # - RSpec test file - # - JSON schema files for arguments and results # # @example Generate a service # rails g servus:service namespace/do_something_helpful user amount @@ -15,8 +14,6 @@ module Generators # @example Generated files # app/services/namespace/do_something_helpful/service.rb # spec/services/namespace/do_something_helpful/service_spec.rb - # app/schemas/services/namespace/do_something_helpful/arguments.json - # app/schemas/services/namespace/do_something_helpful/result.json # # @see https://guides.rubyonrails.org/generators.html class ServiceGenerator < Rails::Generators::NamedBase @@ -30,16 +27,15 @@ class ServiceGenerator < Rails::Generators::NamedBase # Creates all service-related files. # - # Generates the service class, spec file, and schema files from templates. + # Generates the service class and spec file from templates. + # + # Schemas are declared inline with the +schema+ DSL, so there are no + # schema files to generate — the service template scaffolds them in place. # # @return [void] def create_service_file template 'service.rb.erb', service_path template 'service_spec.rb.erb', service_path_spec - - # Template json schemas - template 'result.json.erb', service_result_schema_path - template 'arguments.json.erb', service_arguments_shecma_path end private @@ -49,7 +45,7 @@ def create_service_file # @return [String] service file path # @api private def service_path - "app/services/#{file_path}/service.rb" + File.join(Servus.config.services_dir, file_path, 'service.rb') end # Returns the path for the service spec file. @@ -60,22 +56,6 @@ def service_path_spec "#{Servus.config.tests_dir}/services/#{file_path}/service_spec.rb" end - # Returns the path for the result schema file. - # - # @return [String] result schema path - # @api private - def service_result_schema_path - "app/schemas/services/#{file_path}/result.json" - end - - # Returns the path for the arguments schema file. - # - # @return [String] arguments schema path - # @api private - def service_arguments_shecma_path - "app/schemas/services/#{file_path}/arguments.json" - end - # Returns the service class name with ::Service appended. # # @return [String] service class name @@ -108,10 +88,10 @@ def parameter_list # # @return [String] multi-line instance variable assignments # @example - # initialize_params # => "@user = user\n @amount = amount" + # initialize_params # => "@user = user\n @amount = amount" # @api private def initialize_params - parameters.map { |param| "@#{param} = #{param}" }.join("\n ") + parameters.map { |param| "@#{param} = #{param}" }.join("\n ") end # Generates attr_reader declarations for parameters. diff --git a/gem/lib/generators/servus/service/templates/arguments.json.erb b/gem/lib/generators/servus/service/templates/arguments.json.erb deleted file mode 100644 index 789f43f9..00000000 --- a/gem/lib/generators/servus/service/templates/arguments.json.erb +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "<%= service_class_name %> Arguments", - "description": "JSON Schema for validating <%= service_class_name %> input arguments", - "type": "object", - "properties": { -<%- parameters.each_with_index do |param, index| -%> - "<%= param %>": { - "type": "string", - "description": "TODO: Describe the <%= param %> parameter" - }<%= index < parameters.length - 1 ? ',' : '' %> -<%- end -%> -<%- if parameters.empty? -%> - }, -<%- else -%> - }, -<%- end -%> - "required": [ -<%- parameters.each_with_index do |param, index| -%> - "<%= param %>"<%= index < parameters.length - 1 ? ',' : '' %> -<%- end -%> - ], - "additionalProperties": false -} \ No newline at end of file diff --git a/gem/lib/generators/servus/service/templates/result.json.erb b/gem/lib/generators/servus/service/templates/result.json.erb deleted file mode 100644 index ca5e1d73..00000000 --- a/gem/lib/generators/servus/service/templates/result.json.erb +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "<%= service_class_name %> Result", - "description": "JSON Schema for validating <%= service_class_name %> result data", - "type": "object", - "properties": { - }, - "required": [], - "additionalProperties": true -} \ No newline at end of file diff --git a/gem/lib/generators/servus/service/templates/service.rb.erb b/gem/lib/generators/servus/service/templates/service.rb.erb index 1d30096b..90406b92 100644 --- a/gem/lib/generators/servus/service/templates/service.rb.erb +++ b/gem/lib/generators/servus/service/templates/service.rb.erb @@ -41,34 +41,27 @@ module <%= class_name %> class Service < Servus::Base <%- unless options[:no_docs] -%> - # TODO: Define argument validation schema (optional but recommended) - # schema arguments: { - # type: 'object', -<%- if parameters.any? -%> - # required: [<%= parameters.map { |p| "'#{p}'" }.join(', ') %>], -<%- else -%> - # required: [], + # TODO: give each property a type. An empty property schema accepts anything, + # so until these are filled in only presence is enforced. + # Shared shapes can be referenced instead: { '$ref' => '#/core/$defs/amount' } <%- end -%> - # properties: { + schema( + arguments: { + type: 'object', + required: <%= parameters.empty? ? '[]' : "%w[#{parameters.join(' ')}]" %>, + properties: { <%- parameters.each do |param| -%> - # <%= param %>: { type: 'string' }<%= param == parameters.last ? '' : ',' %> + <%= param %>: {}<%= param == parameters.last ? '' : ',' %> <%- end -%> -<%- if parameters.empty? -%> - # # property_name: { type: 'string' } -<%- end -%> - # } - # } + } + }, + result: { + type: 'object', + required: [], + properties: {} + } + ) - # TODO: Define result validation schema (optional) - # schema result: { - # type: 'object', - # required: [], - # properties: { - # # result_field: { type: 'string' } - # } - # } - -<%- end -%> <%- unless options[:no_docs] -%> # Initializes the service with required parameters. # diff --git a/gem/lib/servus.rb b/gem/lib/servus.rb index a534cbbd..cf5bf8a9 100644 --- a/gem/lib/servus.rb +++ b/gem/lib/servus.rb @@ -5,6 +5,8 @@ require 'active_support' require 'active_support/core_ext/class/attribute' require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/object/deep_dup' +require 'active_support/core_ext/string/inflections' # Servus namespace module Servus; end @@ -17,6 +19,15 @@ module Servus; end # Config require_relative 'servus/config' +# Schema +require_relative 'servus/schema/errors' +require_relative 'servus/schema/cache' +require_relative 'servus/schema/path' +require_relative 'servus/schema/ref' +require_relative 'servus/schema/declaration' +require_relative 'servus/schema/compiler' +require_relative 'servus/schema' + # Support require_relative 'servus/support/logger' require_relative 'servus/support/data_object' @@ -28,6 +39,7 @@ module Servus; end require_relative 'servus/support/message_resolver' # Events +require_relative 'servus/events/errors' require_relative 'servus/events/bus' require_relative 'servus/events/emitter' require_relative 'servus/event' diff --git a/gem/lib/servus/base.rb b/gem/lib/servus/base.rb index 34a32d4f..9e0e202a 100644 --- a/gem/lib/servus/base.rb +++ b/gem/lib/servus/base.rb @@ -52,6 +52,63 @@ class Base include Servus::Events::Emitter include Servus::Guards + extend Servus::Schema::Declaration + + # @!method self.schema(arguments: nil, result: nil, failure: nil) + # Declares the JSON schemas used to validate this service. + # + # Arguments are validated before +call+ runs, so the body can trust the + # shape of its inputs. Result data is validated after it returns, so a + # service that stops honouring its own contract fails loudly rather than + # shipping the wrong shape to its callers. + # + # Schemas may reference shared fragments registered with + # {Servus::Schema.register}; refs are resolved on first read. + # + # Omitting a keyword leaves any schema declared earlier — or by a + # superclass — in place. Passing one explicitly as +nil+ raises. + # + # @param arguments [Hash] JSON schema for the service's arguments + # @param result [Hash] JSON schema for successful result data + # @param failure [Hash] JSON schema for failure response data + # @return [void] + # @raise [ArgumentError] on an unknown keyword or an explicit nil + # + # @example Declaring arguments and result schemas + # class ProcessPayment::Service < Servus::Base + # schema( + # arguments: { + # type: 'object', + # required: ['user_id', 'amount'], + # properties: { + # user_id: { type: 'integer' }, + # amount: { type: 'number', minimum: 0.01 } + # } + # }, + # result: { + # type: 'object', + # required: ['transaction_id'], + # properties: { transaction_id: { type: 'string' } } + # } + # ) + # end + # + # @example Referencing a shared fragment + # schema arguments: { + # type: 'object', + # properties: { amount: { '$ref' => '#/core/$defs/amount' } } + # } + # + # @see Servus::Schema + # + # @!method self.arguments_schema + # @return [Hash, nil] the compiled arguments schema + # @!method self.result_schema + # @return [Hash, nil] the compiled result schema + # @!method self.failure_schema + # @return [Hash, nil] the compiled failure schema + declare_schemas :arguments, :result, :failure + # Support class aliases Logger = Servus::Support::Logger Emitter = Servus::Events::Emitter @@ -158,46 +215,6 @@ def error!(message = nil, type: Servus::Support::Errors::ServiceError) raise type, message end - # Invokes another service from within this service's {#call} and returns its - # data on success. On failure, halts the outer service with the sub-service's - # failure Response — the outer service's caller receives that Response - # unchanged (same error object, message, code, http_status). - # - # Sugar over: - # - # result = SubService.call(**params) - # return result unless result.success? - # data = result.data - # - # Only call from within a service's `#call` (or helpers reachable from - # it); the throw is caught by {Servus::Base.call}. - # - # @example Composing services - # class SendDigitalCash::Service < Servus::Base - # def call - # data1 = call!(Accounts::Lookup::Service, id: account_id) - # data2 = call!(Ledger::RecordTransfer::Service, account:, amount:) - # success(ref: data2.ref) - # end - # end - # - # For invoking a service from *outside* a service context (controllers, - # rake tasks, jobs, consoles), see - # {Servus::Helpers::ControllerHelpers#run_service!}. - # - # @param service_class [Class] the sub-service to invoke - # @param params [Hash] keyword arguments to pass to the sub-service - # @return [Servus::Support::DataObject, Object] the sub-service's data on success - # @throw [:guard_failure, Servus::Support::Response] the failure Response, otherwise - # - # @see Servus::Helpers::ControllerHelpers#run_service! - def call!(service_class, **params) - result = service_class.call(**params) - return result.data if result.success? - - throw(:guard_failure, result) - end - class << self # Executes the service with automatic validation, logging, and benchmarking. # @@ -257,69 +274,6 @@ def call(**args) end # rubocop:enable Metrics/MethodLength - # Defines schema validation rules for the service's arguments, result, and/or failure data. - # - # This method provides a clean DSL for specifying JSON schemas that will be used - # to validate service inputs and outputs. Schemas defined via this method take - # precedence over ARGUMENTS_SCHEMA, RESULT_SCHEMA, and FAILURE_SCHEMA constants. - # The next major version will deprecate those constants in favor of this DSL. - # - # @param arguments [Hash, nil] JSON schema for validating service arguments - # @param result [Hash, nil] JSON schema for validating service result data - # @param failure [Hash, nil] JSON schema for validating failure response data - # @return [void] - # - # @example Defining both arguments and result schemas - # class ProcessPayment::Service < Servus::Base - # schema( - # arguments: { - # type: 'object', - # required: ['user_id', 'amount'], - # properties: { - # user_id: { type: 'integer' }, - # amount: { type: 'number', minimum: 0.01 } - # } - # }, - # result: { - # type: 'object', - # required: ['transaction_id'], - # properties: { - # transaction_id: { type: 'string' } - # } - # } - # ) - # end - # - # @example Defining only arguments schema - # class SendEmail::Service < Servus::Base - # schema arguments: { type: 'object', required: ['email', 'subject'] } - # end - # - # @see Servus::Support::Validator - def schema(arguments: nil, result: nil, failure: nil) - @arguments_schema = arguments.with_indifferent_access if arguments - @result_schema = result.with_indifferent_access if result - @failure_schema = failure.with_indifferent_access if failure - end - - # Returns the arguments schema defined via the schema DSL method. - # - # @return [Hash, nil] the arguments schema or nil if not defined - # @api private - attr_reader :arguments_schema - - # Returns the result schema defined via the schema DSL method. - # - # @return [Hash, nil] the result schema or nil if not defined - # @api private - attr_reader :result_schema - - # Returns the failure schema defined via the schema DSL method. - # - # @return [Hash, nil] the failure schema or nil if not defined - # @api private - attr_reader :failure_schema - # Executes pre-call hooks including logging and argument validation. # # This method is automatically called before service execution and handles: diff --git a/gem/lib/servus/config.rb b/gem/lib/servus/config.rb index 603eb479..f292583d 100644 --- a/gem/lib/servus/config.rb +++ b/gem/lib/servus/config.rb @@ -4,24 +4,18 @@ module Servus # Configuration settings for the Servus gem. # - # Manages global configuration options including schema file locations. - # Access the configuration via {Servus.config} or modify via {Servus.configure}. + # Manages global configuration options for services, events, guards, and + # logging. Access the configuration via {Servus.config} or + # modify via {Servus.configure}. # - # @example Customizing schema location + # @example Configuring Servus # Servus.configure do |config| - # config.schema_root = Rails.root.join('lib/schemas') + # config.require_service_arguments_schema = true # end # # @see Servus.config # @see Servus.configure class Config - # The directory where JSON schema files are located. - # - # Defaults to `Rails.root/app/schemas/services` in Rails applications. - # - # @return [String] the schemas directory path - attr_accessor :schemas_dir - # The directory where Event classes are located. # # Defaults to `Rails.root/app/events` in Rails applications. @@ -171,49 +165,9 @@ def initialize def set_default_directories @guards_dir = 'app/guards' @events_dir = 'app/events' - @schemas_dir = 'app/schemas' @services_dir = 'app/services' @tests_dir = 'spec' end - - # Returns the full path to a service's schema file. - # - # @param service_namespace [String] underscored service namespace (e.g., "process_payment") - # @param type [String] schema type ("arguments" or "result") - # @return [String] full path to the schema JSON file - # - # @example - # config.schema_path_for("process_payment", "arguments") - # # => "/full/path/app/schemas/process_payment/arguments.json" - def schema_path_for(service_namespace, type) - File.join(root_path, schemas_dir, service_namespace, "#{type}.json") - end - - # Returns the directory containing a service's schema files. - # - # @param service_namespace [String] underscored service namespace - # @return [String] directory path for the service's schemas - # - # @example - # config.schema_dir_for("process_payment") - # # => "/full/path/app/schemas/process_payment" - def schema_dir_for(service_namespace) - File.join(root_path, schemas_dir, service_namespace) - end - - private - - # Determines the application root path. - # - # @return [String] Rails.root in Rails apps, or gem's root directory otherwise - # @api private - def root_path - if defined?(Rails) && Rails.respond_to?(:root) - Rails.root - else - File.expand_path('../../..', __dir__) - end - end end # Returns the singleton configuration instance. @@ -221,8 +175,8 @@ def root_path # @return [Servus::Config] the global configuration object # # @example - # Servus.config.schema_root - # # => "/app/app/schemas/services" + # Servus.config.services_dir + # # => "app/services" def self.config @config ||= Config.new end @@ -234,7 +188,7 @@ def self.config # # @example # Servus.configure do |config| - # config.schema_root = Rails.root.join('custom/schemas') + # config.require_service_result_schema = true # end def self.configure yield(config) diff --git a/gem/lib/servus/event.rb b/gem/lib/servus/event.rb index fd681127..59d4ca57 100644 --- a/gem/lib/servus/event.rb +++ b/gem/lib/servus/event.rb @@ -20,7 +20,7 @@ module Servus # # schema payload: { type: 'object', required: ['user_id'] } # - # invoke SendWelcomeEmail::Service, async: true do |payload| + # enqueue SendWelcomeEmail::Service do |payload| # { user_id: payload[:user_id] } # end # end @@ -34,13 +34,49 @@ module Servus # class AuditLogCreated < Servus::Event # event_name :audit_log_created # - # invoke AuditLogger::Service, async: true + # enqueue AuditLogger::Service # end # # @see Servus::Events::Bus # @see Servus::Events::Router # @see Servus::Base class Event + extend Servus::Schema::Declaration + + # @!method self.schema(payload: nil) + # Declares the JSON schema for this event's payload. + # + # The payload is validated on every {Servus::Event.emit}. Schemas may + # reference shared fragments registered with {Servus::Schema.register}; + # refs are resolved on first read. + # + # Omitting the keyword leaves any schema declared earlier — or by a + # superclass — in place. Passing it explicitly as +nil+ raises. + # + # @param payload [Hash] JSON schema for the event payload + # @return [void] + # @raise [ArgumentError] on an unknown keyword or an explicit nil + # + # @example + # class UserCreated < Servus::Event + # event_name :user_created + # + # schema payload: { + # type: 'object', + # required: ['user_id', 'email'], + # properties: { + # user_id: { type: 'integer' }, + # email: { type: 'string', format: 'email' } + # } + # } + # end + # + # @see Servus::Schema + # + # @!method self.payload_schema + # @return [Hash, nil] the compiled payload schema + declare_schemas :payload + class << self # Declares or returns the event name. # @@ -89,37 +125,50 @@ def ensure_registered! event_name(name.demodulize.underscore.to_sym) end - # Declares a service invocation in response to the event. + # Declares a service to enqueue in response to the event. # - # Multiple invocations can be declared for a single event. Each invocation - # requires a block that maps the event payload to the service's arguments. + # An event can declare as many services as it needs; each is enqueued + # independently when the event fires. The block maps the event payload to + # the service's keyword arguments — without one, the full payload is passed + # through. # - # @param service_class [Class] the service class to invoke (must inherit from Servus::Base) + # Invocation is always asynchronous. A reaction that ran inline would put + # its latency and its failures back into the emitting service, which is + # what events exist to avoid. This requires ActiveJob; see + # {Servus::Events::Errors::AsyncBackendMissingError}. + # + # @param service_class [Class] the service to enqueue (must inherit from Servus::Base) # @param options [Hash] invocation options - # @option options [Boolean] :async invoke the service asynchronously via call_async - # @option options [Symbol] :queue the queue name for async jobs - # @option options [Proc] :if condition that must return true for invocation - # @option options [Proc] :unless condition that must return false for invocation + # @option options [Symbol] :queue the queue to route the job to + # @option options [ActiveSupport::Duration] :wait delay before the job runs + # @option options [Time] :wait_until absolute time to run the job + # @option options [Integer] :priority job priority (adapter-dependent) + # @option options [Hash] :job_options additional ActiveJob options + # @option options [Proc] :if condition that must return true to enqueue + # @option options [Proc] :unless condition that must return false to enqueue # @yield [payload] block that maps event payload to service arguments # @yieldparam payload [Hash] the event payload # @yieldreturn [Hash] keyword arguments for the service's initialize method # @return [void] + # @raise [ArgumentError] if the removed +async:+ option is passed # - # @example Basic invocation - # invoke SendEmail::Service do |payload| + # @example Enqueue a service + # enqueue SendEmail::Service do |payload| # { user_id: payload[:user_id], email: payload[:email] } # end # - # @example Async invocation with queue - # invoke SendEmail::Service, async: true, queue: :mailers do |payload| + # @example Route to a queue + # enqueue SendEmail::Service, queue: :mailers do |payload| # { user_id: payload[:user_id] } # end # - # @example Conditional invocation - # invoke GrantRewards::Service, if: ->(p) { p[:premium] } do |payload| + # @example Conditional + # enqueue GrantRewards::Service, if: ->(p) { p[:premium] } do |payload| # { user_id: payload[:user_id] } # end - def invoke(service_class, options = {}, &block) + def enqueue(service_class, options = {}, &block) + reject_async_option!(options) + @invocations ||= [] @invocations << { service_class: service_class, @@ -128,6 +177,20 @@ def invoke(service_class, options = {}, &block) } end + # Explains that +invoke+ was renamed, rather than failing as a typo. + # + # Event classes load at boot, so a bare NoMethodError here would read like + # a misspelling instead of a rename. This covers both changes at once, + # since the overwhelmingly common declaration was +invoke Foo, async: true+. + # + # @raise [NoMethodError] always + # @deprecated Use {#enqueue}. + def invoke(*_args, **_options, &) + raise NoMethodError, + '`invoke` was renamed to `enqueue` in 1.0.0 — event invocation is always ' \ + 'asynchronous. Replace `invoke` with `enqueue`, and drop `async:` if present.' + end + # Returns all service invocations declared for this event. # # @return [Array] array of invocation configurations @@ -135,34 +198,6 @@ def invocations @invocations || [] end - # Defines the JSON schema for validating event payloads. - # - # @param payload [Hash, nil] JSON schema for validating event payloads - # @return [void] - # - # @example - # class UserCreated < Servus::Event - # event_name :user_created - # - # schema payload: { - # type: 'object', - # required: ['user_id', 'email'], - # properties: { - # user_id: { type: 'integer' }, - # email: { type: 'string', format: 'email' } - # } - # } - # end - def schema(payload: nil) - @payload_schema = payload.with_indifferent_access if payload - end - - # Returns the payload schema. - # - # @return [Hash, nil] the payload schema or nil if not defined - # @api private - attr_reader :payload_schema - # Emits this event via the Bus. # # Provides a type-safe, discoverable way to emit events from anywhere in @@ -218,11 +253,31 @@ def invocations_for(payload) # @param payload [Hash] the event payload # @return [Array] results from all invoked services def handle(payload) - invocations_for(payload).map(&:execute) + invocations_for(payload).map(&:enqueue) end private + # Rejects the removed +async:+ option at declaration time. + # + # Declaration time matters here: an Event class loads at boot, so this + # fails on deploy rather than on the first emit in production. Rejecting + # +async: false+ is the point — that declaration asks for synchronous + # invocation, which no longer exists, and quietly giving it the opposite + # would be worse than refusing. + # + # @param options [Hash] + # @return [void] + # @raise [ArgumentError] if +:async+ is present, whatever its value + # @api private + def reject_async_option!(options) + return unless options.key?(:async) + + raise ArgumentError, + '`async:` is no longer a valid option — event invocation is always ' \ + 'asynchronous. Remove it from the declaration.' + end + # @api private def should_invoke?(payload, options) return false if options[:if] && !options[:if].call(payload) diff --git a/gem/lib/servus/events/bus.rb b/gem/lib/servus/events/bus.rb index 607b710a..a46dfded 100644 --- a/gem/lib/servus/events/bus.rb +++ b/gem/lib/servus/events/bus.rb @@ -83,7 +83,7 @@ def emit(event_name, payload) ActiveSupport::Notifications.instrument(notification_name(event_name), payload) do resolve_invocations(event_name, payload) .uniq(&:key) - .each(&:execute) + .each(&:enqueue) end end diff --git a/gem/lib/servus/events/emitter.rb b/gem/lib/servus/events/emitter.rb index 304731d6..3aa73d91 100644 --- a/gem/lib/servus/events/emitter.rb +++ b/gem/lib/servus/events/emitter.rb @@ -15,6 +15,15 @@ module Events module Emitter extend ActiveSupport::Concern + # Triggers accepted by the +emits+ DSL. + # + # +:success+ and +:failure+ are selected from the service's result after + # +call+ returns. +:error!+ is fired by {Servus::Base#error!} immediately + # before it raises, so it never coincides with +:failure+. + # + # Note the bang on +:error!+ — it mirrors the method that triggers it. + EMISSION_TRIGGERS = %i[success failure error!].freeze + # Emits events for a service result. # # Called automatically after service execution completes. Determines the @@ -33,7 +42,7 @@ def self.emit_result_events!(instance, result) # Declares an event that this service will emit. # # Events are automatically emitted when the service completes with the specified - # trigger condition (:success, :failure, or :error). Use the `with` option or a + # trigger condition (:success, :failure, or :error!). Use the `with` option or a # block to provide a custom payload builder. Use `if` or `unless` to gate emission # on a runtime condition. # @@ -86,34 +95,21 @@ def self.emit_result_events!(instance, result) # end # end # - # @note Best Practice: Services should typically emit ONE event per trigger - # that represents their core concern. Multiple downstream reactions should - # be coordinated by Event classes, not by emitting multiple events - # from the service. This maintains separation of concerns. - # - # @example Recommended pattern (one event, multiple reactions) - # # Service emits one event + # @example Multiple events on one trigger, each with its own payload # class CreateUser < Servus::Base # emits :user_created, on: :success - # end - # - # # Event coordinates multiple reactions - # class UserCreated < Servus::Event - # event_name :user_created - # invoke SendWelcomeEmail::Service, async: true - # invoke TrackAnalytics::Service, async: true + # emits :welcome_queued, on: :success, with: :welcome_payload # end # # @see Servus::Events::Bus # @see Servus::Event def emits(event_name, on:, **options, &block) - valid_triggers = %i[success failure error!] - - unless valid_triggers.include?(on) - raise ArgumentError, "Invalid trigger: #{on}. Must be one of: #{valid_triggers.join(', ')}" + unless EMISSION_TRIGGERS.include?(on) + raise ArgumentError, + "Invalid trigger: #{on}. Must be one of: #{EMISSION_TRIGGERS.join(', ')}" end - @event_emissions ||= { success: [], failure: [], error!: [] } + @event_emissions ||= empty_emissions @event_emissions[on] << build_emission(event_name, options, block) end @@ -121,20 +117,25 @@ def emits(event_name, on:, **options, &block) # # @return [Hash] hash of event emissions grouped by trigger # { success: [...], failure: [...], error!: [...] } - def event_emissions - @event_emissions || { success: [], failure: [], error!: [] } - end + def event_emissions = @event_emissions || empty_emissions # Returns event emissions for a specific trigger. # # @param trigger [Symbol] the trigger type (:success, :failure, :error!) # @return [Array] array of event configurations for this trigger - def emissions_for(trigger) - event_emissions[trigger] || [] - end + def emissions_for(trigger) = event_emissions[trigger] || [] private + # An empty emission set, one entry per supported trigger. + # + # Derived from {Emitter::EMISSION_TRIGGERS} rather than written out, so + # the shape cannot drift from the list of triggers actually accepted. + # + # @return [Hash{Symbol => Array}] + # @api private + def empty_emissions = EMISSION_TRIGGERS.to_h { |trigger| [trigger, []] } + def build_emission(event_name, options, block) { event_name: event_name, @@ -201,11 +202,31 @@ def evaluate_emission_condition(condition, result) # @api private def validate_event_payload!(event_name, payload) event_class = Servus::Events::Bus.event_for(event_name) - return unless event_class + return require_event_schema!(event_name) unless event_class Servus::Support::Validator.validate_event_payload!(event_class, payload) end + # Enforces {Servus::Config#require_event_payload_schema} for an event that + # has no Event class to carry a schema. + # + # An unregistered event name is the one case where a payload cannot be + # validated at all, so it is exactly where the flag matters most. Skipping + # it here would mean the setting silently passed over the events furthest + # from having a contract. + # + # @param event_name [Symbol] the event being emitted + # @return [void] + # @raise [Servus::Support::Errors::SchemaRequiredError] if enforcement is enabled + # @api private + def require_event_schema!(event_name) + return unless Servus.config.require_event_payload_schema + + raise Servus::Support::Errors::SchemaRequiredError, + "#{self.class} emits :#{event_name} but no Event class is registered for it — " \ + 'schema missing! require_event_payload_schema is set to true.' + end + # Builds the event payload using the configured payload builder or defaults. # # @param emission [Hash] the emission configuration diff --git a/gem/lib/servus/events/errors.rb b/gem/lib/servus/events/errors.rb new file mode 100644 index 00000000..d6790167 --- /dev/null +++ b/gem/lib/servus/events/errors.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Servus + module Events + # Errors raised while resolving or enqueueing event invocations. + # + # These deliberately do *not* inherit from {Servus::Support::Errors::ServiceError}. + # Everything in that hierarchy carries an +#http_status+ and an +#api_error+ + # because it describes a business outcome a caller might render. A missing job + # backend, or a service that cannot be enqueued, is a configuration problem — + # there is no sensible HTTP status for it. + # + # @see Servus::Events::Invocation + module Errors + # Base class for every event invocation error. + class Error < StandardError; end + + # Raised when an event invocation cannot be enqueued because ActiveJob is + # not loaded. + # + # Event invocation is always asynchronous, so an event that reacts to + # anything needs a job backend. Servus's core — services, schemas, guards, + # and the bus itself — works without one; only +enqueue+ declarations + # require it. + class AsyncBackendMissingError < Error + # @param service [Class] the service that could not be enqueued + # @return [AsyncBackendMissingError] + def self.for(service) + new( + "Cannot enqueue #{service} from an event: ActiveJob is not loaded. " \ + 'Event invocation is always asynchronous and runs through ActiveJob. ' \ + 'Require active_job, or remove the enqueue declaration.' + ) + end + end + + # Raised when a service has no name, so no job class can be generated for it. + # + # ActiveJob resolves a job on the worker by its serialized class name, so a + # service created with +Class.new(Servus::Base)+ has nothing to serialize. + # This surfaces almost exclusively in tests — assign the class to a constant, + # or use +stub_const+. + class AnonymousServiceError < Error + # @param service [Class] the anonymous service + # @return [AnonymousServiceError] + def self.for(service) + new( + "Cannot generate a job class for #{service.inspect}: it is anonymous. " \ + 'ActiveJob resolves jobs by class name, so a service must be assigned ' \ + 'to a constant before it can be enqueued.' + ) + end + end + end + end +end diff --git a/gem/lib/servus/events/invocation.rb b/gem/lib/servus/events/invocation.rb index b8d4a254..ffaa07eb 100644 --- a/gem/lib/servus/events/invocation.rb +++ b/gem/lib/servus/events/invocation.rb @@ -12,61 +12,54 @@ module Events # deduplicates by +#key+ (first wins), and calls +#execute+ on each. # # An Invocation separates *identity* (service + params) from - # *execution strategy* (async, queue, priority, etc.). The +#key+ - # is derived only from the identity — two invocations that call the - # same service with the same params are considered duplicates - # regardless of their options. + # *scheduling* (queue, priority, delay). The +#key+ is derived only + # from the identity — two invocations that call the same service with + # the same params are considered duplicates regardless of their options. # - # @example Sync invocation - # Invocation.new( - # service: Rewards::Grant::Service, - # params: { user_id: "abc-123" }, - # options: {} - # ) + # Invocations are always enqueued, never run inline. A reaction that ran + # synchronously would put its latency and its failures back into the + # emitting service, which is what events exist to avoid. # - # @example Async invocation with scheduling options + # @example # Invocation.new( # service: Notifications::Send::Service, # params: { user_id: "abc-123" }, - # options: { async: true, queue: :mailers, priority: 5 } + # options: { queue: :mailers, priority: 5 } # ) # # @see Servus::Events::Router # @see Servus::Events::Bus class Invocation - # @return [Class] the service class to call (must respond to +.call+ or +.call_async+) + # @return [Class] the service class to enqueue (must respond to +.call_async+) attr_reader :service # @return [Hash] keyword arguments passed to the service attr_reader :params - # @return [Hash] execution options — +async+, +queue+, +wait+, - # +wait_until+, +priority+, +job_options+ + # @return [Hash] scheduling options — +queue+, +wait+, +wait_until+, + # +priority+, +job_options+ attr_reader :options # @param service [Class] the service class # @param params [Hash] keyword arguments for the service - # @param options [Hash] execution options + # @param options [Hash] scheduling options def initialize(service:, params:, options: {}) @service = service @params = params @options = options end - # Executes the invocation. + # Enqueues the invocation via ActiveJob. # - # Delegates to +service.call+ for synchronous invocations or - # +service.call_async+ for asynchronous ones. Async scheduling - # options (queue, wait, priority, etc.) are merged into the - # call_async kwargs. + # Scheduling options (queue, wait, priority, and so on) are merged into + # the +call_async+ keyword arguments. # - # @return [Servus::Support::Response, void] - def execute - if options[:async] - service.call_async(**params, **async_options) - else - service.call(**params) - end + # @return [void] + # @raise [Servus::Events::Errors::AsyncBackendMissingError] if ActiveJob is not loaded + def enqueue + raise Errors::AsyncBackendMissingError.for(service) unless service.respond_to?(:call_async) + + service.call_async(**params, **async_options) end # A deterministic deduplication key derived from the service class @@ -86,6 +79,7 @@ def key # Extracts scheduling options for +call_async+. # # @return [Hash] + # @api private def async_options options.slice(:queue, :wait, :wait_until, :priority, :job_options).compact end diff --git a/gem/lib/servus/extensions/async/call.rb b/gem/lib/servus/extensions/async/call.rb index 6cfcd40d..da09c465 100644 --- a/gem/lib/servus/extensions/async/call.rb +++ b/gem/lib/servus/extensions/async/call.rb @@ -81,6 +81,11 @@ def call_async(**args) # The named job class identifies the service — only args are serialized. job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class job.perform_later(**args) + rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error + # With the :inline and :test adapters perform_later runs the service, + # so Servus's own errors surface here. Wrapping them as an enqueue + # failure would blame the wrong layer. + raise rescue StandardError => e raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}" end @@ -164,6 +169,8 @@ def inherited(subclass) # @return [Class] the generated job class # @api private def build_servus_job_class + raise Servus::Events::Errors::AnonymousServiceError.for(self) if name.nil? + klass = Class.new(Servus::Extensions::Async::Job) klass.servus_service = self diff --git a/gem/lib/servus/helpers/controller_helpers.rb b/gem/lib/servus/helpers/controller_helpers.rb index 84a123e2..b71ab2e3 100644 --- a/gem/lib/servus/helpers/controller_helpers.rb +++ b/gem/lib/servus/helpers/controller_helpers.rb @@ -39,46 +39,6 @@ def run_service(klass, params) render_service_error(@result.error) unless @result.success? end - # Executes a service and returns its data on success, raising the - # failure's error otherwise. - # - # The bang counterpart to {#run_service}. Use it outside a standard - # controller render flow — inside background logic, callbacks, or any - # place where a failure should propagate as an exception rather than be - # rendered as JSON. - # - # Inside a service's `#call` method, use {Servus::Base#call!} instead — - # it preserves the failure Response for the outer service's caller rather - # than raising. - # - # Mirrors {#run_service}: stores the full Response in @result so views - # and downstream helpers can reach for it the same way, then returns the - # data on success or raises on failure. The only behavioural difference - # between the two is raise-vs-render on failure. - # - # Sugar over: - # - # @result = Service.call(**params) - # raise @result.error unless @result.success? - # data = @result.data - # - # @example From a rake task - # data = run_service!(Treasury::Reconcile::Service, date: Date.current) - # - # @param klass [Class] service class to execute - # @param params [Hash] keyword arguments to pass to the service - # @return [Servus::Support::DataObject, Object] the service's data on success - # @raise [Servus::Support::Errors::ServiceError] the failure's error otherwise - # - # @see #run_service - # @see Servus::Base#call! - def run_service!(klass, **params) - @result = klass.call(**params) - return @result.data if @result.success? - - raise @result.error - end - # Renders a service error as a JSON response. # # Uses error.http_status for the response status code and diff --git a/gem/lib/servus/railtie.rb b/gem/lib/servus/railtie.rb index a38dd90c..d5f69211 100644 --- a/gem/lib/servus/railtie.rb +++ b/gem/lib/servus/railtie.rb @@ -41,6 +41,9 @@ class Railtie < Rails::Railtie Servus::Events::Bus.clear if Rails.env.development? + # Schemas are cached per class, and reloading replaces those classes. + Servus::Support::Validator.clear_cache! + # Eager load all event classes events_path = Rails.root.join(Servus.config.events_dir) Dir[File.join(events_path, '**/*_event.rb')].each do |file| diff --git a/gem/lib/servus/schema.rb b/gem/lib/servus/schema.rb new file mode 100644 index 00000000..6749a75d --- /dev/null +++ b/gem/lib/servus/schema.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'did_you_mean' + +module Servus + # Registry of reusable JSON Schema fragments, and the entry point for + # compiling a schema that references them. + # + # Servus services and events declare their contracts inline via the +schema+ + # DSL. That keeps a service's inputs and outputs visible in the file that + # implements it. The cost of inline-only declaration is duplication: the same + # +amount+ or +timestamp+ shape gets copy-pasted across every service that + # touches it. + # + # Registered fragments close that gap without giving up explicitness. A + # fragment is registered under a key, and services reference into it with a + # standard JSON Schema +$ref+. A service that references a shared type is + # still explicitly declaring that type — it just names it once. + # + # @example Registering a fragment + # Servus::Schema.register('core', { + # '$defs' => { + # 'amount' => { 'type' => 'integer', 'minimum' => 0 } + # } + # }) + # + # @example Referencing it from a service + # class Treasury::TransferGold::Service < Servus::Base + # schema arguments: { + # type: 'object', + # required: ['gold_dragons'], + # properties: { + # gold_dragons: { '$ref' => '#/core/$defs/amount' } + # } + # } + # end + # + # Lookups never return nil. An unregistered key raises {UnknownKeyError} at + # the point of use, because the alternative — silently skipping validation for + # a service that appears to declare a contract — is the worst failure mode + # this system has. + # + # @see Servus::Schema::Compiler + # @see Servus::Base.schema + module Schema + @registry = {}.freeze + @cache = Cache.new + @mutex = Mutex.new + + class << self + # Memoized ref resolutions and the generation counter derived from them. + # + # @return [Servus::Schema::Cache] + # @api private + attr_reader :cache + + # Monotonic counter bumped whenever the registry changes. + # + # Consumers memoize compiled schemas alongside the generation they were + # compiled under, and recompile when it moves. That makes registry + # updates propagate without any explicit dependency tracking. + # + # @return [Integer] + def generation = cache.generation + + # Registers a reusable schema fragment under +key+. + # + # Re-registering an equal value is a silent no-op, so calling this from a + # Rails +to_prepare+ block is safe. Re-registering a *different* value + # replaces it, logs an override, and bumps {generation} — which + # invalidates every compiled schema that referenced it. + # + # @param key [String, Symbol] the fragment key, referenced as +#//...+ + # @param fragment [Hash] the schema fragment + # @return [ActiveSupport::HashWithIndifferentAccess] the normalized fragment + # @raise [InvalidKeyError] if the key is blank or contains a +/+ + # @raise [ArgumentError] if the fragment is not a Hash + # + # @example + # Servus::Schema.register('core', { '$defs' => { 'id' => { 'type' => 'integer' } } }) + def register(key, fragment) + key = normalize_key(key) + normalized = normalize_fragment(key, fragment) + + cache.invalidate! if store(key, normalized) + + normalized + end + + # Returns a registered fragment, or a definition within one. + # + # Given no path, returns the whole fragment. Given path segments, walks + # them as literal keys — the same addressing a +$ref+ uses, so + # +fetch(key, *path)+ reads exactly what +ref(key, *path)+ points at. + # + # A missing path raises rather than returning nil. Reaching for the + # fragment and calling +dig+ would return nil on a typo, which is the + # silent failure this registry exists to prevent. + # + # Fragments are returned as authored, with any +$ref+s intact. Use + # {compile} to resolve them. + # + # @param key [String, Symbol] the fragment key + # @param path [Array] segments to walk within the fragment + # @return [ActiveSupport::HashWithIndifferentAccess, Object] the frozen fragment or definition + # @raise [UnknownKeyError] if nothing is registered under +key+ + # @raise [RefNotFoundError] if the path is not present in the fragment + # + # @example + # Servus::Schema.fetch('core') + # Servus::Schema.fetch('core', '$defs', 'amount') + def fetch(key, *path) + key = key.to_s + fragment = @registry.fetch(key) { raise UnknownKeyError.for(key, available: @registry.keys) } + + Path.walk(fragment, key, path.map(&:to_s)) + end + + # Returns a fragment, or a definition within one, with all +$ref+s resolved. + # + # The compiled counterpart to {fetch}: same addressing, but the result is + # self-contained and ready to validate against. This is usually what + # application code outside a service wants — a controller validating a + # request body, a serializer checking a response shape. + # + # Results are memoized, so asking repeatedly for the same address is cheap. + # + # @param key [String, Symbol] the fragment key + # @param path [Array] segments to walk within the fragment + # @return [Hash, Object] the compiled fragment or definition + # @raise [UnknownKeyError] if nothing is registered under +key+ + # @raise [RefNotFoundError] if the path is not present in the fragment + # @raise [Error] if a ref within it cannot be resolved + # + # @example + # Servus::Schema.resolve('endpoints::trades::create', '$defs', 'request') + # # => { "type" => "object", "properties" => { "price" => { "type" => "integer" } } } + def resolve(key, *path) + pointer = ref(key, *path) + + compile(pointer, context: "schema #{pointer['$ref']}") + end + + # Compiles every registered fragment, resolving all +$ref+s. + # + # Returns a hash of key to compiled fragment, mirroring the registry's own + # shape so keys stay addressable and the result serializes straight to + # JSON. Useful for producing a single schema asset for an API description, + # a docs build, client codegen, or a CI freshness check. + # + # @return [Hash{String => Hash}] every fragment, refs resolved + # @raise [Error] if any fragment contains a ref that cannot be resolved + # + # @example + # File.write('schema.json', JSON.pretty_generate(Servus::Schema.compile_all)) + def compile_all + keys.to_h { |key| [key, compile(fetch(key), context: "schema fragment #{key.inspect}")] } + end + + # @return [Array] registered keys, sorted + def keys + @registry.keys.sort + end + + # Builds a +$ref+ pointing at a registered fragment. + # + # Prefer this over hand-writing ref strings — it is typo-proof in the + # separator and prefix, which are the parts people get wrong. + # + # @param key [String, Symbol] the fragment key + # @param path [Array] segments to walk within the fragment + # @return [Hash] a +$ref+ hash + # + # @example + # Servus::Schema.ref('core', '$defs', 'amount') + # # => { "$ref" => "#/core/$defs/amount" } + def ref(key, *path) + { '$ref' => "#/#{[key, *path].map(&:to_s).join('/')}" } + end + + # Compiles a schema, replacing every +$ref+ with the fragment it names. + # + # @param schema [Hash, nil] the authored schema + # @param context [String, nil] label used in error messages, e.g. + # "Treasury::TransferGold::Service arguments schema" + # @return [Hash, nil] the compiled schema, or nil if +schema+ was nil + # @raise [Error] if any ref cannot be resolved + def compile(schema, context: nil) + return nil if schema.nil? + + Compiler.new(context: context).compile(schema) + end + + # Clears the registry. Intended for test suites. + # + # @return [void] + def reset! + restore({}.freeze) + end + + # Captures the registry state so a test can restore it afterwards. + # + # @return [Hash] an opaque snapshot for {restore} + # @api private + def snapshot + @registry + end + + # Restores a snapshot taken by {snapshot}. + # + # @param snapshot [Hash] + # @return [void] + # @api private + def restore(snapshot) + @mutex.synchronize { @registry = snapshot } + cache.invalidate! + end + + private + + # Writes a normalized fragment into the registry. + # + # @param key [String] + # @param normalized [Hash] the normalized fragment + # @return [Boolean] whether the registry actually changed + # @api private + def store(key, normalized) + @mutex.synchronize do + existing = @registry[key] + return false if existing == normalized + + Support::Logger.log_schema_override(key) if existing + @registry = @registry.merge(key => normalized).freeze + end + + true + end + + # @param key [String, Symbol] + # @return [String] + # @raise [InvalidKeyError] + # @api private + def normalize_key(key) + key = key.to_s + + raise InvalidKeyError, 'schema fragment key cannot be blank' if key.strip.empty? + + if key.include?('/') + raise InvalidKeyError, "schema fragment key #{key.inspect} cannot contain '/' — " \ + 'it is the separator in $ref paths, so the key would be unreferenceable' + end + + key + end + + # @param key [String] the key, for the error message + # @param fragment [Hash] + # @return [ActiveSupport::HashWithIndifferentAccess] deeply frozen + # @raise [ArgumentError] if the fragment is not a Hash + # @api private + def normalize_fragment(key, fragment) + unless fragment.is_a?(Hash) + raise ArgumentError, "schema fragment for #{key.inspect} must be a Hash, got #{fragment.class}" + end + + deep_freeze(fragment.deep_dup.with_indifferent_access) + end + + # @param value [Object] + # @return [Object] the same value, frozen through nested hashes and arrays + # @api private + def deep_freeze(value) + case value + when Hash then value.each_value { |v| deep_freeze(v) }.freeze + when Array then value.each { |v| deep_freeze(v) }.freeze + else value.freeze + end + end + end + end +end diff --git a/gem/lib/servus/schema/cache.rb b/gem/lib/servus/schema/cache.rb new file mode 100644 index 00000000..67b38bdd --- /dev/null +++ b/gem/lib/servus/schema/cache.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Servus + module Schema + # Memoized +$ref+ resolutions, plus the generation counter that invalidates + # everything derived from them. + # + # Entries are keyed by ref string and hold the resolved *target* of that + # ref, before any sibling properties are merged over it. That is what makes + # a single entry safe to share across every site that uses the ref: the + # target depends only on the ref string and the registry contents, and + # callers apply their own siblings afterwards with +Hash#merge+, which + # returns a new hash and never mutates the cached one. + # + # The generation counter lets consumers that build on compiled schemas — + # {Servus::Base}, {Servus::Event} — memoize alongside the generation they + # compiled under and rebuild when it moves, with no dependency tracking. + # + # @see Servus::Schema + # @see Servus::Schema::Compiler + # @api private + class Cache + # Monotonic counter, advanced by {#invalidate!}. + # + # @return [Integer] + attr_reader :generation + + def initialize + @entries = {} + @generation = 0 + @mutex = Mutex.new + end + + # Returns the memoized resolution of +ref+, computing it on a miss. + # + # A raising block leaves no entry behind, so a ref that failed part way + # through resolution is never cached in a half-built state. + # + # @param ref [String] the ref string + # @yieldreturn [Object] the resolved target, computed on a miss + # @return [Object] the resolved target + def resolve(ref) + cached = @entries[ref] + return cached unless cached.nil? + + resolved = yield + @mutex.synchronize { @entries[ref] = resolved } + resolved + end + + # Discards every memoized resolution and advances {#generation}. + # + # @return [void] + def invalidate! + @mutex.synchronize do + @entries = {} + @generation += 1 + end + end + + # Number of memoized resolutions. Used by specs to assert that a repeated + # ref is expanded once rather than once per occurrence. + # + # @return [Integer] + def size + @entries.size + end + end + end +end diff --git a/gem/lib/servus/schema/compiler.rb b/gem/lib/servus/schema/compiler.rb new file mode 100644 index 00000000..c47413a4 --- /dev/null +++ b/gem/lib/servus/schema/compiler.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module Servus + module Schema + # Resolves +$ref+ pointers in a schema against the {Servus::Schema} registry, + # producing a self-contained schema with no refs left in it. + # + # One instance per compile. The instance carries the cycle-detection state + # and the context label used in error messages; the memo it consults is + # process-wide and lives on {Servus::Schema}. + # + # == Sibling properties + # + # Keys alongside a +$ref+ override the resolved target: + # + # { '$ref' => '#/core/$defs/amount', 'description' => 'Fee charged' } + # + # This is a template-and-override reading, which is what makes shared + # fragments usable in practice — you take the shape and re-describe it for + # the site that uses it. Note that it *differs* from JSON Schema 2019-09 and + # later, where properties beside a +$ref+ are an additional subschema + # applied as an intersection rather than an override. + # + # Siblings are compiled independently and merged *onto* an already-resolved + # target, rather than merged first and resolved after. That ordering is what + # makes the target cacheable: the memo holds a value that does not depend on + # the call site. + # + # @see Servus::Schema + # @see Servus::Schema::Ref + class Compiler + # Maximum structural nesting depth before {DepthExceededError} is raised. + # + # This is a runaway guard for pathological input, not a cycle check — + # cycles are caught exactly by {#resolve_ref}'s visited set, however deep + # or shallow they are. Keeping the two separate means a legitimately deep + # acyclic schema compiles instead of being misreported as circular. + MAX_DEPTH = 100 + + # Keys stripped from a fragment when it is spliced into another schema. + # + # +json-schema+ resolves a nested +$schema+ against its registered + # validators and raises +JSON::Schema::SchemaError+ when it does not + # recognize the URI — at any position in the document, not just the root. + # Fragments authored as standalone documents routinely carry these, so + # they are dropped on splice rather than left to blow up at validation time. + # + # @see https://github.com/voxpupuli/json-schema + METADATA_KEYS = %w[$schema $id id].freeze + + # @param context [String, nil] label for the schema being compiled, used + # in error messages, e.g. "Treasury::TransferGold::Service arguments schema" + def initialize(context: nil) + @context = context + @path = [] + end + + # Compiles a schema, replacing every +$ref+ with the fragment it names. + # + # @param schema [Object] the authored schema + # @return [Object] the compiled schema + # @raise [Error] if any ref cannot be resolved + def compile(schema) + resolve(schema, 0) + end + + private + + # Recursively resolves a node. + # + # @param node [Object] + # @param depth [Integer] current structural nesting depth + # @return [Object] + # @api private + def resolve(node, depth) + raise DepthExceededError, contextualize(depth_message) if depth > MAX_DEPTH + + case node + when Hash then resolve_hash(node, depth) + when Array then node.map { |item| resolve(item, depth + 1) } + else node + end + end + + # @param node [Hash] + # @param depth [Integer] + # @return [Hash] + # @api private + def resolve_hash(node, depth) + return resolve_ref_node(node, depth) if node.key?('$ref') || node.key?(:$ref) + + node.transform_values { |value| resolve(value, depth + 1) } + end + + # Resolves a node carrying a +$ref+, merging sibling keys over the target. + # + # @param node [Hash] + # @param depth [Integer] + # @return [Hash] + # @api private + def resolve_ref_node(node, depth) + target = resolve_ref(node['$ref'] || node[:$ref]) + siblings = node.reject { |key, _| key.to_s == '$ref' } + + siblings.empty? ? target : target.merge(resolve_hash(siblings, depth)) + end + + # Resolves a single ref to its target, guarding against cycles. + # + # @param value [Object] the raw +$ref+ value + # @return [Object] the resolved target + # @raise [Error] + # @api private + def resolve_ref(value) + ref = with_context { Ref.parse(value) } + + raise CircularReferenceError, contextualize(circular_message(ref)) if @path.include?(ref.value) + + @path.push(ref.value) + begin + Schema.cache.resolve(ref.value) { expand(ref) } + ensure + @path.pop + end + end + + # Looks a ref's target up in the registry and resolves it in turn. + # + # @param ref [Ref] + # @return [Object] + # @api private + def expand(ref) + target = with_context { Schema.fetch(ref.key, *ref.segments) } + + strip_metadata(resolve(target, 0)) + end + + # Removes document-level metadata from a spliced fragment. + # + # @param node [Object] + # @return [Object] + # @api private + def strip_metadata(node) + return node unless node.is_a?(Hash) + return node unless node.keys.map(&:to_s).intersect?(METADATA_KEYS) + + node.reject { |key, _| METADATA_KEYS.include?(key.to_s) } + end + + # Runs a block, re-raising any schema error with this compile's context. + # + # {Ref} and {Servus::Schema} raise where the problem is detected and know + # nothing about which schema or ref chain led there. This attaches that. + # + # Both call sites wrap a single foreign call rather than the recursion + # around it, so an error is decorated exactly once on its way out. + # + # @yield the work that might raise + # @return [Object] the block's value + # @api private + def with_context + yield + rescue Error => e + raise e.class, contextualize(e.message) + end + + # Appends the schema being compiled and the ref chain that led here. + # + # @param message [String] + # @return [String] + # @api private + def contextualize(message) + parts = [message] + parts << "while compiling #{@context}" if @context + parts << "(resolution path: #{@path.join(' -> ')})" if @path.length > 1 + + parts.join(" + ") + end + + # @param ref [Ref] + # @return [String] + # @api private + def circular_message(ref) + "circular $ref detected: #{(@path + [ref.value]).join(' -> ')}" + end + + # @return [String] + # @api private + def depth_message + "schema nests more than #{MAX_DEPTH} levels deep. This is a runaway guard — " \ + 'if the schema is legitimately this deep, flatten it into registered fragments.' + end + end + end +end diff --git a/gem/lib/servus/schema/declaration.rb b/gem/lib/servus/schema/declaration.rb new file mode 100644 index 00000000..3b4c3815 --- /dev/null +++ b/gem/lib/servus/schema/declaration.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Servus + module Schema + # Provides the +schema+ DSL to a class. + # + # Extend a class with this and call {#declare_schemas} with the schema + # kinds it supports. {Servus::Base} declares +arguments+, +result+, and + # +failure+; {Servus::Event} declares +payload+. Each kind gets: + # + # * a keyword on the generated +schema+ class method + # * a reader returning the compiled schema, e.g. +arguments_schema+ + # + # == Compiled on read + # + # The plain reader compiles, so every consumer — validation, the test + # example builders, the +have_schema+ matcher, application code reading a + # service's contract — sees resolved +$ref+s without having to know that + # compilation exists. Results are memoized per class against + # {Servus::Schema.generation}, so registering a changed fragment rebuilds + # dependent schemas with no dependency tracking. + # + # == Inheritance + # + # Readers walk the ancestor chain, so a subclass of a schema-bearing class + # inherits its contract. Without this a subclass silently validates nothing, + # which is the failure mode this whole subsystem is built to prevent. + # + # @see Servus::Base.schema + # @see Servus::Event.schema + module Declaration + # Defines the +schema+ DSL and its readers for the given kinds. + # + # @param types [Array] the schema kinds this class supports + # @return [void] + # + # @example + # class Servus::Event + # extend Servus::Schema::Declaration + # declare_schemas :payload + # end + def declare_schemas(*types) + @schema_types = types.freeze + + types.each do |type| + define_singleton_method(:"#{type}_schema") { compiled_schema(type) } + end + end + + # The schema kinds this class supports. + # + # @return [Array] + # @api private + def schema_types + @schema_types || superclass.schema_types + end + + # Declares schemas for this class. + # + # Omitting a keyword leaves any previously declared schema of that kind + # in place. Passing one explicitly as +nil+ raises, rather than quietly + # leaving the class unvalidated — a lookup that returns nil is a bug at + # the call site, and swallowing it is how contracts silently disappear. + # + # @param schemas [Hash{Symbol => Hash}] schema kind to JSON Schema + # @return [void] + # @raise [ArgumentError] on an unknown kind or an explicit nil + # + # @example + # schema arguments: { type: 'object', required: ['user_id'] } + def schema(**schemas) + validate_schema_kinds!(schemas.keys) + + schemas.each do |type, value| + raise ArgumentError, nil_schema_message(type) if value.nil? + + instance_variable_set(:"@raw_#{type}_schema", value.with_indifferent_access) + end + + @compiled_schemas = nil + end + + private + + # @param types [Array] + # @return [void] + # @raise [ArgumentError] + # @api private + def validate_schema_kinds!(types) + unknown = types - schema_types + return if unknown.empty? + + raise ArgumentError, + "unknown schema #{'kind'.pluralize(unknown.size)} #{unknown.map(&:inspect).join(', ')} " \ + "for #{name}. Valid: #{schema_types.map(&:inspect).join(', ')}." + end + + # @param type [Symbol] + # @return [String] + # @api private + def nil_schema_message(type) + "#{name} declared a nil #{type} schema. Pass a Hash, or omit the keyword entirely — " \ + 'an explicit nil is usually a lookup that failed, and accepting it would leave this ' \ + 'class silently unvalidated.' + end + + # The schema as authored, from this class or the nearest ancestor. + # + # @param type [Symbol] + # @return [Hash, nil] + # @api private + def raw_schema(type) + klass = self + + while klass.respond_to?(:raw_schema, true) + declared = klass.instance_variable_get(:"@raw_#{type}_schema") + return declared if declared + + klass = klass.superclass + end + + nil + end + + # The compiled schema, memoized against the registry generation. + # + # @param type [Symbol] + # @return [Hash, nil] + # @raise [Servus::Schema::Error] if a ref cannot be resolved + # @api private + def compiled_schema(type) + generation = Schema.generation + @compiled_schemas = nil unless @compiled_generation == generation + @compiled_generation = generation + @compiled_schemas ||= {} + + return @compiled_schemas[type] if @compiled_schemas.key?(type) + + compiled = Schema.compile(raw_schema(type), context: "#{name} #{type} schema") + + # Compilation rebuilds hashes as it walks, so the indifferent access + # applied at declaration does not survive it. Readers are public API; + # restore it rather than making callers know which keys are strings. + @compiled_schemas[type] = compiled&.with_indifferent_access + end + end + end +end diff --git a/gem/lib/servus/schema/errors.rb b/gem/lib/servus/schema/errors.rb new file mode 100644 index 00000000..30240d04 --- /dev/null +++ b/gem/lib/servus/schema/errors.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Servus + module Schema + # Base class for every schema registry and compilation error. + # + # These deliberately do *not* inherit from {Servus::Support::Errors::ServiceError}. + # Everything in that hierarchy carries an +#http_status+ and an +#api_error+ + # because it describes a business outcome a caller might render. A malformed + # +$ref+ or an unregistered fragment key is a programming error in the schema + # itself — there is no sensible HTTP status for it, and rescuing it would + # reintroduce exactly the silent non-validation this design exists to prevent. + # + # @see Servus::Schema + # @see Servus::Schema::Compiler + class Error < StandardError; end + + # Raised when a +$ref+ names a fragment key that is not registered. + # + # This is the error the whole registry design exists to produce. A lookup + # that returned nil instead would let a service that appears to declare a + # contract run with no validation at all, indefinitely and silently. + class UnknownKeyError < Error + # Builds the error for a missed lookup, suggesting the nearest key. + # + # @param key [String] the key that was not found + # @param available [Array] currently registered keys + # @return [UnknownKeyError] + def self.for(key, available:) + return new(nothing_registered(key)) if available.empty? + + new("unknown schema key #{key.inspect}.#{suggestion(key, available)}") + end + + # @param key [String] + # @return [String] + # @api private + def self.nothing_registered(key) + "unknown schema key #{key.inspect}: no schema fragments are registered. " \ + 'Register one with Servus::Schema.register(key, fragment).' + end + + # @param key [String] + # @param available [Array] + # @return [String] a " Did you mean: ..." clause, or an empty string + # @api private + def self.suggestion(key, available) + matches = DidYouMean::SpellChecker.new(dictionary: available).correct(key) + return '' if matches.empty? + + " Did you mean: #{matches.map(&:inspect).join(', ')}?" + end + + private_class_method :nothing_registered, :suggestion + end + + # Raised when a +$ref+ names a registered key but the path within it is absent. + class RefNotFoundError < Error; end + + # Raised when a +$ref+ value is not a supported ref form. + class InvalidRefError < Error; end + + # Raised when a key passed to {Servus::Schema.register} cannot be referenced. + class InvalidKeyError < Error; end + + # Raised when refs form a cycle. + class CircularReferenceError < Error; end + + # Raised when a schema nests more deeply than {Servus::Schema::Compiler::MAX_DEPTH}. + # + # Distinct from {CircularReferenceError} on purpose: a deep but acyclic + # schema is a different problem from a cycle, and conflating them is what + # makes depth-counter-only implementations reject valid schemas. + class DepthExceededError < Error; end + end +end diff --git a/gem/lib/servus/schema/path.rb b/gem/lib/servus/schema/path.rb new file mode 100644 index 00000000..ae188422 --- /dev/null +++ b/gem/lib/servus/schema/path.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Servus + module Schema + # Walks a path of literal keys into a registered schema fragment. + # + # This is the single addressing implementation behind both + # {Servus::Schema.fetch} and +$ref+ resolution, so a path that misses reads + # the same whether application code asked for it directly or a ref led there. + # + # Segments are literal hash keys, not JSON Pointer tokens — there is no + # +~0+/+~1+ unescaping and no array indexing. + # + # @see Servus::Schema.fetch + # @see Servus::Schema::Ref + # @api private + module Path + class << self + # Walks +path+ into +fragment+. + # + # @param fragment [Hash] the registered fragment + # @param key [String] the fragment key, for the error message + # @param path [Array] segments to walk + # @return [Object] the value at the path, or the fragment if path is empty + # @raise [RefNotFoundError] if a segment is not present + def walk(fragment, key, path) + path.reduce(fragment) do |current, segment| + unless current.is_a?(Hash) && current.key?(segment) + raise RefNotFoundError, message_for(key, path, segment, current) + end + + current[segment] + end + end + + private + + # @param key [String] the fragment key + # @param path [Array] the full path being walked + # @param segment [String] the segment that was not found + # @param current [Object] the node the walk failed at + # @return [String] + # @api private + def message_for(key, path, segment, current) + "#{path.join('/').inspect} could not be resolved in schema fragment #{key.inspect}: " \ + "#{segment.inspect} is not present.#{available_in(current)}" + end + + # @param current [Object] the node the walk failed at + # @return [String] a clause describing what was there instead + # @api private + def available_in(current) + return " #{current.class} is not a Hash, so it has no keys to walk into." unless current.is_a?(Hash) + + " Available keys: #{current.keys.map(&:to_s).sort.map(&:inspect).join(', ')}." + end + end + end + end +end diff --git a/gem/lib/servus/schema/ref.rb b/gem/lib/servus/schema/ref.rb new file mode 100644 index 00000000..310212db --- /dev/null +++ b/gem/lib/servus/schema/ref.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Servus + module Schema + # A parsed +$ref+ pointing at a registered schema fragment. + # + # Servus supports exactly two ref forms: + # + # #/core # the whole fragment registered as "core" + # #/core/$defs/amount # a path walked within it + # + # Segments are literal hash keys, not JSON Pointer tokens — there is no + # +~0+/+~1+ unescaping and no array indexing. +$defs+ carries no special + # meaning; it is a conventional place to put definitions, and any key works. + # + # Everything else is rejected by {parse} with a message that names what was + # wrong. That matters most for local refs: +#/$defs/amount+ would otherwise + # parse as a request for a fragment registered under the key +$defs+ and + # fail as a confusing lookup miss rather than as the unsupported form it is. + # + # @see Servus::Schema::Compiler + class Ref + # Ref forms Servus does not implement, paired with the reason. + # + # Checked in order; the first match raises {InvalidRefError}. + # + # @api private + REJECTIONS = [ + [ + ->(value) { !value.start_with?('#/') }, + 'Servus resolves refs against registered schema fragments, which always take the form ' \ + '"#/" or "#//". Remote and file refs are not supported.' + ], + [ + ->(value) { value == '#/' }, + 'it names no schema fragment key.' + ], + [ + ->(value) { value.delete_prefix('#/').start_with?('$') }, + 'it looks like a local ref. Refs resolve against registered fragments, not against the ' \ + 'enclosing document. Register the shared definition as a fragment and reference it as ' \ + '"#//...".' + ] + ].freeze + + # The original ref string. + # + # @return [String] + attr_reader :value + + # The registry key the ref names. + # + # @return [String] + attr_reader :key + + # Path segments to walk within the fragment. Empty for a whole-fragment ref. + # + # @return [Array] + attr_reader :segments + + # Parses a +$ref+ value. + # + # @param value [Object] the raw +$ref+ value from a schema + # @return [Ref] + # @raise [InvalidRefError] if the value is not a supported ref form + # + # @example + # Servus::Schema::Ref.parse('#/core/$defs/amount').key # => "core" + def self.parse(value) + raise InvalidRefError, "$ref must be a String, got #{value.class}: #{value.inspect}" unless value.is_a?(String) + + _, explanation = REJECTIONS.find { |rejects, _| rejects.call(value) } + + raise InvalidRefError, "#{value.inspect} is not a supported $ref — #{explanation}" if explanation + + new(value) + end + + # @param value [String] a ref string already known to be well-formed + # @api private + def initialize(value) + @value = value + @key, *@segments = value.delete_prefix('#/').split('/') + end + end + end +end diff --git a/gem/lib/servus/support/logger.rb b/gem/lib/servus/support/logger.rb index 8befc553..37536b31 100644 --- a/gem/lib/servus/support/logger.rb +++ b/gem/lib/servus/support/logger.rb @@ -94,6 +94,16 @@ def self.log_exception(service_class, exception) logger.error("#{service_class.name} uncaught exception: #{exception.class} - #{exception.message}") end + # Logs that a registered schema fragment was replaced with a different value. + # + # Expected during development reloads. Outside of that it usually means + # two libraries are claiming the same fragment key. + # + # @param key [String] The schema fragment key being overridden + def self.log_schema_override(key) + logger.warn("Schema fragment #{key.inspect} was already registered with a different value; replacing it.") + end + # Filters parameters for logging based on the configured filter list. # # @param params [Hash] The parameters to filter diff --git a/gem/lib/servus/support/validator.rb b/gem/lib/servus/support/validator.rb index 7366fbf9..f5709af3 100644 --- a/gem/lib/servus/support/validator.rb +++ b/gem/lib/servus/support/validator.rb @@ -2,42 +2,46 @@ module Servus module Support - # Handles JSON Schema validation for service arguments and results. + # Validates service arguments and results, and event payloads, against the + # JSON schemas declared with the +schema+ DSL. # - # The Validator class provides automatic validation of service inputs and outputs - # against JSON Schema definitions. Schemas can be defined as inline constants - # (ARGUMENTS_SCHEMA, RESULT_SCHEMA) or as external JSON files. + # Arguments are validated before +call+ runs, so a service body can trust + # the shape of its inputs. Result data is validated after it returns, so a + # service that stops honouring its own contract fails loudly rather than + # passing the wrong shape to its callers. Both raise + # {Servus::Support::Errors::ValidationError}, which signals a bug — in the + # caller for arguments, in the service itself for results — and is not + # meant to be rescued. # - # @example Inline schema validation + # Schemas come from the +schema+ DSL and nowhere else. The class-level + # readers resolve any +$ref+s against {Servus::Schema}, so what arrives + # here is always a self-contained schema. + # + # @example # class MyService < Servus::Base - # ARGUMENTS_SCHEMA = { - # type: "object", - # required: ["user_id"], - # properties: { - # user_id: { type: "integer" } - # } - # } + # schema arguments: { type: 'object', required: ['user_id'] } # end # - # @example File-based schema validation - # # app/schemas/services/my_service/arguments.json - # # { "type": "object", "required": ["user_id"], ... } - # + # @see Servus::Base.schema + # @see Servus::Schema # @see https://json-schema.org/specification.html class Validator + # Schema kinds that may be requested from {.load_schema}. + # + # @api private + SCHEMA_TYPES = %w[arguments result failure payload].freeze + # @api private @schema_cache = {} - # Validates service arguments against the ARGUMENTS_SCHEMA. - # - # Checks arguments against either an inline ARGUMENTS_SCHEMA constant or - # a file-based schema at app/schemas/services/namespace/arguments.json. - # Validation is skipped if no schema is defined. + # Validates service arguments against the service's arguments schema. # # @param service_class [Class] the service class being validated # @param args [Hash] keyword arguments passed to the service # @return [Boolean] true if validation passes # @raise [Servus::Support::Errors::ValidationError] if arguments fail validation + # @raise [Servus::Support::Errors::SchemaRequiredError] if no schema is + # declared and +require_service_arguments_schema+ is enabled # # @example # Validator.validate_arguments!(MyService, { user_id: 123 }) @@ -48,11 +52,7 @@ def self.validate_arguments!(service_class, args) enforce_schema_presence!(schema, service_class, :require_service_arguments_schema) return true unless schema - validate_data_against_schema!( - args, - schema, - "Invalid arguments for #{service_class.name}" - ) + validate_data_against_schema!(args, schema, "Invalid arguments for #{service_class.name}") true end @@ -102,7 +102,7 @@ def self.result_schema_for(service_class, result) end end - # Validates event payload against the Event class's payload schema. + # Validates an event payload against the event's payload schema. # # @param event_class [Class] the Event subclass # @param payload [Hash] the event payload to validate @@ -114,7 +114,7 @@ def self.result_schema_for(service_class, result) # # @api private def self.validate_event_payload!(event_class, payload) - schema = event_class.payload_schema + schema = load_schema(event_class, 'payload') enforce_schema_presence!(schema, event_class, :require_event_payload_schema) return true unless schema @@ -127,50 +127,36 @@ def self.validate_event_payload!(event_class, payload) true end - # Loads and caches a schema for a service. - # - # Implements a three-tier lookup strategy: - # 1. Check for schema defined via DSL method (service_class.arguments_schema/result_schema) - # 2. Check for inline constant (ARGUMENTS_SCHEMA or RESULT_SCHEMA) - # 3. Fall back to JSON file in app/schemas/services/namespace/type.json + # Returns a class's compiled schema of the given kind. # - # Schemas are cached after first load for performance. + # Cached per class and kind. The underlying compilation is also memoized + # on the class itself and rebuilds when {Servus::Schema} changes, so this + # cache exists to skip the lookup, not to hold compilation results. # - # @param service_class [Class] the service class - # @param type [String] schema type ("arguments", "result", or "failure") - # @return [Hash, nil] the schema hash, or nil if no schema found + # @param klass [Class] a {Servus::Base} or {Servus::Event} subclass + # @param type [String, Symbol] one of {SCHEMA_TYPES} + # @return [Hash, nil] the compiled schema, or nil if none is declared + # @raise [ArgumentError] if +type+ is not a known schema kind # # @api private - # rubocop:disable Metrics/MethodLength - def self.load_schema(service_class, type) - # Get service path based on class name (e.g., "process_payment" from "Servus::ProcessPayment::Service") - service_namespace = parse_service_namespace(service_class) - schema_path = Servus.config.schema_path_for(service_namespace, type) - - # Return from cache if available - return @schema_cache[schema_path] if @schema_cache.key?(schema_path) + def self.load_schema(klass, type) + type = type.to_s - # Check for DSL-defined schema first - dsl_schema = case type - when 'arguments' then service_class.arguments_schema - when 'result' then service_class.result_schema - when 'failure' then service_class.failure_schema - end + unless SCHEMA_TYPES.include?(type) + raise ArgumentError, "unknown schema type #{type.inspect}. Valid: #{SCHEMA_TYPES.join(', ')}." + end - inline_schema_constant_name = "#{service_class}::#{type.upcase}_SCHEMA" - inline_schema_constant = if Object.const_defined?(inline_schema_constant_name) - Object.const_get(inline_schema_constant_name) - end + key = [klass, type] + return @schema_cache[key] if @schema_cache.key?(key) - @schema_cache[schema_path] = fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path) - @schema_cache[schema_path] + @schema_cache[key] = klass.public_send(:"#{type}_schema") end - # rubocop:enable Metrics/MethodLength # Clears the schema cache. # - # Useful in development when schema files are modified, or in tests - # to ensure fresh schema loading between test cases. + # Useful in tests, and in development after changing a schema. Registry + # changes invalidate compiled schemas on their own, so this is rarely + # needed in application code. # # @return [Hash] empty hash # @@ -184,7 +170,7 @@ def self.clear_cache! # Returns the current schema cache. # - # @return [Hash] cache mapping schema paths to loaded schemas + # @return [Hash] cache mapping [class, type] pairs to compiled schemas # @api private def self.cache @schema_cache @@ -206,12 +192,12 @@ def self.validate_data_against_schema!(data, schema, message_prefix) raise Servus::Base::ValidationError, "#{message_prefix}: #{errors.join(', ')}" end - # Returns the schema if present. Raises if absent and the config flag is enabled. + # Raises if a schema is absent and the corresponding config flag is on. # # @param schema [Hash, nil] the loaded schema # @param klass [Class] the service or Event class # @param config_flag [Symbol] the config method to check - # @return [Hash, nil] the schema + # @return [Hash, nil] the schema, unchanged # @raise [Servus::Support::Errors::SchemaRequiredError] if schema is nil and enforcement is enabled # # @api private @@ -223,49 +209,6 @@ def self.enforce_schema_presence!(schema, klass, config_flag) raise Servus::Support::Errors::SchemaRequiredError, "#{klass.name} schema missing! #{config_flag} is set to true." end - - # Fetches schema from DSL, inline constant, or file. - # - # Implements the schema resolution precedence: - # 1. DSL-defined schema (if provided) - # 2. Inline constant (if provided) - # 3. File at schema_path (if exists) - # 4. nil (no schema found) - # - # @param dsl_schema [Hash, nil] schema from DSL method (e.g., schema arguments: Hash) - # @param inline_schema_constant [Hash, nil] inline schema constant (e.g., ARGUMENTS_SCHEMA) - # @param schema_path [String] file path to external schema JSON - # @return [Hash, nil] schema with indifferent access, or nil if not found - # - # @api private - def self.fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path) - if dsl_schema - dsl_schema.with_indifferent_access - elsif inline_schema_constant - inline_schema_constant.with_indifferent_access - elsif File.exist?(schema_path) - JSON.load_file(schema_path).with_indifferent_access - end - end - - # Converts service class name to file path namespace. - # - # Transforms a class name like "Services::ProcessPayment::Service" into - # "services/process_payment" for locating schema files. - # - # @param service_class [Class] the service class - # @return [String] underscored namespace path - # - # @example - # parse_service_namespace(Services::ProcessPayment::Service) - # # => "services/process_payment" - # - # @api private - def self.parse_service_namespace(service_class) - service_class.name.split('::')[..-2].map do |s| - s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase - end.join('/') - end end end end diff --git a/gem/lib/servus/testing/example_extractor.rb b/gem/lib/servus/testing/example_extractor.rb index be26da76..9539f04e 100644 --- a/gem/lib/servus/testing/example_extractor.rb +++ b/gem/lib/servus/testing/example_extractor.rb @@ -286,13 +286,11 @@ def deep_symbolize_keys(value) end end - # Loads schema from service class using Validator. + # Loads a schema from a service class using the Validator. # - # Reuses the existing Validator schema loading logic which handles: - # - DSL-defined schemas - # - Constant-defined schemas - # - File-based schemas - # - Schema caching + # The schema returned is compiled, so +example+ and +examples+ values + # inside +$ref+'d fragments are visible to extraction — a shared fragment + # can carry its own examples and every service referencing it gets them. # # @param service_class [Class] The service class # @param schema_type [Symbol] Either :arguments or :result diff --git a/gem/lib/servus/testing/matchers.rb b/gem/lib/servus/testing/matchers.rb index 7f55db16..5a555baf 100644 --- a/gem/lib/servus/testing/matchers.rb +++ b/gem/lib/servus/testing/matchers.rb @@ -94,12 +94,7 @@ module Matchers # Matcher for asserting schema presence on a service or Event class RSpec::Matchers.define :have_schema do |schema_type| match do |klass| - if schema_type.to_s == 'payload' - !klass.payload_schema.nil? - else - Servus::Support::Validator.clear_cache! - !Servus::Support::Validator.load_schema(klass, schema_type.to_s).nil? - end + !Servus::Support::Validator.load_schema(klass, schema_type.to_s).nil? end failure_message do |klass| diff --git a/gem/lib/servus/version.rb b/gem/lib/servus/version.rb index b69a42a5..5afbe4e8 100644 --- a/gem/lib/servus/version.rb +++ b/gem/lib/servus/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Servus - VERSION = '0.7.0' + VERSION = '1.0.0' end diff --git a/gem/servus.gemspec b/gem/servus.gemspec index 1efb0bd4..d45ac7f7 100644 --- a/gem/servus.gemspec +++ b/gem/servus.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |spec| spec.summary = 'A gem for managing service objects.' spec.description = 'Servus is a Ruby gem that provides a structured way to create and manage service objects, promoting clean code architecture and separation of concerns in your applications.' - spec.required_ruby_version = '>= 3.0.0' + spec.required_ruby_version = '>= 3.2.0' spec.metadata['allowed_push_host'] = 'https://rubygems.org' spec.metadata['source_code_uri'] = 'https://github.com/zarpay/servus' diff --git a/gem/spec/generators/servus/service_generator_spec.rb b/gem/spec/generators/servus/service_generator_spec.rb index bc9afe5a..d7398005 100644 --- a/gem/spec/generators/servus/service_generator_spec.rb +++ b/gem/spec/generators/servus/service_generator_spec.rb @@ -10,10 +10,12 @@ let(:tmp_root) { Dir.mktmpdir('servus-generator-spec') } around do |example| - original = Servus.config.tests_dir + original_tests = Servus.config.tests_dir + original_services = Servus.config.services_dir example.run ensure - Servus.config.tests_dir = original + Servus.config.tests_dir = original_tests + Servus.config.services_dir = original_services FileUtils.rm_rf(tmp_root) end @@ -35,4 +37,87 @@ def invoke(name, *parameters) expect(File).to exist(File.join(tmp_root, 'test/services/treasury/transfer_gold/service_spec.rb')) expect(File).not_to exist(File.join(tmp_root, 'spec/services/treasury/transfer_gold/service_spec.rb')) end + + it 'generates the service class' do + invoke('treasury/transfer_gold', 'from_account') + + expect(File).to exist(File.join(tmp_root, 'app/services/treasury/transfer_gold/service.rb')) + end + + it 'honours config.services_dir when placing the service' do + Servus.config.services_dir = 'app/domain' + + invoke('treasury/transfer_gold', 'from_account') + + expect(File).to exist(File.join(tmp_root, 'app/domain/treasury/transfer_gold/service.rb')) + expect(File).not_to exist(File.join(tmp_root, 'app/services/treasury/transfer_gold/service.rb')) + end + + describe 'the generated schema declaration' do + subject(:contents) do + invoke('treasury/transfer_gold', 'from_account', 'gold_dragons') + File.read(File.join(tmp_root, 'app/services/treasury/transfer_gold/service.rb')) + end + + # Commented-out scaffolding gets skipped. Real code gets filled in. + it 'is live code rather than a comment block' do + expect(contents).to match(/^\s{4}schema\(/) + end + + it 'declares both an arguments and a result schema' do + expect(contents).to include('arguments: {') + expect(contents).to include('result: {') + end + + it 'requires every declared parameter' do + expect(contents).to include('required: %w[from_account gold_dragons]') + end + + it 'lists every parameter as a property' do + expect(contents).to match(/from_account: \{/) + expect(contents).to match(/gold_dragons: \{/) + end + + it 'still generates a schema when docs are skipped' do + described_class.start( + ['treasury/transfer_gold', 'from_account', '--no-docs'], + destination_root: tmp_root + ) + written = File.read(File.join(tmp_root, 'app/services/treasury/transfer_gold/service.rb')) + + expect(written).to match(/^\s{4}schema\(/) + end + + it 'produces a service whose schema Servus accepts' do + invoke('treasury/transfer_gold', 'from_account') + written = File.read(File.join(tmp_root, 'app/services/treasury/transfer_gold/service.rb')) + + # Strip the module nesting and evaluate the class body against Servus::Base + # to prove the generated declaration is valid, not merely well-shaped text. + body = written[/class Service < Servus::Base\n(.*)\n end\nend/m, 1] + klass = Class.new(Servus::Base) + expect { klass.class_eval(body) }.not_to raise_error + expect(klass.arguments_schema['required']).to eq(['from_account']) + end + end + + it 'indents every instance variable assignment inside initialize' do + invoke('treasury/transfer_gold', 'from_account', 'to_account') + + contents = File.read(File.join(tmp_root, 'app/services/treasury/transfer_gold/service.rb')) + body = contents[/def initialize.*?\n(.*?)\n end/m, 1] + + expect(body.lines.map { |line| line[/\A */].length }.uniq).to eq([6]) + end + + describe 'a service generated with no parameters' do + subject(:contents) do + invoke('treasury/reconcile') + File.read(File.join(tmp_root, 'app/services/treasury/reconcile/service.rb')) + end + + it 'declares an empty required list rather than omitting it' do + expect(contents).to include('required: []') + end + end end diff --git a/gem/spec/servus/base_call_spec.rb b/gem/spec/servus/base_call_spec.rb deleted file mode 100644 index 22d5d413..00000000 --- a/gem/spec/servus/base_call_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module BaseCallSpecSupport - class InnerService < Servus::Base - def initialize(should_fail: false) - @should_fail = should_fail - end - - def call - return failure('Inner went boom', type: Servus::Support::Errors::NotFoundError) if @should_fail - - success(value: 42) - end - end - - class OuterService < Servus::Base - def initialize(should_fail: false) - @should_fail = should_fail - end - - def call - data = call!(InnerService, should_fail: @should_fail) - success(value: data.value) - end - end -end - -RSpec.describe Servus::Base do - describe '#call!' do - subject(:result) { BaseCallSpecSupport::OuterService.call(should_fail: should_fail) } - - context 'when the sub-service succeeds' do - let(:should_fail) { false } - - it 'lets the outer service proceed' do - expect(result).to be_success - end - - it "exposes the sub-service's data to the caller" do - expect(result.data.value).to eq(42) - end - end - - context 'when the sub-service fails' do - let(:should_fail) { true } - - it 'halts the outer service' do - expect(result).not_to be_success - end - - it "passes the sub-service's failure through untouched" do - expect(result.error).to be_a(Servus::Support::Errors::NotFoundError) - expect(result.error.message).to eq('Inner went boom') - end - end - - it 'is defined as an instance method on Servus::Base' do - expect(Servus::Base.instance_method(:call!)).not_to be_nil - end - end -end diff --git a/gem/spec/servus/base_events_spec.rb b/gem/spec/servus/base_events_spec.rb index 1ffab1f2..7248a87a 100644 --- a/gem/spec/servus/base_events_spec.rb +++ b/gem/spec/servus/base_events_spec.rb @@ -123,7 +123,7 @@ def custom_payload(result) } } - invoke EventTestHelpers::NoopService do |_payload| + enqueue EventTestHelpers::NoopService do |_payload| {} end end) @@ -153,7 +153,7 @@ def call } } - invoke EventTestHelpers::NoopService do |_payload| + enqueue EventTestHelpers::NoopService do |_payload| {} end end) @@ -174,7 +174,7 @@ def call stub_const('NoSchemaHandler', Class.new(Servus::Event) do event_name :unvalidated_event - invoke EventTestHelpers::NoopService do |_payload| + enqueue EventTestHelpers::NoopService do |_payload| {} end end) diff --git a/gem/spec/servus/config_spec.rb b/gem/spec/servus/config_spec.rb index c13b6652..f9d5c71a 100644 --- a/gem/spec/servus/config_spec.rb +++ b/gem/spec/servus/config_spec.rb @@ -3,6 +3,50 @@ require 'spec_helper' RSpec.describe Servus::Config do + describe 'Servus.configure' do + after { Servus.config.tests_dir = 'spec' } + + it 'yields the configuration for modification' do + Servus.configure { |config| config.tests_dir = 'test' } + + expect(Servus.config.tests_dir).to eq('test') + end + + it 'yields the same singleton returned by Servus.config' do + expect { |b| Servus.configure(&b) }.to yield_with_args(Servus.config) + end + end + + describe '#services_dir' do + let(:default_dir) { 'app/services' } + + it 'defaults to app/services' do + expect(Servus.config.services_dir).to eq(default_dir) + end + + it 'can be customized' do + Servus.config.services_dir = 'app/domain' + expect(Servus.config.services_dir).to eq('app/domain') + end + + after { Servus.config.services_dir = default_dir } + end + + describe '#events_dir' do + let(:default_dir) { 'app/events' } + + it 'defaults to app/events' do + expect(Servus.config.events_dir).to eq(default_dir) + end + + it 'can be customized' do + Servus.config.events_dir = 'app/domain_events' + expect(Servus.config.events_dir).to eq('app/domain_events') + end + + after { Servus.config.events_dir = default_dir } + end + describe '#guards_dir' do let(:default_dir) { 'app/guards' } diff --git a/gem/spec/servus/event_spec.rb b/gem/spec/servus/event_spec.rb index 7f361fb6..bcabc741 100644 --- a/gem/spec/servus/event_spec.rb +++ b/gem/spec/servus/event_spec.rb @@ -2,7 +2,14 @@ require 'spec_helper' -RSpec.describe Servus::Event do +RSpec.describe Servus::Event, :inline_jobs do + # Event invocation enqueues through ActiveJob, which resolves a job by its + # class name — so an anonymous service has nothing to serialise. Give each + # fixture a real constant. + def named_service(name = 'DummyService', &body) + stub_const(name, Class.new(Servus::Base, &body)) + end + after do Servus::Events::Bus.clear end @@ -59,12 +66,12 @@ describe '.invoke' do it 'declares a service invocation with payload mapping' do - dummy_service = Class.new(Servus::Base) + dummy_service = named_service event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service do |payload| + enqueue dummy_service do |payload| { user_id: payload[:user_id] } end end @@ -76,7 +83,7 @@ end it 'passes the full payload when no block is given' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call(**args) @called_with = args Servus::Support::Response.new(true, args, nil) @@ -90,7 +97,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service + enqueue dummy_service end event_class.handle({ user_id: 123, email: 'test@example.com' }) @@ -98,28 +105,40 @@ class << self expect(dummy_service.called_with).to eq({ user_id: 123, email: 'test@example.com' }) end - it 'supports async option' do - dummy_service = Class.new(Servus::Base) + # `async: false` asked for synchronous invocation, which no longer exists. + # Silently giving it the opposite would be worse than refusing. + it 'rejects the removed async: option, whatever its value' do + dummy_service = named_service - event_class = Class.new(described_class) do - event_name :user_created - - invoke dummy_service, async: true do |payload| - { user_id: payload[:user_id] } - end + [true, false].each do |value| + expect do + Class.new(described_class) do + enqueue dummy_service, async: value + end + end.to raise_error(ArgumentError, /`async:` is no longer a valid option/) end + end - invocations = event_class.invocations - expect(invocations.first[:options][:async]).to be true + it 'points at enqueue when a class still uses invoke' do + dummy_service = named_service + + expect do + Class.new(described_class) do + invoke dummy_service, async: true + end + end.to raise_error(NoMethodError) { |error| + expect(error.message).to include('renamed to `enqueue`') + expect(error.message).to include('drop `async:`') + } end it 'supports conditional execution with :if option' do - dummy_service = Class.new(Servus::Base) + dummy_service = named_service event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, if: ->(payload) { payload[:premium] } do |payload| + enqueue dummy_service, if: ->(payload) { payload[:premium] } do |payload| { user_id: payload[:user_id] } end end @@ -131,7 +150,7 @@ class << self describe '.handle' do it 'dispatches to the configured service' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call(**args) @called_with = args Servus::Support::Response.new(true, { result: 'success' }, nil) @@ -145,7 +164,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service do |payload| + enqueue dummy_service do |payload| { user_id: payload[:user_id] } end end @@ -156,7 +175,7 @@ class << self end it 'respects :if condition - invokes when true' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call(**_args) @call_count = 1 Servus::Support::Response.new(true, nil, nil) @@ -170,7 +189,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, if: ->(payload) { payload[:premium] } do |payload| + enqueue dummy_service, if: ->(payload) { payload[:premium] } do |payload| { user_id: payload[:user_id] } end end @@ -181,7 +200,7 @@ class << self end it 'respects :if condition - skips when false' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call(**_args) @call_count = 1 Servus::Support::Response.new(true, nil, nil) @@ -195,7 +214,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, if: ->(payload) { payload[:premium] } do |payload| + enqueue dummy_service, if: ->(payload) { payload[:premium] } do |payload| { user_id: payload[:user_id] } end end @@ -206,7 +225,7 @@ class << self end it 'respects :unless condition - invokes when false' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call(**_args) @call_count = 1 Servus::Support::Response.new(true, nil, nil) @@ -220,7 +239,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, unless: ->(payload) { payload[:spam] } do |payload| + enqueue dummy_service, unless: ->(payload) { payload[:spam] } do |payload| { user_id: payload[:user_id] } end end @@ -231,7 +250,7 @@ class << self end it 'respects :unless condition - skips when true' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call(**_args) @call_count = 1 Servus::Support::Response.new(true, nil, nil) @@ -245,7 +264,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, unless: ->(payload) { payload[:spam] } do |payload| + enqueue dummy_service, unless: ->(payload) { payload[:spam] } do |payload| { user_id: payload[:user_id] } end end @@ -258,14 +277,14 @@ class << self it 'invokes multiple services in order' do calls = [] - service1 = Class.new(Servus::Base) do + service1 = named_service('ServiceOne') do define_singleton_method(:call) do |**args| calls << [:service1, args] Servus::Support::Response.new(true, nil, nil) end end - service2 = Class.new(Servus::Base) do + service2 = named_service('ServiceTwo') do define_singleton_method(:call) do |**args| calls << [:service2, args] Servus::Support::Response.new(true, nil, nil) @@ -275,11 +294,11 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke service1 do |payload| + enqueue service1 do |payload| { id: payload[:user_id] } end - invoke service2 do |payload| + enqueue service2 do |payload| { user: payload[:user_id] } end end @@ -293,7 +312,7 @@ class << self end it 'invokes service asynchronously when async: true' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call_async(**args) @async_called_with = args end @@ -306,7 +325,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, async: true do |payload| + enqueue dummy_service do |payload| { user_id: payload[:user_id] } end end @@ -317,7 +336,7 @@ class << self end it 'passes queue option to call_async' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call_async(**args) @async_called_with = args end @@ -330,7 +349,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, async: true, queue: :mailers do |payload| + enqueue dummy_service, queue: :mailers do |payload| { user_id: payload[:user_id] } end end @@ -341,7 +360,7 @@ class << self end it 'passes multiple scheduling options to call_async' do - dummy_service = Class.new(Servus::Base) do + dummy_service = named_service do def self.call_async(**args) @async_called_with = args end @@ -354,7 +373,7 @@ class << self event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, async: true, queue: :critical, wait: 10.minutes, priority: 5 do |payload| + enqueue dummy_service, queue: :critical, wait: 10.minutes, priority: 5 do |payload| { user_id: payload[:user_id] } end end @@ -372,12 +391,12 @@ class << self describe '.invocations_for' do it 'returns Invocation objects for the given payload' do - dummy_service = Class.new(Servus::Base) + dummy_service = named_service event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service do |payload| + enqueue dummy_service do |payload| { user_id: payload[:user_id] } end end @@ -391,12 +410,12 @@ class << self end it 'filters out invocations that fail the if condition' do - dummy_service = Class.new(Servus::Base) + dummy_service = named_service event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, if: ->(p) { p[:premium] } do |payload| + enqueue dummy_service, if: ->(p) { p[:premium] } do |payload| { user_id: payload[:user_id] } end end @@ -405,29 +424,28 @@ class << self expect(event_class.invocations_for({ user_id: 1, premium: true }).length).to eq(1) end - it 'passes async options through to the Invocation' do - dummy_service = Class.new(Servus::Base) + it 'passes scheduling options through to the Invocation' do + dummy_service = named_service event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, async: true, queue: :mailers do |payload| + enqueue dummy_service, queue: :mailers do |payload| { user_id: payload[:user_id] } end end invocation = event_class.invocations_for({ user_id: 1 }).first - expect(invocation.options[:async]).to be true expect(invocation.options[:queue]).to eq(:mailers) end it 'excludes if/unless from the Invocation options' do - dummy_service = Class.new(Servus::Base) + dummy_service = named_service event_class = Class.new(described_class) do event_name :user_created - invoke dummy_service, async: true, if: ->(_p) { true } do |payload| + enqueue dummy_service, if: ->(_p) { true } do |payload| { user_id: payload[:user_id] } end end diff --git a/gem/spec/servus/events/bus_spec.rb b/gem/spec/servus/events/bus_spec.rb index c4fbc6d9..e4f5d460 100644 --- a/gem/spec/servus/events/bus_spec.rb +++ b/gem/spec/servus/events/bus_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Servus::Events::Bus do +RSpec.describe Servus::Events::Bus, :inline_jobs do after do described_class.clear Servus.config.routers = nil @@ -37,7 +37,7 @@ Class.new(Servus::Event) do event_name :test_event - invoke ServiceA do |payload| + enqueue ServiceA do |payload| { user_id: payload[:user_id] } end end diff --git a/gem/spec/servus/events/class_router_spec.rb b/gem/spec/servus/events/class_router_spec.rb index 2221222b..0cb56bc5 100644 --- a/gem/spec/servus/events/class_router_spec.rb +++ b/gem/spec/servus/events/class_router_spec.rb @@ -29,7 +29,7 @@ def self.call(**args) Class.new(Servus::Event) do event_name :order_placed - invoke svc do |payload| + enqueue svc do |payload| { user_id: payload[:user_id] } end end @@ -47,11 +47,11 @@ def self.call(**args) Class.new(Servus::Event) do event_name :order_placed - invoke svc_a do |payload| + enqueue svc_a do |payload| { user_id: payload[:user_id] } end - invoke svc_b do |payload| + enqueue svc_b do |payload| { order_id: payload[:order_id] } end end @@ -66,7 +66,7 @@ def self.call(**args) Class.new(Servus::Event) do event_name :order_placed - invoke svc, if: ->(p) { p[:premium] } do |payload| + enqueue svc, if: ->(p) { p[:premium] } do |payload| { user_id: payload[:user_id] } end end @@ -81,7 +81,7 @@ def self.call(**args) Class.new(Servus::Event) do event_name :order_placed - invoke svc, if: ->(p) { p[:premium] } do |payload| + enqueue svc, if: ->(p) { p[:premium] } do |payload| { user_id: payload[:user_id] } end end diff --git a/gem/spec/servus/events/emitter_spec.rb b/gem/spec/servus/events/emitter_spec.rb new file mode 100644 index 00000000..a712653c --- /dev/null +++ b/gem/spec/servus/events/emitter_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Servus::Events::Emitter do + after { Servus::Events::Bus.clear } + + describe 'EMISSION_TRIGGERS' do + it 'lists the supported triggers' do + expect(described_class::EMISSION_TRIGGERS).to eq(%i[success failure error!]) + end + + it 'is frozen' do + expect(described_class::EMISSION_TRIGGERS).to be_frozen + end + end + + describe '.emits' do + let(:service_class) { Class.new(Servus::Base) } + + it 'accepts every supported trigger' do + described_class::EMISSION_TRIGGERS.each do |trigger| + expect { service_class.emits(:something_happened, on: trigger) }.not_to raise_error + end + end + + it 'rejects an unsupported trigger' do + expect { service_class.emits(:something_happened, on: :whenever) } + .to raise_error(ArgumentError, /Invalid trigger: whenever/) + end + + # The bang is easy to drop, and the resulting event would simply never fire. + it 'names error! with its bang when rejecting the unbanged spelling' do + expect { service_class.emits(:something_happened, on: :error) } + .to raise_error(ArgumentError, /error!/) + end + end + + describe 'payload schema enforcement' do + subject(:call_service) { service_class.call } + + let(:service_class) do + stub_const('UnregisteredEmitService', Class.new(Servus::Base) do + emits :nothing_listens_to_this, on: :success + + def call = success({ any: 'data' }) + end) + end + + after { Servus.config.require_event_payload_schema = false } + + context 'when no Event class is registered for the emitted name' do + it 'emits without validating while enforcement is off' do + expect { call_service }.not_to raise_error + end + + # Without this the one flag whose job is to make a missing payload schema + # loud was silently bypassed on the events that had no schema at all. + it 'raises once enforcement is on' do + Servus.config.require_event_payload_schema = true + + expect { call_service } + .to raise_error(Servus::Support::Errors::SchemaRequiredError, /require_event_payload_schema/) + end + + it 'names the service and the event it could not validate' do + Servus.config.require_event_payload_schema = true + + expect { call_service }.to raise_error(Servus::Support::Errors::SchemaRequiredError) { |error| + expect(error.message).to include('UnregisteredEmitService') + expect(error.message).to include('nothing_listens_to_this') + } + end + end + + context 'when an Event class is registered' do + let(:service_class) do + stub_const('RegisteredEmitService', Class.new(Servus::Base) do + emits :registered_emission, on: :success + + def call = success({ user_id: 7 }) + end) + end + + before do + stub_const('RegisteredEmission', Class.new(Servus::Event) do + event_name :registered_emission + + schema payload: { + type: 'object', + required: ['user_id'], + properties: { user_id: { type: 'integer' } } + } + end) + end + + it 'validates against its schema rather than raising' do + Servus.config.require_event_payload_schema = true + + expect { call_service }.not_to raise_error + end + + it 'still reports a payload that does not match' do + service_class.define_method(:call) { success({ user_id: 'seven' }) } + + expect { call_service } + .to raise_error(Servus::Support::Errors::ValidationError, /user_id/) + end + end + end +end diff --git a/gem/spec/servus/events/errors_spec.rb b/gem/spec/servus/events/errors_spec.rb new file mode 100644 index 00000000..f70d74cc --- /dev/null +++ b/gem/spec/servus/events/errors_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Servus::Events::Errors do + describe described_class::AsyncBackendMissingError do + subject(:error) { described_class.for(stub_const('Ledger::RecordEntry::Service', Class.new(Servus::Base))) } + + it 'names the service that could not be enqueued' do + expect(error.message).to include('Ledger::RecordEntry::Service') + end + + it 'says what is missing' do + expect(error.message).to include('ActiveJob is not loaded') + end + + it 'says what to do about it' do + expect(error.message).to include('Require active_job') + end + + # Rescuing these as service failures would render a job-backend problem to + # an API caller as though it were a business outcome. + it 'is not a ServiceError' do + expect(error).not_to be_a(Servus::Support::Errors::ServiceError) + end + end + + describe described_class::AnonymousServiceError do + subject(:error) { described_class.for(Class.new(Servus::Base)) } + + it 'explains why a name is required' do + expect(error.message).to include('resolves jobs by class name') + end + + it 'says what to do about it' do + expect(error.message).to include('assigned') + expect(error.message).to include('constant') + end + end + + it 'gives both errors a common ancestor' do + expect(described_class::AsyncBackendMissingError.ancestors).to include(described_class::Error) + expect(described_class::AnonymousServiceError.ancestors).to include(described_class::Error) + end +end diff --git a/gem/spec/servus/events/invocation_spec.rb b/gem/spec/servus/events/invocation_spec.rb index 76034ce3..f2f37614 100644 --- a/gem/spec/servus/events/invocation_spec.rb +++ b/gem/spec/servus/events/invocation_spec.rb @@ -20,103 +20,88 @@ class << self end end - describe '#execute' do - it 'calls the service synchronously when async is not set' do - invocation = described_class.new( - service: service_class, - params: { user_id: 123 }, - options: {} - ) + def invocation(params: { user_id: 123 }, options: {}) + described_class.new(service: service_class, params: params, options: options) + end - invocation.execute + describe '#enqueue' do + it 'enqueues the service' do + invocation.enqueue - expect(service_class.called_with).to eq({ user_id: 123 }) + expect(service_class.async_called_with).to eq({ user_id: 123 }) end - it 'calls the service asynchronously when async is true' do - invocation = described_class.new( - service: service_class, - params: { user_id: 456 }, - options: { async: true } - ) - - invocation.execute + # Sync invocation is gone entirely — there is no option that brings it back. + it 'never calls the service inline' do + invocation.enqueue - expect(service_class.async_called_with).to eq({ user_id: 456 }) + expect(service_class.called_with).to be_nil end - it 'passes queue option to call_async' do - invocation = described_class.new( - service: service_class, - params: { user_id: 789 }, - options: { async: true, queue: :mailers } - ) - - invocation.execute + it 'passes the queue option through' do + invocation(options: { queue: :mailers }).enqueue - expect(service_class.async_called_with).to eq({ user_id: 789, queue: :mailers }) + expect(service_class.async_called_with).to eq({ user_id: 123, queue: :mailers }) end - it 'passes wait option to call_async' do - invocation = described_class.new( - service: service_class, - params: { user_id: 1 }, - options: { async: true, wait: 300 } - ) + it 'passes the wait option through' do + invocation(options: { wait: 300 }).enqueue - invocation.execute - - expect(service_class.async_called_with).to eq({ user_id: 1, wait: 300 }) + expect(service_class.async_called_with).to eq({ user_id: 123, wait: 300 }) end - it 'passes priority option to call_async' do - invocation = described_class.new( - service: service_class, - params: { user_id: 1 }, - options: { async: true, priority: 10 } - ) - - invocation.execute + it 'passes the priority option through' do + invocation(options: { priority: 10 }).enqueue - expect(service_class.async_called_with).to eq({ user_id: 1, priority: 10 }) + expect(service_class.async_called_with).to eq({ user_id: 123, priority: 10 }) end - it 'passes multiple scheduling options to call_async' do - invocation = described_class.new( - service: service_class, - params: { user_id: 1 }, - options: { async: true, queue: :critical, wait: 600, priority: 5 } - ) - - invocation.execute + it 'passes multiple scheduling options through' do + invocation(options: { queue: :critical, wait: 600, priority: 5 }).enqueue expect(service_class.async_called_with).to eq({ - user_id: 1, + user_id: 123, queue: :critical, wait: 600, priority: 5 }) end + + it 'drops options that are not scheduling options' do + invocation(options: { nonsense: true }).enqueue + + expect(service_class.async_called_with).to eq({ user_id: 123 }) + end + + context 'when the service cannot be enqueued' do + let(:service_class) { Class.new(Servus::Base) } + + before { allow(service_class).to receive(:respond_to?).with(:call_async).and_return(false) } + + # Without ActiveJob this used to be a bare NoMethodError raised from inside + # the emitting service's after_call. + it 'raises naming the service and what to do' do + expect { invocation.enqueue } + .to raise_error(Servus::Events::Errors::AsyncBackendMissingError) { |error| + expect(error.message).to include('ActiveJob is not loaded') + expect(error.message).to include('Require active_job') + } + end + end end describe '#key' do it 'is the same for identical service and params' do - a = described_class.new(service: service_class, params: { user_id: 1 }, options: {}) - b = described_class.new(service: service_class, params: { user_id: 1 }, options: {}) - - expect(a.key).to eq(b.key) + expect(invocation(params: { user_id: 1 }).key).to eq(invocation(params: { user_id: 1 }).key) end it 'differs when params differ' do - a = described_class.new(service: service_class, params: { user_id: 1 }, options: {}) - b = described_class.new(service: service_class, params: { user_id: 2 }, options: {}) - - expect(a.key).not_to eq(b.key) + expect(invocation(params: { user_id: 1 }).key).not_to eq(invocation(params: { user_id: 2 }).key) end it 'excludes options from the key' do - a = described_class.new(service: service_class, params: { user_id: 1 }, options: {}) - b = described_class.new(service: service_class, params: { user_id: 1 }, options: { async: true, queue: :low }) + a = invocation(params: { user_id: 1 }, options: {}) + b = invocation(params: { user_id: 1 }, options: { queue: :low, priority: 5 }) expect(a.key).to eq(b.key) end diff --git a/gem/spec/servus/extensions/async/call_spec.rb b/gem/spec/servus/extensions/async/call_spec.rb index 70c59267..ca8be0cf 100644 --- a/gem/spec/servus/extensions/async/call_spec.rb +++ b/gem/spec/servus/extensions/async/call_spec.rb @@ -92,4 +92,34 @@ AsyncEmailService.call_async(test: 'data') end.to raise_error(Servus::Extensions::Async::Errors::JobEnqueueError, /Failed to enqueue async job/) end + + # With the :inline and :test adapters, perform_later runs the service — so a + # service's own failure surfaces here. Wrapping it as an enqueue failure would + # blame the wrong layer and hide the real cause. + it 'lets a service error through rather than reporting an enqueue failure' do + allow(job_class).to receive(:perform_later) + .and_raise(Servus::Support::Errors::ValidationError, 'Invalid arguments') + + expect do + AsyncEmailService.call_async(test: 'data') + end.to raise_error(Servus::Support::Errors::ValidationError, /Invalid arguments/) + end + + it 'lets an event error through as well' do + allow(job_class).to receive(:perform_later) + .and_raise(Servus::Events::Errors::AnonymousServiceError, 'anonymous') + + expect do + AsyncEmailService.call_async(test: 'data') + end.to raise_error(Servus::Events::Errors::AnonymousServiceError) + end + + describe 'anonymous services' do + # ActiveJob resolves a job on the worker by its serialized class name, so + # there is nothing to serialize for a class with no name. + it 'raises a named error rather than NoMethodError on nil' do + expect { Class.new(Servus::Base).call_async(test: 'data') } + .to raise_error(Servus::Events::Errors::AnonymousServiceError, /anonymous/) + end + end end diff --git a/gem/spec/servus/helpers/controller_helpers_spec.rb b/gem/spec/servus/helpers/controller_helpers_spec.rb index 3fc84dbf..b7d7fcaa 100644 --- a/gem/spec/servus/helpers/controller_helpers_spec.rb +++ b/gem/spec/servus/helpers/controller_helpers_spec.rb @@ -72,47 +72,6 @@ def self.call(**) end end - describe '#run_service!' do - let(:error) do - klass = Class.new(Servus::Support::Errors::ServiceError) do - def self.name = 'TestServiceError' - end - klass.new('nope') - end - - let(:raising_service) do - failing_error = error - Class.new do - define_singleton_method(:call) do |**_args| - Servus::Support::Response.new(false, nil, failing_error) - end - end - end - - it 'returns the data on success' do - data = controller.run_service!(fake_service_success) - expect(data[:hello]).to eq('world') - end - - it "raises the failure's error on failure" do - expect { controller.run_service!(raising_service) } - .to raise_error(Servus::Support::Errors::ServiceError, /nope/) - end - - it 'forwards keyword arguments to the service' do - captured = nil - capturing_service = Class.new do - define_singleton_method(:call) do |**args| - captured = args - Servus::Support::Response.new(true, { ok: true }, nil) - end - end - - controller.run_service!(capturing_service, foo: 1, bar: 2) - expect(captured).to eq(foo: 1, bar: 2) - end - end - describe '#render_service_error' do it 'renders error with http_status and api_error body' do controller.render_service_error(error_class.new) diff --git a/gem/spec/servus/schema/cache_spec.rb b/gem/spec/servus/schema/cache_spec.rb new file mode 100644 index 00000000..324c2cd2 --- /dev/null +++ b/gem/spec/servus/schema/cache_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Schema::Cache do + subject(:cache) { described_class.new } + + describe '#resolve' do + it 'returns the block value on a miss' do + expect(cache.resolve('#/core') { 'resolved' }).to eq('resolved') + end + + it 'does not call the block again on a hit' do + cache.resolve('#/core') { 'first' } + + expect(cache.resolve('#/core') { raise 'should not be called' }).to eq('first') + end + + it 'keys entries independently' do + cache.resolve('#/a') { 'a' } + cache.resolve('#/b') { 'b' } + + expect(cache.size).to eq(2) + end + + it 'caches a falsey resolution rather than recomputing it' do + cache.resolve('#/core') { false } + + expect(cache.resolve('#/core') { 'recomputed' }).to be(false) + end + + # A ref that failed part way through must not leave a half-built value + # behind for the next caller to pick up. + it 'stores nothing when the block raises' do + expect { cache.resolve('#/core') { raise 'boom' } }.to raise_error('boom') + + expect(cache.size).to eq(0) + end + end + + describe '#invalidate!' do + it 'drops every entry' do + cache.resolve('#/core') { 'resolved' } + + cache.invalidate! + + expect(cache.size).to eq(0) + end + + it 'advances the generation' do + expect { cache.invalidate! }.to change(cache, :generation).by(1) + end + end + + describe '#generation' do + it 'starts at zero' do + expect(cache.generation).to eq(0) + end + + it 'does not move when entries are merely added' do + expect { cache.resolve('#/core') { 'resolved' } }.not_to change(cache, :generation) + end + end +end diff --git a/gem/spec/servus/schema/compiler_spec.rb b/gem/spec/servus/schema/compiler_spec.rb new file mode 100644 index 00000000..2627cb57 --- /dev/null +++ b/gem/spec/servus/schema/compiler_spec.rb @@ -0,0 +1,325 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Schema::Compiler, :schema_registry do + subject(:compile) { described_class.new(context: context).compile(schema) } + + let(:context) { nil } + + before do + Servus::Schema.register('core', { + '$defs' => { + 'amount' => { 'type' => 'integer', 'minimum' => 0, 'description' => 'An amount' }, + 'timestamp' => { 'type' => 'string', 'format' => 'date-time' } + } + }) + end + + describe 'schemas without refs' do + let(:schema) do + { 'type' => 'object', 'properties' => { 'name' => { 'type' => 'string' } }, 'required' => ['name'] } + end + + it 'returns an equal schema' do + expect(compile).to eq(schema) + end + + it 'consults no fragments' do + expect { compile }.not_to(change { Servus::Schema.cache.size }) + end + end + + describe 'whole-fragment refs' do + let(:schema) { { '$ref' => '#/core' } } + + it 'resolves to the entire fragment' do + expect(compile['$defs']['amount']['type']).to eq('integer') + end + end + + describe 'path refs' do + let(:schema) do + { 'type' => 'object', 'properties' => { 'fee' => { '$ref' => '#/core/$defs/amount' } } } + end + + it 'resolves to the fragment at that path' do + expect(compile['properties']['fee']).to eq( + { 'type' => 'integer', 'minimum' => 0, 'description' => 'An amount' } + ) + end + + it 'leaves no $ref in the output' do + expect(compile.to_s).not_to include('$ref') + end + end + + describe 'transitive refs' do + before do + Servus::Schema.register('money', { + '$defs' => { + 'price' => { '$ref' => '#/core/$defs/amount' } + } + }) + end + + let(:schema) { { '$ref' => '#/money/$defs/price' } } + + it 'follows a ref that points at another ref' do + expect(compile).to eq({ 'type' => 'integer', 'minimum' => 0, 'description' => 'An amount' }) + end + end + + describe 'refs inside arrays' do + let(:schema) do + { 'anyOf' => [{ '$ref' => '#/core/$defs/amount' }, { 'type' => 'null' }] } + end + + it 'resolves each element' do + expect(compile['anyOf'].first['type']).to eq('integer') + expect(compile['anyOf'].last).to eq({ 'type' => 'null' }) + end + end + + describe 'refs nested deep in the document' do + let(:schema) do + { + 'type' => 'object', + 'properties' => { + 'items' => { + 'type' => 'array', + 'items' => { 'type' => 'object', 'properties' => { 'fee' => { '$ref' => '#/core/$defs/amount' } } } + } + } + } + end + + it 'resolves them' do + expect(compile.dig('properties', 'items', 'items', 'properties', 'fee', 'type')).to eq('integer') + end + end + + # A ref does not have to land on an object. JSON Schema puts arrays in + # `required` and `enum`, and draft-06 accepts a bare boolean as a schema. + describe 'refs that resolve to something other than a hash' do + before do + Servus::Schema.register('lists', { + '$defs' => { + 'statuses' => %w[open closed], + 'anything' => true + } + }) + end + + it 'resolves a ref to an array' do + result = described_class.new.compile( + { 'type' => 'object', 'required' => { '$ref' => '#/lists/$defs/statuses' } } + ) + + expect(result['required']).to eq(%w[open closed]) + end + + it 'resolves a ref to a boolean schema' do + result = described_class.new.compile( + { 'properties' => { 'extra' => { '$ref' => '#/lists/$defs/anything' } } } + ) + + expect(result.dig('properties', 'extra')).to be(true) + end + end + + describe 'sibling properties' do + let(:schema) do + { '$ref' => '#/core/$defs/amount', 'description' => 'The fee charged' } + end + + it 'overrides the resolved value' do + expect(compile['description']).to eq('The fee charged') + end + + it 'keeps the keys it does not override' do + expect(compile['type']).to eq('integer') + expect(compile['minimum']).to eq(0) + end + + it 'resolves a sibling that is itself a ref' do + schema = { + '$ref' => '#/core/$defs/amount', + 'properties' => { 'at' => { '$ref' => '#/core/$defs/timestamp' } } + } + + result = described_class.new.compile(schema) + + expect(result['properties']['at']['format']).to eq('date-time') + end + + # The memo holds the resolved target *before* siblings are merged, so the + # same ref used with different siblings must not contaminate other sites. + it 'does not leak overrides between sites that share a ref' do + schema = { + 'properties' => { + 'a' => { '$ref' => '#/core/$defs/amount', 'description' => 'A' }, + 'b' => { '$ref' => '#/core/$defs/amount', 'description' => 'B' }, + 'c' => { '$ref' => '#/core/$defs/amount' } + } + } + + result = described_class.new.compile(schema) + + expect(result['properties']['a']['description']).to eq('A') + expect(result['properties']['b']['description']).to eq('B') + expect(result['properties']['c']['description']).to eq('An amount') + end + end + + describe 'memoization' do + let(:schema) do + { + 'properties' => { + 'a' => { '$ref' => '#/core/$defs/amount' }, + 'b' => { '$ref' => '#/core/$defs/amount' }, + 'c' => { '$ref' => '#/core/$defs/amount' } + } + } + end + + it 'expands a repeated ref once' do + expect { compile }.to change { Servus::Schema.cache.size }.by(1) + end + + it 'reuses the memo across separate compiles' do + described_class.new.compile(schema) + + expect { described_class.new.compile(schema) } + .not_to(change { Servus::Schema.cache.size }) + end + end + + describe 'cycles' do + it 'raises on a direct self-reference' do + Servus::Schema.register('a', { 'self' => { '$ref' => '#/a/self' } }) + + expect { described_class.new.compile({ '$ref' => '#/a/self' }) } + .to raise_error(Servus::Schema::CircularReferenceError, %r{#/a/self -> #/a/self}) + end + + it 'raises on an indirect cycle and names every hop' do + Servus::Schema.register('a', { 'node' => { '$ref' => '#/b/node' } }) + Servus::Schema.register('b', { 'node' => { '$ref' => '#/a/node' } }) + + expect { described_class.new.compile({ '$ref' => '#/a/node' }) } + .to raise_error( + Servus::Schema::CircularReferenceError, + %r{#/a/node -> #/b/node -> #/a/node} + ) + end + end + + describe 'depth' do + # A depth-counter-only implementation cannot tell this apart from a cycle. + it 'compiles a long acyclic chain of fragments' do + 60.times do |i| + target = i.zero? ? { 'type' => 'integer' } : { '$ref' => "#/chain_#{i - 1}/node" } + Servus::Schema.register("chain_#{i}", { 'node' => target }) + end + + expect(described_class.new.compile({ '$ref' => '#/chain_59/node' })) + .to eq({ 'type' => 'integer' }) + end + + it 'raises DepthExceededError, not CircularReferenceError, on runaway nesting' do + deep = { 'type' => 'integer' } + (described_class::MAX_DEPTH + 5).times { deep = { 'properties' => { 'x' => deep } } } + + expect { described_class.new.compile(deep) } + .to raise_error(Servus::Schema::DepthExceededError) + end + end + + describe 'errors' do + let(:context) { 'Treasury::TransferGold::Service arguments schema' } + + context 'when the fragment key is not registered' do + let(:schema) { { '$ref' => '#/cor/$defs/amount' } } + + it 'names the key, the ref, the context and a suggestion' do + expect { compile }.to raise_error(Servus::Schema::UnknownKeyError) { |error| + expect(error.message).to include('"cor"') + expect(error.message).to include('Did you mean: "core"') + expect(error.message).to include(context) + } + end + end + + context 'when the path within a known fragment is absent' do + let(:schema) { { '$ref' => '#/core/$defs/nope' } } + + it 'lists what was available at the failing segment' do + expect { compile }.to raise_error(Servus::Schema::RefNotFoundError) { |error| + expect(error.message).to include('"nope"') + expect(error.message).to include('Available keys: "amount", "timestamp"') + } + end + end + + context 'when the failure is reached through another ref' do + let(:schema) { { '$ref' => '#/outer/node' } } + + before { Servus::Schema.register('outer', { 'node' => { '$ref' => '#/core/$defs/nope' } }) } + + it 'reports the resolution path' do + expect { compile }.to raise_error( + Servus::Schema::RefNotFoundError, + %r{resolution path: #/outer/node -> #/core/\$defs/nope} + ) + end + end + + # Which ref forms are rejected, and why, belongs to Servus::Schema::Ref. + # What matters here is that the compiler surfaces that rejection with the + # context of the schema being compiled attached. + context 'with an unsupported ref form' do + let(:schema) { { 'properties' => { 'fee' => { '$ref' => '#/$defs/amount' } } } } + + it 'raises InvalidRefError naming the schema being compiled' do + expect { compile }.to raise_error(Servus::Schema::InvalidRefError) { |error| + expect(error.message).to include('local ref') + expect(error.message).to include(context) + } + end + end + end + + describe 'fragment metadata' do + before do + Servus::Schema.register('doc', { + '$schema' => 'http://json-schema.org/draft-07/schema#', + '$id' => 'doc', + 'type' => 'integer' + }) + end + + # json-schema raises SchemaError on a $schema URI it does not recognize, + # at any position in the document, so a spliced fragment must not carry one. + it 'strips $schema and $id from a spliced fragment' do + result = described_class.new.compile({ 'properties' => { 'x' => { '$ref' => '#/doc' } } }) + + expect(result['properties']['x']).to eq({ 'type' => 'integer' }) + end + + it 'produces a schema json-schema can validate against' do + result = described_class.new.compile( + { 'type' => 'object', 'properties' => { 'x' => { '$ref' => '#/doc' } } } + ) + + expect(JSON::Validator.fully_validate(result, { 'x' => 'nope' })).not_to be_empty + expect(JSON::Validator.fully_validate(result, { 'x' => 5 })).to be_empty + end + end + + describe 'immutability' do + let(:schema) { { 'properties' => { 'fee' => { '$ref' => '#/core/$defs/amount' } } } } + + it 'does not hand back the registry object itself' do + expect(compile['properties']['fee']).not_to equal(Servus::Schema.fetch('core')['$defs']['amount']) + end + end +end diff --git a/gem/spec/servus/schema/declaration_spec.rb b/gem/spec/servus/schema/declaration_spec.rb new file mode 100644 index 00000000..2a1c7b1b --- /dev/null +++ b/gem/spec/servus/schema/declaration_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Schema::Declaration, :schema_registry do + let(:service_class) { stub_const('DeclarationTest::Service', Class.new(Servus::Base)) } + + describe '.schema' do + it 'stores a declared schema with indifferent access' do + service_class.schema arguments: { type: 'object', required: ['name'] } + + expect(service_class.arguments_schema[:type]).to eq('object') + expect(service_class.arguments_schema['type']).to eq('object') + end + + it 'leaves other kinds untouched' do + service_class.schema arguments: { type: 'object' } + + expect(service_class.result_schema).to be_nil + expect(service_class.failure_schema).to be_nil + end + + it 'preserves an earlier declaration when a keyword is omitted' do + service_class.schema arguments: { type: 'object' } + service_class.schema result: { type: 'array' } + + expect(service_class.arguments_schema['type']).to eq('object') + expect(service_class.result_schema['type']).to eq('array') + end + + it 'replaces an earlier declaration of the same kind' do + service_class.schema arguments: { type: 'object' } + service_class.schema arguments: { type: 'array' } + + expect(service_class.arguments_schema['type']).to eq('array') + end + + # A nil here is almost always a lookup that failed. Accepting it would + # leave the class validating nothing, with nothing to indicate that. + it 'raises on an explicit nil rather than silently declaring nothing' do + expect { service_class.schema arguments: nil } + .to raise_error(ArgumentError, /declared a nil arguments schema/) + end + + it 'raises on an unknown schema kind' do + expect { service_class.schema argument: { type: 'object' } } + .to raise_error(ArgumentError, /unknown schema kind :argument/) + end + + it 'lists the valid kinds when rejecting an unknown one' do + expect { service_class.schema payload: { type: 'object' } } + .to raise_error(ArgumentError, /Valid: :arguments, :result, :failure/) + end + + it 'reports every unknown kind at once' do + expect { service_class.schema foo: {}, bar: {} } + .to raise_error(ArgumentError, /unknown schema kinds :foo, :bar/) + end + end + + describe 'compiled readers' do + before do + Servus::Schema.register('core', { '$defs' => { 'amount' => { 'type' => 'integer' } } }) + service_class.schema arguments: { + type: 'object', + properties: { fee: { '$ref' => '#/core/$defs/amount' } } + } + end + + it 'resolves refs' do + expect(service_class.arguments_schema.dig('properties', 'fee')).to eq({ 'type' => 'integer' }) + end + + it 'memoizes the compiled result' do + first = service_class.arguments_schema + + expect(service_class.arguments_schema).to equal(first) + end + + it 'recompiles when a referenced fragment changes' do + service_class.arguments_schema + + Servus::Schema.register('core', { '$defs' => { 'amount' => { 'type' => 'number' } } }) + + expect(service_class.arguments_schema.dig('properties', 'fee')).to eq({ 'type' => 'number' }) + end + + it 'recompiles when the schema is redeclared' do + service_class.arguments_schema + + service_class.schema arguments: { type: 'array' } + + expect(service_class.arguments_schema['type']).to eq('array') + end + end + + describe 'inheritance' do + let(:parent) do + stub_const('DeclarationTest::Parent', Class.new(Servus::Base)).tap do |klass| + klass.schema arguments: { type: 'object', required: ['name'] } + end + end + + let(:child) { stub_const('DeclarationTest::Child', Class.new(parent)) } + + # Without this a subclass silently validates nothing, which is the exact + # failure this subsystem exists to prevent. + it 'inherits a parent schema' do + expect(child.arguments_schema['required']).to eq(['name']) + end + + it 'lets a child override without affecting the parent' do + child.schema arguments: { type: 'object', required: ['email'] } + + expect(child.arguments_schema['required']).to eq(['email']) + expect(parent.arguments_schema['required']).to eq(['name']) + end + + it 'returns nil when no ancestor declares one' do + expect(child.result_schema).to be_nil + end + end + + describe 'events' do + let(:event_class) { stub_const('DeclarationTest::Event', Class.new(Servus::Event)) } + + it 'declares only a payload kind' do + expect { event_class.schema arguments: { type: 'object' } } + .to raise_error(ArgumentError, /Valid: :payload/) + end + + it 'compiles the payload schema' do + Servus::Schema.register('core', { '$defs' => { 'id' => { 'type' => 'integer' } } }) + event_class.schema payload: { properties: { id: { '$ref' => '#/core/$defs/id' } } } + + expect(event_class.payload_schema.dig('properties', 'id')).to eq({ 'type' => 'integer' }) + end + + it 'raises on an explicit nil payload' do + expect { event_class.schema payload: nil } + .to raise_error(ArgumentError, /declared a nil payload schema/) + end + end +end diff --git a/gem/spec/servus/schema/path_spec.rb b/gem/spec/servus/schema/path_spec.rb new file mode 100644 index 00000000..dd3c1be3 --- /dev/null +++ b/gem/spec/servus/schema/path_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Schema::Path do + let(:fragment) do + { + '$defs' => { + 'amount' => { 'type' => 'integer' }, + 'timestamp' => { 'type' => 'string' } + } + } + end + + describe '.walk' do + it 'returns the fragment when the path is empty' do + expect(described_class.walk(fragment, 'core', [])).to eq(fragment) + end + + it 'returns the value at the path' do + expect(described_class.walk(fragment, 'core', ['$defs', 'amount'])) + .to eq({ 'type' => 'integer' }) + end + + it 'treats segments as literal keys rather than JSON Pointer tokens' do + escaped = { 'a~1b' => { 'type' => 'string' } } + + expect(described_class.walk(escaped, 'core', ['a~1b'])).to eq({ 'type' => 'string' }) + end + + it 'raises naming the missing segment and the fragment' do + expect { described_class.walk(fragment, 'core', ['$defs', 'nope']) } + .to raise_error(Servus::Schema::RefNotFoundError) { |error| + expect(error.message).to include('"nope"') + expect(error.message).to include('schema fragment "core"') + } + end + + it 'lists the keys available where the walk failed' do + expect { described_class.walk(fragment, 'core', ['$defs', 'nope']) } + .to raise_error(Servus::Schema::RefNotFoundError, /Available keys: "amount", "timestamp"/) + end + + it 'reports the full path that was attempted' do + expect { described_class.walk(fragment, 'core', ['$defs', 'nope']) } + .to raise_error(Servus::Schema::RefNotFoundError, %r{\$defs/nope}) + end + + it 'explains when the walk runs into a value that is not a Hash' do + expect { described_class.walk(fragment, 'core', ['$defs', 'amount', 'type', 'deeper']) } + .to raise_error(Servus::Schema::RefNotFoundError, /not a Hash/) + end + end +end diff --git a/gem/spec/servus/schema/ref_spec.rb b/gem/spec/servus/schema/ref_spec.rb new file mode 100644 index 00000000..44a7b57a --- /dev/null +++ b/gem/spec/servus/schema/ref_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Schema::Ref do + describe '.parse' do + context 'with a whole-fragment ref' do + subject(:ref) { described_class.parse('#/core') } + + it 'extracts the key' do + expect(ref.key).to eq('core') + end + + it 'has no segments' do + expect(ref.segments).to be_empty + end + + it 'keeps the original value' do + expect(ref.value).to eq('#/core') + end + end + + context 'with a path ref' do + subject(:ref) { described_class.parse('#/core/$defs/amount') } + + it 'extracts the key' do + expect(ref.key).to eq('core') + end + + it 'extracts the segments in order' do + expect(ref.segments).to eq(['$defs', 'amount']) + end + end + + context 'with a namespaced key' do + subject(:ref) { described_class.parse('#/models::trade/$defs/id') } + + it 'treats :: as part of the key' do + expect(ref.key).to eq('models::trade') + expect(ref.segments).to eq(['$defs', 'id']) + end + end + + it 'treats segments as literal keys rather than JSON Pointer tokens' do + ref = described_class.parse('#/core/a~1b') + + expect(ref.segments).to eq(['a~1b']) + end + + describe 'rejected forms' do + it 'rejects a non-String value' do + expect { described_class.parse(123) } + .to raise_error(Servus::Schema::InvalidRefError, /must be a String/) + end + + it 'rejects a ref that does not start with #/' do + expect { described_class.parse('core/$defs/amount') } + .to raise_error(Servus::Schema::InvalidRefError, /always take the form/) + end + + it 'rejects a remote ref' do + expect { described_class.parse('https://example.com/s.json') } + .to raise_error(Servus::Schema::InvalidRefError, /Remote and file refs/) + end + + it 'rejects a bare fragment marker' do + expect { described_class.parse('#/') } + .to raise_error(Servus::Schema::InvalidRefError, /names no schema fragment key/) + end + + # Naming this form specifically is the point — parsed positionally it + # would look like a request for a fragment registered under "$defs". + it 'rejects a local ref and says so' do + expect { described_class.parse('#/$defs/amount') } + .to raise_error(Servus::Schema::InvalidRefError, /local ref/) + end + end + end +end diff --git a/gem/spec/servus/schema_spec.rb b/gem/spec/servus/schema_spec.rb new file mode 100644 index 00000000..b3bcd18d --- /dev/null +++ b/gem/spec/servus/schema_spec.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Schema, :schema_registry do + let(:core_fragment) do + { + '$defs' => { + 'amount' => { 'type' => 'integer', 'minimum' => 0 }, + 'timestamp' => { 'type' => 'string', 'format' => 'date-time' } + } + } + end + + describe '.register' do + it 'returns the normalized fragment' do + result = described_class.register('core', core_fragment) + + expect(result).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(result[:$defs][:amount][:type]).to eq('integer') + end + + it 'makes the fragment retrievable by string key' do + described_class.register('core', core_fragment) + + expect(described_class.fetch('core')).to eq(core_fragment) + end + + it 'accepts a symbol key and stores it as a string' do + described_class.register(:core, core_fragment) + + expect(described_class.keys).to eq(['core']) + expect(described_class.fetch('core')).to eq(core_fragment) + end + + it 'accepts a symbol-keyed fragment and exposes it indifferently' do + described_class.register('core', { '$defs': { amount: { type: 'integer' } } }) + + expect(described_class.fetch('core')['$defs']['amount']['type']).to eq('integer') + end + + it 'deep dups so later mutation of the caller hash cannot corrupt the registry' do + mutable = { '$defs' => { 'amount' => { 'type' => 'integer' } } } + described_class.register('core', mutable) + + mutable['$defs']['amount']['type'] = 'string' + + expect(described_class.fetch('core')['$defs']['amount']['type']).to eq('integer') + end + + it 'freezes the stored fragment' do + described_class.register('core', core_fragment) + + expect(described_class.fetch('core')).to be_frozen + end + + # Arrays are everywhere in JSON Schema — `required`, `enum`, `anyOf` — so + # freezing that stops at hashes would leave most fragments mutable. + it 'freezes through arrays as well as hashes' do + described_class.register('core', { + '$defs' => { + 'status' => { 'type' => 'string', 'enum' => %w[open closed] } + }, + 'required' => ['status'] + }) + + fragment = described_class.fetch('core') + + expect(fragment['required']).to be_frozen + expect(fragment.dig('$defs', 'status', 'enum')).to be_frozen + expect { fragment['required'] << 'other' }.to raise_error(FrozenError) + end + + it 'accepts a key containing :: namespace separators' do + described_class.register('models::trade', core_fragment) + + expect(described_class.keys).to include('models::trade') + end + + it 'rejects a key containing a / because it collides with the ref path separator' do + expect { described_class.register('a/b', core_fragment) } + .to raise_error(Servus::Schema::InvalidKeyError, %r{a/b}) + end + + it 'rejects a blank key' do + expect { described_class.register('', core_fragment) } + .to raise_error(Servus::Schema::InvalidKeyError) + end + + it 'rejects a non-Hash fragment' do + expect { described_class.register('core', 'nope') } + .to raise_error(ArgumentError, /Hash/) + end + end + + describe '.register idempotency' do + it 'is a silent no-op when re-registering an equal value' do + described_class.register('core', core_fragment) + generation = described_class.generation + + expect(Servus::Support::Logger).not_to receive(:log_schema_override) + described_class.register('core', core_fragment.dup) + + expect(described_class.generation).to eq(generation) + end + + it 'replaces the value and bumps generation when re-registering a different value' do + described_class.register('core', core_fragment) + generation = described_class.generation + + allow(Servus::Support::Logger).to receive(:log_schema_override) + described_class.register('core', { '$defs' => { 'amount' => { 'type' => 'string' } } }) + + expect(described_class.fetch('core')['$defs']['amount']['type']).to eq('string') + expect(described_class.generation).to be > generation + end + + it 'logs an override when re-registering a different value' do + described_class.register('core', core_fragment) + + expect(Servus::Support::Logger).to receive(:log_schema_override).with('core') + + described_class.register('core', { '$defs' => {} }) + end + end + + describe '.fetch' do + it 'raises UnknownKeyError for an unregistered key' do + described_class.register('core', core_fragment) + + expect { described_class.fetch('nope') } + .to raise_error(Servus::Schema::UnknownKeyError, /nope/) + end + + it 'suggests the nearest registered key' do + described_class.register('core', core_fragment) + + expect { described_class.fetch('cor') } + .to raise_error(Servus::Schema::UnknownKeyError, /Did you mean.*core/) + end + + it 'reports that nothing is registered when the registry is empty' do + expect { described_class.fetch('core') } + .to raise_error(Servus::Schema::UnknownKeyError, /no schema fragments are registered/i) + end + end + + describe '.fetch with a path' do + before { described_class.register('core', core_fragment) } + + it 'returns the definition at the path' do + expect(described_class.fetch('core', '$defs', 'amount')) + .to eq({ 'type' => 'integer', 'minimum' => 0 }) + end + + it 'accepts symbol segments' do + expect(described_class.fetch('core', :$defs, :amount)).to be_a(Hash) + end + + it 'returns the whole fragment when given no path' do + expect(described_class.fetch('core')).to eq(core_fragment) + end + + # The alternative — fetching the fragment and calling dig — returns nil on + # a typo, which is the silent failure this registry exists to avoid. + it 'raises for a missing path rather than returning nil' do + expect { described_class.fetch('core', '$defs', 'amnout') } + .to raise_error(Servus::Schema::RefNotFoundError, /amnout/) + end + + it 'lists the keys available at the failing segment' do + expect { described_class.fetch('core', '$defs', 'amnout') } + .to raise_error(Servus::Schema::RefNotFoundError, /Available keys: "amount", "timestamp"/) + end + + it 'names the fragment in the error' do + expect { described_class.fetch('core', 'nope') } + .to raise_error(Servus::Schema::RefNotFoundError, /"core"/) + end + + it 'raises when the path runs into a non-Hash' do + expect { described_class.fetch('core', '$defs', 'amount', 'type', 'deeper') } + .to raise_error(Servus::Schema::RefNotFoundError) + end + + it 'still raises UnknownKeyError when the fragment itself is absent' do + expect { described_class.fetch('nope', '$defs') } + .to raise_error(Servus::Schema::UnknownKeyError) + end + + it 'returns fragments with refs left unresolved' do + described_class.register('money', { '$defs' => { 'price' => { '$ref' => '#/core/$defs/amount' } } }) + + expect(described_class.fetch('money', '$defs', 'price')) + .to eq({ '$ref' => '#/core/$defs/amount' }) + end + end + + describe '.resolve' do + before do + described_class.register('core', core_fragment) + described_class.register('models::trade', { + '$defs' => { + 'representation' => { + 'type' => 'object', + 'properties' => { 'price' => { '$ref' => '#/core/$defs/amount' } } + } + } + }) + end + + it 'returns the definition with refs resolved' do + expect(described_class.resolve('models::trade', '$defs', 'representation')) + .to eq({ 'type' => 'object', 'properties' => { 'price' => { 'type' => 'integer', 'minimum' => 0 } } }) + end + + it 'resolves a whole fragment when given no path' do + expect(described_class.resolve('models::trade').to_s).not_to include('$ref') + end + + it 'memoizes so repeated lookups are cheap' do + described_class.resolve('models::trade', '$defs', 'representation') + + expect { described_class.resolve('models::trade', '$defs', 'representation') } + .not_to(change { described_class.cache.size }) + end + + it 'raises UnknownKeyError for an unregistered fragment' do + expect { described_class.resolve('nope') } + .to raise_error(Servus::Schema::UnknownKeyError) + end + + it 'raises RefNotFoundError for a missing path' do + expect { described_class.resolve('models::trade', '$defs', 'nope') } + .to raise_error(Servus::Schema::RefNotFoundError, /Available keys: "representation"/) + end + + it 'names the address in the error' do + expect { described_class.resolve('models::trade', '$defs', 'nope') } + .to raise_error(Servus::Schema::RefNotFoundError, %r{#/models::trade/\$defs/nope}) + end + + it 'produces a schema json-schema can validate against' do + schema = described_class.resolve('models::trade', '$defs', 'representation') + + expect(JSON::Validator.fully_validate(schema, { 'price' => 5 })).to be_empty + expect(JSON::Validator.fully_validate(schema, { 'price' => 'no' })).not_to be_empty + end + end + + describe '.compile_all' do + before do + described_class.register('core', core_fragment) + described_class.register('models::trade', { + '$defs' => { + 'representation' => { + 'type' => 'object', + 'properties' => { 'price' => { '$ref' => '#/core/$defs/amount' } } + } + } + }) + end + + it 'returns every registered fragment keyed by name' do + expect(described_class.compile_all.keys).to eq(['core', 'models::trade']) + end + + it 'resolves refs in every fragment' do + compiled = described_class.compile_all + + expect(compiled.dig('models::trade', '$defs', 'representation', 'properties', 'price')) + .to eq({ 'type' => 'integer', 'minimum' => 0 }) + end + + it 'leaves no $ref anywhere in the output' do + expect(described_class.compile_all.to_s).not_to include('$ref') + end + + it 'serializes to JSON as a single asset' do + expect { JSON.generate(described_class.compile_all) }.not_to raise_error + end + + it 'returns an empty hash when nothing is registered' do + described_class.reset! + + expect(described_class.compile_all).to eq({}) + end + + it 'names the fragment being compiled when one cannot be resolved' do + described_class.register('broken', { '$defs' => { 'x' => { '$ref' => '#/nope/thing' } } }) + + expect { described_class.compile_all } + .to raise_error(Servus::Schema::UnknownKeyError, /broken/) + end + end + + describe '.keys' do + it 'returns registered keys sorted' do + described_class.register('zulu', core_fragment) + described_class.register('alpha', core_fragment) + + expect(described_class.keys).to eq(%w[alpha zulu]) + end + end + + describe '.ref' do + it 'builds a whole-fragment ref' do + expect(described_class.ref('core')).to eq({ '$ref' => '#/core' }) + end + + it 'builds a path ref' do + expect(described_class.ref('core', '$defs', 'amount')) + .to eq({ '$ref' => '#/core/$defs/amount' }) + end + + it 'stringifies symbol segments' do + expect(described_class.ref(:core, :$defs, :amount)) + .to eq({ '$ref' => '#/core/$defs/amount' }) + end + end + + describe '.reset!' do + it 'clears the registry and bumps generation' do + described_class.register('core', core_fragment) + generation = described_class.generation + + described_class.reset! + + expect(described_class.keys).to be_empty + expect(described_class.generation).to be > generation + end + end + + describe 'thread safety' do + it 'loses no writes when registering concurrently' do + keys = (1..50).map { |i| "frag_#{i}" } + + writers = keys.map do |key| + Thread.new { described_class.register(key, { '$defs' => { 'x' => { 'type' => 'integer' } } }) } + end + readers = Array.new(10) { Thread.new { 20.times { described_class.keys } } } + + (writers + readers).each(&:join) + + expect(described_class.keys).to match_array(keys) + end + end +end diff --git a/gem/spec/servus/support/validator_spec.rb b/gem/spec/servus/support/validator_spec.rb index 23b48c65..16d451e7 100644 --- a/gem/spec/servus/support/validator_spec.rb +++ b/gem/spec/servus/support/validator_spec.rb @@ -2,985 +2,273 @@ require 'spec_helper' -RSpec.describe Servus::Support::Validator do - # Create a test service class - module SchemaValidationTest - class Service < Servus::Base - def initialize(name:, age:) - @name = name - @age = age - end - - def call - success( - { - id: 123, - name: @name, - age: @age - } - ) - end - end - - class ServiceWithNonPrimitiveArguments < Servus::Base - def initialize(user:) - @user = user - end - - def call - success({ user: @user }) - end - end - end - - context 'with inline schema' do - before { described_class.clear_cache! } - - after do - if defined?(SchemaValidationTest::Service::ARGUMENTS_SCHEMA) - SchemaValidationTest::Service.send(:remove_const, :ARGUMENTS_SCHEMA) - end - - if defined?(SchemaValidationTest::Service::RESULT_SCHEMA) - SchemaValidationTest::Service.send(:remove_const, :RESULT_SCHEMA) - end - - if defined?(SchemaValidationTest::Service::FAILURE_SCHEMA) - SchemaValidationTest::Service.send(:remove_const, :FAILURE_SCHEMA) - end - end - - describe '.load_schema' do - context 'when inline schema exists' do - before do - module SchemaValidationTest - class Service - ARGUMENTS_SCHEMA = { - type: 'object', - required: %w[name age], - properties: { name: { type: 'string' }, age: { type: 'integer' } } - }.freeze - end - end +RSpec.describe Servus::Support::Validator, :schema_registry do + # A fresh class per example. Schemas live on the class, so sharing one across + # examples lets a schema declared in one leak into the next. + let(:service_class) do + stub_const( + 'SchemaValidationTest::Service', + Class.new(Servus::Base) do + def initialize(name:, age:) + @name = name + @age = age end - it 'loads and returns the schema' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema).to be_a(Hash) - expect(schema['type']).to eq('object') - expect(schema['required']).to include('name', 'age') - end - - it 'caches the schema' do - # Load once - described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - # Modify the inline schema - SchemaValidationTest::Service::ARGUMENTS_SCHEMA = { - type: 'object', - required: ['modified'] - }.freeze - # Load again - should return cached version - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema['required']).to include('name', 'age') - expect(schema['required']).not_to include('modified') + def call + success({ id: 123, name: @name, age: @age }) end end + ) + end - context 'when inline schema does not exist' do - it 'returns nil' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'nonexistent') - - expect(schema).to be_nil - end - - it 'caches the nil result' do - expect(File).to receive(:exist?).once.and_call_original + before { described_class.clear_cache! } - # Load twice - described_class.load_schema(SchemaValidationTest::Service, 'nonexistent') - described_class.load_schema(SchemaValidationTest::Service, 'nonexistent') - end - end + describe '.load_schema' do + it 'returns nil when no schema is declared' do + expect(described_class.load_schema(service_class, 'arguments')).to be_nil end - describe '.validate_arguments' do - context 'when no schema exists' do - it 'returns true without validation' do - expect(described_class.validate_arguments!(SchemaValidationTest::Service, { any: 'args' })).to eq(true) - end - end - - context 'when schema exists' do - before do - module SchemaValidationTest - class Service - ARGUMENTS_SCHEMA = { - type: 'object', - required: ['name'], - properties: { - name: { type: 'string' }, - age: { type: 'integer', minimum: 18 } - } - }.freeze - end - end - end - - it 'returns true for valid arguments' do - expect(described_class.validate_arguments!(SchemaValidationTest::Service, - { name: 'John', age: 25 })).to eq(true) - end - - it 'raises ValidationError for missing required field' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { age: 25 }) - end.to raise_error(Servus::Base::ValidationError, /required property of 'name'/) - end + it 'returns the declared schema' do + service_class.schema arguments: { type: 'object', required: ['name'] } - it 'raises ValidationError for invalid field type' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John', age: 'twenty' }) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) - end + schema = described_class.load_schema(service_class, 'arguments') - it 'raises ValidationError for out of range value' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John', age: 17 }) - end.to raise_error(Servus::Base::ValidationError, /did not have a minimum value of 18/) - end - end + expect(schema['type']).to eq('object') + expect(schema['required']).to include('name') end - describe '.validate_result' do - let(:success_result) { Servus::Support::Response.new(true, { id: 123 }, nil) } - let(:error_result) { Servus::Support::Response.new(false, nil, 'Error') } - - context 'when no schema exists' do - it 'returns the result unchanged' do - expect(described_class.validate_result!(SchemaValidationTest::Service, success_result)).to eq(success_result) - end - end - - context 'when schema exists' do - before do - module SchemaValidationTest - class Service - RESULT_SCHEMA = { - type: 'object', - required: %w[id status], - properties: { - id: { type: 'integer' }, - status: { type: 'string' } - } - }.freeze - end - end - end - - it 'returns error results unchanged without validation' do - expect(described_class.validate_result!(SchemaValidationTest::Service, error_result)).to eq(error_result) - end - - it 'returns the success result unchanged if valid' do - valid_result = Servus::Support::Response.new(true, { id: 123, status: 'complete' }, nil) - expect(described_class.validate_result!(SchemaValidationTest::Service, valid_result)).to eq(valid_result) - end - - it 'raises ValidationError if success result has invalid structure' do - expect do - described_class.validate_result!(SchemaValidationTest::Service, success_result) - end.to raise_error(Servus::Base::ValidationError, /did not contain a required property of 'status'/) - end - - it 'raises ValidationError if success result has invalid types' do - invalid_result = Servus::Support::Response.new(true, { id: '123', status: 'complete' }, nil) - expect do - described_class.validate_result!(SchemaValidationTest::Service, invalid_result) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) - end - end - - context 'when non-primitive values are passed' do - # Defines a test user object class - class TestUserObject - attr_reader :id, :name, :age - - def initialize(id:, name:, age:) - @id = id - @age = age - @name = name - end - end - - before do - module SchemaValidationTest - class ServiceWithNonPrimitiveArguments - RESULT_SCHEMA = { - type: 'object', - required: ['user'], - properties: { - user: { - type: 'object', - properties: { - id: { type: 'string' }, - age: { type: 'integer' }, - name: { type: 'string' } - } - } - } - }.freeze - end - end - end - - it 'returns the success result unchanged if valid' do - user = TestUserObject.new(id: '123e4567-e89b-12d3-a456-426614174000', name: 'John Doe', age: 30) - - valid_result = Servus::Support::Response.new(true, { user: user }, nil) + it 'resolves $refs against the registry' do + Servus::Schema.register('core', { '$defs' => { 'name' => { 'type' => 'string' } } }) + service_class.schema arguments: { + type: 'object', + properties: { name: { '$ref' => '#/core/$defs/name' } } + } - expect(described_class.validate_result!(SchemaValidationTest::ServiceWithNonPrimitiveArguments, - valid_result)).to eq(valid_result) - end + schema = described_class.load_schema(service_class, 'arguments') - it 'raises ValidationError if success result has invalid types' do - user = TestUserObject.new(id: 1, name: 'John Doe', age: 30) # Invalid UUID (string) - invalid_result = Servus::Support::Response.new(true, { user: user }, nil) - expect do - described_class.validate_result!(SchemaValidationTest::ServiceWithNonPrimitiveArguments, invalid_result) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: string/) - end - end + expect(schema.dig('properties', 'name')).to eq({ 'type' => 'string' }) end - describe '.validate_result! with failure schema' do - let(:error) { Servus::Support::Errors::ServiceError.new('something failed') } - let(:failure_with_data) { Servus::Support::Response.new(false, { reason: 'invalid', code: 42 }, error) } - let(:failure_without_data) { Servus::Support::Response.new(false, nil, error) } - let(:success_result) { Servus::Support::Response.new(true, { id: 123 }, nil) } - - context 'when no failure schema exists' do - it 'returns the failure result unchanged' do - expect(described_class.validate_result!(SchemaValidationTest::Service, - failure_with_data)).to eq(failure_with_data) - end - end - - context 'when failure schema exists via DSL' do - before do - SchemaValidationTest::Service.schema( - failure: { - type: 'object', - required: %w[reason], - properties: { - reason: { type: 'string' }, - code: { type: 'integer' } - } - } - ) - end + it 'raises rather than skipping validation when a $ref cannot be resolved' do + service_class.schema arguments: { '$ref' => '#/nope/$defs/thing' } - it 'returns failure result unchanged if data matches schema' do - expect(described_class.validate_result!(SchemaValidationTest::Service, - failure_with_data)).to eq(failure_with_data) - end + expect { described_class.load_schema(service_class, 'arguments') } + .to raise_error(Servus::Schema::UnknownKeyError) + end - it 'raises ValidationError if failure data does not match schema' do - bad_failure = Servus::Support::Response.new(false, { reason: 123 }, error) - expect do - described_class.validate_result!(SchemaValidationTest::Service, bad_failure) - end.to raise_error(Servus::Base::ValidationError, /Invalid failure structure/) - end + it 'raises for an unknown schema type' do + expect { described_class.load_schema(service_class, 'nonexistent') } + .to raise_error(ArgumentError, /unknown schema type/) + end - it 'skips validation when failure has nil data' do - expect(described_class.validate_result!(SchemaValidationTest::Service, - failure_without_data)).to eq(failure_without_data) - end + it 'accepts a symbol type' do + service_class.schema arguments: { type: 'object' } - it 'does not apply failure schema to success results' do - expect(described_class.validate_result!(SchemaValidationTest::Service, success_result)).to eq(success_result) - end - end + expect(described_class.load_schema(service_class, :arguments)).to be_a(Hash) + end - context 'when failure schema exists via inline constant' do - before do - module SchemaValidationTest - class Service - FAILURE_SCHEMA = { - type: 'object', - required: %w[reason], - properties: { - reason: { type: 'string' } - } - }.freeze - end - end - end + describe 'caching' do + before { service_class.schema arguments: { type: 'object', required: ['name'] } } - after do - SchemaValidationTest::Service.send(:remove_const, :FAILURE_SCHEMA) - end + it 'serves later reads from the cache' do + described_class.load_schema(service_class, 'arguments') + service_class.schema arguments: { type: 'object', required: ['changed'] } - it 'validates failure data against the inline constant schema' do - bad_failure = Servus::Support::Response.new(false, { reason: 123 }, error) - expect do - described_class.validate_result!(SchemaValidationTest::Service, bad_failure) - end.to raise_error(Servus::Base::ValidationError, /Invalid failure structure/) - end + expect(described_class.load_schema(service_class, 'arguments')['required']).to include('name') end - end - describe '.clear_cache!' do - module SchemaValidationTest - class Service - RESULT_SCHEMA = { type: 'object' }.freeze - end - end + it 'picks up changes after the cache is cleared' do + described_class.load_schema(service_class, 'arguments') + service_class.schema arguments: { type: 'object', required: ['changed'] } + described_class.clear_cache! - before do - described_class.load_schema(SchemaValidationTest::Service, 'arguments') + expect(described_class.load_schema(service_class, 'arguments')['required']).to include('changed') end - it 'clears the schema cache' do - # Load once (should use cache) - described_class.load_schema(SchemaValidationTest::Service, 'arguments') + # The cache used to be keyed by a file path derived from the class's + # namespace, which dropped the final segment — so two services in the + # same namespace silently shared a schema. + it 'does not confuse two classes in the same namespace' do + other = stub_const('SchemaValidationTest::Other', Class.new(Servus::Base)) + other.schema arguments: { type: 'object', required: ['other_field'] } - # Check cache - expect(described_class.cache).not_to be_empty - - # Clear cache - described_class.clear_cache! - - # Check cache is cleared - expect(described_class.cache).to be_empty + expect(described_class.load_schema(service_class, 'arguments')['required']).to include('name') + expect(described_class.load_schema(other, 'arguments')['required']).to include('other_field') end end end - context 'with file schema' do - # Set up temp directory for test schemas - let(:schema_dir) { Servus.config.schema_dir_for('schema_validation_test') } - + describe '.validate_arguments!' do before do - # Create schema directory if it doesn't exist - FileUtils.mkdir_p(schema_dir) - described_class.clear_cache! + service_class.schema arguments: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + age: { type: 'integer', minimum: 18 } + } + } end - after do - # Clean up test schemas - FileUtils.rm_rf(schema_dir) + it 'returns true for valid arguments' do + expect(described_class.validate_arguments!(service_class, { name: 'John', age: 25 })).to be(true) end - describe '.load_schema' do - context 'when schema file exists' do - before do - File.write( - "#{schema_dir}/arguments.json", - { - type: 'object', - required: %w[name age], - properties: { name: { type: 'string' }, age: { type: 'integer' } } - }.to_json - ) - end - - it 'loads and returns the schema' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema).to be_a(Hash) - expect(schema['type']).to eq('object') - expect(schema['required']).to include('name', 'age') - end - - it 'caches the schema' do - # Load once - described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - # Modify the file - File.write( - "#{schema_dir}/arguments.json", - { - type: 'object', - required: ['modified'] - }.to_json - ) - - # Load again - should return cached version - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema['required']).to include('name', 'age') - expect(schema['required']).not_to include('modified') - end - end - - context 'when schema file does not exist' do - it 'returns nil' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'nonexistent') - - expect(schema).to be_nil - end - - it 'caches the nil result' do - expect(File).to receive(:exist?).once.and_call_original - - # Load twice - described_class.load_schema(SchemaValidationTest::Service, 'nonexistent') - described_class.load_schema(SchemaValidationTest::Service, 'nonexistent') - end - end + it 'returns true when no schema is declared' do + expect(described_class.validate_arguments!(stub_const('Bare::Service', Class.new(Servus::Base)), {})) + .to be(true) end - describe '.validate_arguments' do - context 'when no schema exists' do - it 'returns true without validation' do - expect(described_class.validate_arguments!(SchemaValidationTest::Service, { any: 'args' })).to eq(true) - end - end - - context 'when schema exists' do - before do - File.write( - "#{schema_dir}/arguments.json", - { - type: 'object', - required: ['name'], - properties: { - name: { type: 'string' }, - age: { type: 'integer', minimum: 18 } - } - }.to_json - ) - end - - it 'returns true for valid arguments' do - expect(described_class.validate_arguments!(SchemaValidationTest::Service, - { name: 'John', age: 25 })).to eq(true) - end - - it 'raises ValidationError for missing required field' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { age: 25 }) - end.to raise_error(Servus::Base::ValidationError, /required property of 'name'/) - end - - it 'raises ValidationError for invalid field type' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John', age: 'twenty' }) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) - end - - it 'raises ValidationError for out of range value' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John', age: 17 }) - end.to raise_error(Servus::Base::ValidationError, /did not have a minimum value of 18/) - end - end + it 'raises for a missing required field' do + expect { described_class.validate_arguments!(service_class, { age: 25 }) } + .to raise_error(Servus::Base::ValidationError, /required property of 'name'/) end - describe '.validate_result' do - let(:success_result) { Servus::Support::Response.new(true, { id: 123 }, nil) } - let(:error_result) { Servus::Support::Response.new(false, nil, 'Error') } - - context 'when no schema exists' do - it 'returns the result unchanged' do - expect(described_class.validate_result!(SchemaValidationTest::Service, success_result)).to eq(success_result) - end - end - - context 'when schema exists' do - before do - File.write( - "#{schema_dir}/result.json", { - type: 'object', - required: %w[id status], - properties: { - id: { type: 'integer' }, - status: { type: 'string' } - } - }.to_json - ) - end - - it 'returns error results unchanged without validation' do - expect(described_class.validate_result!(SchemaValidationTest::Service, error_result)).to eq(error_result) - end - - it 'returns the success result unchanged if valid' do - valid_result = Servus::Support::Response.new(true, { id: 123, status: 'complete' }, nil) - expect(described_class.validate_result!(SchemaValidationTest::Service, valid_result)).to eq(valid_result) - end - - it 'raises ValidationError if success result has invalid structure' do - expect do - described_class.validate_result!(SchemaValidationTest::Service, success_result) - end.to raise_error(Servus::Base::ValidationError, /did not contain a required property of 'status'/) - end - - it 'raises ValidationError if success result has invalid types' do - invalid_result = Servus::Support::Response.new(true, { id: '123', status: 'complete' }, nil) - expect do - described_class.validate_result!(SchemaValidationTest::Service, invalid_result) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) - end - end - - context 'when non-primitive values are passed' do - class TestUserObject - attr_reader :id, :name, :age # leftovers:keep - - def initialize(id:, name:, age:) - @id = id - @age = age - @name = name - end - end - - before do - File.write( - "#{schema_dir}/result.json", - { - type: 'object', - required: ['user'], - properties: { - user: { - type: 'object', - properties: { - id: { type: 'string' }, - age: { type: 'integer' }, - name: { type: 'string' } - } - } - } - }.to_json - ) - end - - it 'returns the success result unchanged if valid' do - user = TestUserObject.new(id: '123e4567-e89b-12d3-a456-426614174000', name: 'John Doe', age: 30) - - valid_result = Servus::Support::Response.new(true, { user: user }, nil) - - expect(described_class.validate_result!( - SchemaValidationTest::ServiceWithNonPrimitiveArguments, - valid_result - )).to eq(valid_result) - end - - it 'raises ValidationError if success result has invalid types' do - user = TestUserObject.new(id: 1, name: 'John Doe', age: 30) # Invalid UUID (string) - invalid_result = Servus::Support::Response.new(true, { user: user }, nil) - expect do - described_class.validate_result!(SchemaValidationTest::ServiceWithNonPrimitiveArguments, invalid_result) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: string/) - end - end + it 'raises for an invalid field type' do + expect { described_class.validate_arguments!(service_class, { name: 'John', age: 'twenty' }) } + .to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) end - describe '.clear_cache!' do - before do - File.write("#{schema_dir}/arguments.json", { type: 'object' }.to_json) - described_class.load_schema(SchemaValidationTest::Service, 'arguments') - end - - it 'clears the schema cache' do - # Load once (should use cache) - described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - # Check cache - expect(described_class.cache).not_to be_empty - - # Clear cache - described_class.clear_cache! + it 'raises for an out of range value' do + expect { described_class.validate_arguments!(service_class, { name: 'John', age: 17 }) } + .to raise_error(Servus::Base::ValidationError, /did not have a minimum value of 18/) + end - # Check cache is cleared - expect(described_class.cache).to be_empty - end + it 'names the service in the error' do + expect { described_class.validate_arguments!(service_class, {}) } + .to raise_error(Servus::Base::ValidationError, /Invalid arguments for SchemaValidationTest::Service/) end end - context 'with schema DSL method' do - before { described_class.clear_cache! } - - after do - # Clean up class instance variables - if SchemaValidationTest::Service.instance_variable_defined?(:@arguments_schema) - SchemaValidationTest::Service.remove_instance_variable(:@arguments_schema) - end - - if SchemaValidationTest::Service.instance_variable_defined?(:@result_schema) - SchemaValidationTest::Service.remove_instance_variable(:@result_schema) - end - - if SchemaValidationTest::Service.instance_variable_defined?(:@failure_schema) - SchemaValidationTest::Service.remove_instance_variable(:@failure_schema) - end - - # Clean up constants if they exist - if defined?(SchemaValidationTest::Service::ARGUMENTS_SCHEMA) - SchemaValidationTest::Service.send(:remove_const, :ARGUMENTS_SCHEMA) - end - - if defined?(SchemaValidationTest::Service::RESULT_SCHEMA) - SchemaValidationTest::Service.send(:remove_const, :RESULT_SCHEMA) - end + describe '.validate_result!' do + let(:success_result) { Servus::Support::Response.new(true, { id: 123 }, nil) } + let(:error_result) { Servus::Support::Response.new(false, nil, 'Error') } - if defined?(SchemaValidationTest::Service::FAILURE_SCHEMA) - SchemaValidationTest::Service.send(:remove_const, :FAILURE_SCHEMA) - end + before do + service_class.schema result: { + type: 'object', + required: %w[id status], + properties: { id: { type: 'integer' }, status: { type: 'string' } } + } end - describe 'schema class method' do - context 'when defining both arguments and result schemas' do - before do - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: ['name'], - properties: { name: { type: 'string' } } - }, - result: { - type: 'object', - required: ['id'], - properties: { id: { type: 'integer' } } - } - ) - end - - it 'stores the arguments schema' do - expect(SchemaValidationTest::Service.arguments_schema).to be_a(Hash) - expect(SchemaValidationTest::Service.arguments_schema['type']).to eq('object') - end - - it 'stores the result schema' do - expect(SchemaValidationTest::Service.result_schema).to be_a(Hash) - expect(SchemaValidationTest::Service.result_schema['type']).to eq('object') - end - end - - context 'when defining only arguments schema' do - before do - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: ['name'], - properties: { name: { type: 'string' } } - } - ) - end - - it 'stores the arguments schema' do - expect(SchemaValidationTest::Service.arguments_schema).to be_a(Hash) - end - - it 'does not set result schema' do - expect(SchemaValidationTest::Service.result_schema).to be_nil - end - end - - context 'when defining only result schema' do - before do - SchemaValidationTest::Service.schema( - result: { - type: 'object', - required: ['id'], - properties: { id: { type: 'integer' } } - } - ) - end - - it 'stores the result schema' do - expect(SchemaValidationTest::Service.result_schema).to be_a(Hash) - end + it 'returns a valid success result unchanged' do + valid = Servus::Support::Response.new(true, { id: 123, status: 'complete' }, nil) - it 'does not set arguments schema' do - expect(SchemaValidationTest::Service.arguments_schema).to be_nil - end - end + expect(described_class.validate_result!(service_class, valid)).to eq(valid) end - describe '.load_schema with DSL method' do - context 'when schema is defined via DSL' do - before do - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: %w[name age], - properties: { name: { type: 'string' }, age: { type: 'integer' } } - } - ) - end - - it 'loads and returns the schema from DSL' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema).to be_a(Hash) - expect(schema['type']).to eq('object') - expect(schema['required']).to include('name', 'age') - end - - it 'caches the schema' do - # Load once - described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - # Change the DSL schema - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: ['modified'] - } - ) - - # Load again - should return cached version - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema['required']).to include('name', 'age') - expect(schema['required']).not_to include('modified') - end - end - - context 'when both DSL and constant exist' do - before do - # Define via DSL first - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: ['dsl_field'], - properties: { dsl_field: { type: 'string' } } - } - ) - - # Define constant - module SchemaValidationTest - class Service - ARGUMENTS_SCHEMA = { - type: 'object', - required: ['constant_field'], - properties: { constant_field: { type: 'string' } } - }.freeze - end - end - end - - it 'uses DSL schema and ignores constant' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') - - expect(schema['required']).to include('dsl_field') - expect(schema['required']).not_to include('constant_field') - end - end - - context 'when DSL schema is nil but constant exists' do - before do - # Define constant - module SchemaValidationTest - class Service - ARGUMENTS_SCHEMA = { - type: 'object', - required: ['constant_field'], - properties: { constant_field: { type: 'string' } } - }.freeze - end - end - - # Set DSL schema to nil explicitly - SchemaValidationTest::Service.instance_variable_set(:@arguments_schema, nil) - end - - it 'falls back to constant' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') + it 'returns failure results without validating them' do + expect(described_class.validate_result!(service_class, error_result)).to eq(error_result) + end - expect(schema['required']).to include('constant_field') - end - end + it 'raises when a success result is missing a required property' do + expect { described_class.validate_result!(service_class, success_result) } + .to raise_error(Servus::Base::ValidationError, /did not contain a required property of 'status'/) + end - context 'when no DSL, no constant, but file exists' do - let(:schema_dir) { Servus.config.schema_dir_for('schema_validation_test') } - - before do - FileUtils.mkdir_p(schema_dir) - File.write( - "#{schema_dir}/arguments.json", - { - type: 'object', - required: ['file_field'], - properties: { file_field: { type: 'string' } } - }.to_json - ) - end + it 'raises when a success result has the wrong type' do + invalid = Servus::Support::Response.new(true, { id: '123', status: 'complete' }, nil) - after do - FileUtils.rm_rf(schema_dir) - end + expect { described_class.validate_result!(service_class, invalid) } + .to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) + end + end - it 'falls back to file-based schema' do - schema = described_class.load_schema(SchemaValidationTest::Service, 'arguments') + describe '.validate_result! with a failure schema' do + let(:error) { Servus::Support::Errors::ServiceError.new('failed') } - expect(schema['required']).to include('file_field') - end - end + before do + service_class.schema failure: { + type: 'object', + required: %w[reason], + properties: { reason: { type: 'string' }, code: { type: 'integer' } } + } end - describe '.validate_arguments with DSL schema' do - context 'when schema defined via DSL' do - before do - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: ['name'], - properties: { - name: { type: 'string' }, - age: { type: 'integer', minimum: 18 } - } - } - ) - end - - it 'returns true for valid arguments' do - expect(described_class.validate_arguments!(SchemaValidationTest::Service, - { name: 'John', age: 25 })).to eq(true) - end + it 'validates failure data against the failure schema' do + valid = Servus::Support::Response.new(false, { reason: 'declined' }, error) - it 'raises ValidationError for missing required field' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { age: 25 }) - end.to raise_error(Servus::Base::ValidationError, /required property of 'name'/) - end + expect(described_class.validate_result!(service_class, valid)).to eq(valid) + end - it 'raises ValidationError for invalid field type' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John', age: 'twenty' }) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) - end + it 'raises when failure data does not match' do + invalid = Servus::Support::Response.new(false, { reason: 123 }, error) - it 'raises ValidationError for out of range value' do - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John', age: 17 }) - end.to raise_error(Servus::Base::ValidationError, /did not have a minimum value of 18/) - end - end + expect { described_class.validate_result!(service_class, invalid) } + .to raise_error(Servus::Base::ValidationError, /Invalid failure structure/) end - describe '.validate_result with DSL schema' do - let(:success_result) { Servus::Support::Response.new(true, { id: 123 }, nil) } - let(:error_result) { Servus::Support::Response.new(false, nil, 'Error') } - - context 'when schema defined via DSL' do - before do - SchemaValidationTest::Service.schema( - result: { - type: 'object', - required: %w[id status], - properties: { - id: { type: 'integer' }, - status: { type: 'string' } - } - } - ) - end + it 'skips failures that carry no data' do + no_data = Servus::Support::Response.new(false, nil, error) - it 'returns error results unchanged without validation' do - expect(described_class.validate_result!(SchemaValidationTest::Service, error_result)).to eq(error_result) - end + expect(described_class.validate_result!(service_class, no_data)).to eq(no_data) + end + end - it 'returns the success result unchanged if valid' do - valid_result = Servus::Support::Response.new(true, { id: 123, status: 'complete' }, nil) - expect(described_class.validate_result!(SchemaValidationTest::Service, valid_result)).to eq(valid_result) - end + describe 'integration with .call' do + before do + service_class.schema( + arguments: { + type: 'object', + required: %w[name age], + properties: { name: { type: 'string' }, age: { type: 'integer', minimum: 18 } } + }, + result: { + type: 'object', + required: %w[id name age], + properties: { id: { type: 'integer' }, name: { type: 'string' }, age: { type: 'integer' } } + } + ) + end - it 'raises ValidationError if success result has invalid structure' do - expect do - described_class.validate_result!(SchemaValidationTest::Service, success_result) - end.to raise_error(Servus::Base::ValidationError, /did not contain a required property of 'status'/) - end + it 'validates arguments before the call and the result after it' do + result = service_class.call(name: 'John', age: 25) - it 'raises ValidationError if success result has invalid types' do - invalid_result = Servus::Support::Response.new(true, { id: '123', status: 'complete' }, nil) - expect do - described_class.validate_result!(SchemaValidationTest::Service, invalid_result) - end.to raise_error(Servus::Base::ValidationError, /did not match the following type: integer/) - end - end + expect(result).to be_success + expect(result.data[:id]).to eq(123) end - describe 'integration with service call' do - context 'when using DSL schema' do - before do - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - required: %w[name age], - properties: { - name: { type: 'string' }, - age: { type: 'integer', minimum: 18 } - } - }, - result: { - type: 'object', - required: %w[id name age], - properties: { - id: { type: 'integer' }, - name: { type: 'string' }, - age: { type: 'integer' } - } - } - ) - end - - it 'validates arguments before call and result after call' do - result = SchemaValidationTest::Service.call(name: 'John', age: 25) + it 'raises before the call for invalid arguments' do + expect { service_class.call(name: 'John', age: 17) } + .to raise_error(Servus::Base::ValidationError, /did not have a minimum value of 18/) + end + end - expect(result).to be_success - expect(result.data[:id]).to eq(123) - expect(result.data[:name]).to eq('John') - expect(result.data[:age]).to eq(25) - end + describe '.clear_cache!' do + it 'empties the cache' do + service_class.schema arguments: { type: 'object' } + described_class.load_schema(service_class, 'arguments') - it 'raises ValidationError for invalid arguments' do - expect do - SchemaValidationTest::Service.call(name: 'John', age: 17) - end.to raise_error(Servus::Base::ValidationError, /did not have a minimum value of 18/) - end - end + expect { described_class.clear_cache! }.to change { described_class.cache.size }.to(0) end end - context 'with schema enforcement' do - before { described_class.clear_cache! } - + describe 'schema enforcement' do after do Servus.config.require_service_arguments_schema = false Servus.config.require_service_result_schema = false end describe 'require_service_arguments_schema' do - it 'raises SchemaRequiredError when enabled and no arguments schema exists' do + it 'raises when enabled and no arguments schema is declared' do Servus.config.require_service_arguments_schema = true - expect do - described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John' }) - end.to raise_error(Servus::Support::Errors::SchemaRequiredError, /require_service_arguments_schema/) + expect { described_class.validate_arguments!(service_class, { name: 'John' }) } + .to raise_error(Servus::Support::Errors::SchemaRequiredError, /require_service_arguments_schema/) end - it 'does not raise when disabled and no arguments schema exists' do + it 'does not raise when disabled' do Servus.config.require_service_arguments_schema = false - expect(described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John' })).to eq(true) + expect(described_class.validate_arguments!(service_class, { name: 'John' })).to be(true) end - it 'does not raise when enabled and arguments schema exists' do + it 'does not raise when enabled and a schema is declared' do Servus.config.require_service_arguments_schema = true + service_class.schema arguments: { type: 'object', properties: { name: { type: 'string' } } } - SchemaValidationTest::Service.schema( - arguments: { - type: 'object', - properties: { name: { type: 'string' } } - } - ) - - expect(described_class.validate_arguments!(SchemaValidationTest::Service, { name: 'John' })).to eq(true) + expect(described_class.validate_arguments!(service_class, { name: 'John' })).to be(true) end end @@ -988,37 +276,30 @@ class Service let(:success_result) { Servus::Support::Response.new(true, { id: 123 }, nil) } let(:failure_result) { Servus::Support::Response.new(false, nil, Servus::Support::Errors::ServiceError.new) } - it 'raises SchemaRequiredError when enabled and success has no result schema' do + it 'raises when enabled and a success result has no schema' do Servus.config.require_service_result_schema = true - expect do - described_class.validate_result!(SchemaValidationTest::Service, success_result) - end.to raise_error(Servus::Support::Errors::SchemaRequiredError, /require_service_result_schema/) + expect { described_class.validate_result!(service_class, success_result) } + .to raise_error(Servus::Support::Errors::SchemaRequiredError, /require_service_result_schema/) end it 'does not raise for failure responses even when enabled' do Servus.config.require_service_result_schema = true - expect(described_class.validate_result!(SchemaValidationTest::Service, failure_result)).to eq(failure_result) + expect(described_class.validate_result!(service_class, failure_result)).to eq(failure_result) end - it 'does not raise when disabled and no result schema exists' do + it 'does not raise when disabled' do Servus.config.require_service_result_schema = false - expect(described_class.validate_result!(SchemaValidationTest::Service, success_result)).to eq(success_result) + expect(described_class.validate_result!(service_class, success_result)).to eq(success_result) end - it 'does not raise when enabled and result schema exists' do + it 'does not raise when enabled and a result schema is declared' do Servus.config.require_service_result_schema = true + service_class.schema result: { type: 'object', properties: { id: { type: 'integer' } } } - SchemaValidationTest::Service.schema( - result: { - type: 'object', - properties: { id: { type: 'integer' } } - } - ) - - expect(described_class.validate_result!(SchemaValidationTest::Service, success_result)).to eq(success_result) + expect(described_class.validate_result!(service_class, success_result)).to eq(success_result) end end end diff --git a/gem/spec/servus/testing/example_extractor_spec.rb b/gem/spec/servus/testing/example_extractor_spec.rb index ea717489..c47720f5 100644 --- a/gem/spec/servus/testing/example_extractor_spec.rb +++ b/gem/spec/servus/testing/example_extractor_spec.rb @@ -289,6 +289,44 @@ def call end end + context 'with an array of scalars whose items carry an example' do + before do + ExampleExtractionTest::ArrayService.schema( + arguments: { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string', example: 'urgent' } } + } + } + ) + end + + it 'wraps the item example in an array' do + result = described_class.extract(ExampleExtractionTest::ArrayService, :arguments) + + expect(result[:tags]).to eq(['urgent']) + end + end + + context 'with an array whose items carry no example' do + before do + ExampleExtractionTest::ArrayService.schema( + arguments: { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string' } } + } + } + ) + end + + it 'omits the property rather than inventing a value' do + result = described_class.extract(ExampleExtractionTest::ArrayService, :arguments) + + expect(result).not_to have_key(:tags) + end + end + context 'with result schema' do before do ExampleExtractionTest::SimpleService.schema( @@ -466,6 +504,63 @@ def call expect(result).to eq({ optional_text: '' }) end end + + # Extraction reads the compiled schema, so a shared fragment can carry its + # own examples and every service referencing it inherits them. If schemas + # were compiled at validation time instead of on read, these would be + # invisible here. + context 'with schemas that reference shared fragments', :schema_registry do + before do + Servus::Schema.register('core', { + '$defs' => { + 'amount' => { 'type' => 'integer', 'example' => 500 }, + 'user' => { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'integer', 'example' => 7 }, + 'email' => { 'type' => 'string', 'example' => 'a@b.com' } + } + } + } + }) + end + + it 'extracts an example from a referenced fragment' do + ExampleExtractionTest::SimpleService.schema( + arguments: { + type: 'object', + properties: { fee: { '$ref' => '#/core/$defs/amount' } } + } + ) + + expect(described_class.extract(ExampleExtractionTest::SimpleService, :arguments)) + .to eq({ fee: 500 }) + end + + it 'extracts nested examples from a referenced object fragment' do + ExampleExtractionTest::SimpleService.schema( + arguments: { + type: 'object', + properties: { user: { '$ref' => '#/core/$defs/user' } } + } + ) + + expect(described_class.extract(ExampleExtractionTest::SimpleService, :arguments)) + .to eq({ user: { id: 7, email: 'a@b.com' } }) + end + + it 'prefers an example declared alongside the ref' do + ExampleExtractionTest::SimpleService.schema( + arguments: { + type: 'object', + properties: { fee: { '$ref' => '#/core/$defs/amount', 'example' => 99 } } + } + ) + + expect(described_class.extract(ExampleExtractionTest::SimpleService, :arguments)) + .to eq({ fee: 99 }) + end + end end describe '#initialize' do diff --git a/gem/spec/servus/testing/matchers_spec.rb b/gem/spec/servus/testing/matchers_spec.rb index d087ab68..c6d6b2cf 100644 --- a/gem/spec/servus/testing/matchers_spec.rb +++ b/gem/spec/servus/testing/matchers_spec.rb @@ -124,6 +124,27 @@ def call = success({}) expect(handler_class).not_to have_schema(:payload) end + + # The matcher used to clear the global schema cache on every invocation, + # which discarded cache state belonging to every other example. + it 'leaves the schema cache alone' do + service_class = stub_const('CachePreservedService', Class.new(Servus::Base) do + schema arguments: { type: 'object' } + end) + Servus::Support::Validator.load_schema(service_class, 'arguments') + + expect { expect(service_class).to have_schema(:arguments) } + .not_to(change { Servus::Support::Validator.cache.size }) + end + + it 'fails loudly when a schema references an unregistered fragment' do + service_class = stub_const('BrokenRefService', Class.new(Servus::Base) do + schema arguments: { '$ref' => '#/nope/$defs/thing' } + end) + + expect { expect(service_class).to have_schema(:arguments) } + .to raise_error(Servus::Schema::UnknownKeyError) + end end describe 'be_service_success matcher' do diff --git a/gem/spec/spec_helper.rb b/gem/spec/spec_helper.rb index 119e132b..c0b6a711 100644 --- a/gem/spec/spec_helper.rb +++ b/gem/spec/spec_helper.rb @@ -1,8 +1,18 @@ # frozen_string_literal: true +if ENV['COVERAGE'] + require 'simplecov' + SimpleCov.start do + enable_coverage :branch + add_filter %r{^/spec/} + track_files 'lib/**/*.rb' + end +end + require 'servus' require 'servus/testing' require 'spec_support/active_job_loader' +require 'spec_support/schema_registry' require 'spec_support/test_services' # Internal tests sometimes instantiate anonymous Servus::Base subclasses to @@ -23,7 +33,10 @@ c.syntax = :expect end - config.before(:each) do - ActiveJob::Base.queue_adapter = :test + # Event invocation always enqueues, so a spec asserting that a service + # actually ran needs the job to execute. Tag it `:inline_jobs` to swap the + # adapter; everything else keeps `:test` and asserts on enqueued jobs. + config.before(:each) do |example| + ActiveJob::Base.queue_adapter = example.metadata[:inline_jobs] ? :inline : :test end end diff --git a/gem/spec/spec_support/active_job_loader.rb b/gem/spec/spec_support/active_job_loader.rb index a7bc93fa..49f9780b 100644 --- a/gem/spec/spec_support/active_job_loader.rb +++ b/gem/spec/spec_support/active_job_loader.rb @@ -2,6 +2,10 @@ require 'active_job' require 'active_job/base' -# Trigger ActiveJob load hook manually — Rails normally does this. -ActiveSupport.run_load_hooks(:active_job, ActiveJob::Base) -require 'servus/railtie' + +# Rails wires this up through the railtie's `on_load(:active_job)` hook, which +# only fires during a Rails::Application boot. The suite never boots one, so +# without this the whole suite runs with `call_async` undefined — which since +# 1.0.0 means no event invocation works at all. +require 'servus/extensions/async/ext' +Servus::Base.extend(Servus::Extensions::Async::Call) diff --git a/gem/spec/spec_support/schema_registry.rb b/gem/spec/spec_support/schema_registry.rb new file mode 100644 index 00000000..94710f7b --- /dev/null +++ b/gem/spec/spec_support/schema_registry.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Shared context for specs that register schema fragments. +# +# The registry is process-global, so any example that registers a fragment must +# restore the previous state or it leaks into later examples. Tag an example or +# group with `:schema_registry` to get a snapshot/restore around it. +# +# @example +# RSpec.describe MyThing, :schema_registry do +# before { Servus::Schema.register('core', { '$defs' => { 'id' => { 'type' => 'integer' } } }) } +# end +RSpec.shared_context 'with a clean schema registry' do + around do |example| + snapshot = Servus::Schema.snapshot + Servus::Schema.reset! + example.run + ensure + Servus::Schema.restore(snapshot) + end +end + +RSpec.configure do |config| + config.include_context 'with a clean schema registry', :schema_registry +end diff --git a/gem/spec/spec_support/test_services.rb b/gem/spec/spec_support/test_services.rb index 7f541449..556be5c3 100644 --- a/gem/spec/spec_support/test_services.rb +++ b/gem/spec/spec_support/test_services.rb @@ -31,7 +31,7 @@ class ServiceB < TrackingService; end class UserCreatedEvent < Servus::Event event_name :user_created - invoke ServiceA + enqueue ServiceA end # --- Async extension fixtures ------------------------------------------------ diff --git a/site/.vitepress/config.ts b/site/.vitepress/config.ts index df464af3..be9332d3 100644 --- a/site/.vitepress/config.ts +++ b/site/.vitepress/config.ts @@ -49,6 +49,7 @@ export default defineConfig({ text: 'Features', items: [ { text: 'Schema Validation', link: '/features/schema-validation' }, + { text: 'Shared Schemas', link: '/features/shared-schemas' }, { text: 'Error Handling', link: '/features/error-handling' }, { text: 'Async Execution', link: '/features/async-execution' }, { text: 'Logging', link: '/features/logging' }, diff --git a/site/core/composition.md b/site/core/composition.md index bf1da954..bf1d3864 100644 --- a/site/core/composition.md +++ b/site/core/composition.md @@ -1,10 +1,8 @@ # Composition -Most non-trivial actions need to invoke other actions. Servus gives you one helper for composing services — `call!` — and one for driving a service from outside the service layer — `run_service!`. Both eliminate the same boilerplate: checking `success?`, extracting `data`, and early-returning on failure. - -## `call!` — inside services - -`call!` is the primary composition helper. It is an instance method on `Servus::Base`, so it's available anywhere your `#call` runs. +Most non-trivial actions need to invoke other actions. In Servus a service +invokes another service exactly the way anything else does — `.call`, check the +result, decide what happens next: ```ruby module Treasury @@ -17,57 +15,42 @@ module Treasury end def call - transfer = call!( - Treasury::TransferGold::Service, + transfer = Treasury::TransferGold::Service.call( from_account: @from_account, to_account: @to_account, gold_dragons: @gold_dragons ) + return transfer unless transfer.success? - call!(Ravens::DispatchReceipt::Service, transfer_id: transfer.id) + receipt = Ravens::DispatchReceipt::Service.call(transfer_id: transfer.data.id) + return receipt unless receipt.success? - success(transfer_id: transfer.id) + success(transfer_id: transfer.data.id) end end end end ``` -On success, `call!` returns the sub-service's data — the same `DataObject` the caller would get from `SubService.call(...).data`. On failure, it halts the outer service and passes the sub-service's failure `Response` through unchanged: same error object, same message, same `code`, same `http_status`. The outer service's caller receives the sub-service's failure as if they had invoked it directly. - -### Why `call!` exists - -The same code without `call!`: +There is one way to invoke a service and one way to handle what it returns. The +whole control flow is on the page: which calls happen, in what order, and what a +failure does to the rest of the method. -```ruby -def call - transfer_result = Treasury::TransferGold::Service.call( - from_account: @from_account, - to_account: @to_account, - gold_dragons: @gold_dragons - ) - return transfer_result unless transfer_result.success? - - dispatch_result = Ravens::DispatchReceipt::Service.call( - transfer_id: transfer_result.data.id - ) - return dispatch_result unless dispatch_result.success? - - success(transfer_id: transfer_result.data.id) -end -``` +## Passing a failure through -Every sub-service call grows three lines of plumbing: one to invoke, one to branch, one to early-return. Pull that plumbing out and the business logic is what's left. +`return result unless result.success?` returns the sub-service's failure +`Response` unchanged — same error object, message, `code`, and `http_status`. +The outer service's caller receives it as though they had invoked the +sub-service directly, so a `NotFoundError` raised three services deep still +reaches the controller as a 404. -### Failure semantics +That's usually what you want. It's worth writing out, because the alternative is +a reader having to know that some other construct decided it for them. -`call!` uses the same `throw/catch` mechanism as guards. When the sub-service fails, `call!` throws `:guard_failure` with the sub-service's failure `Response`. The `catch` block inside `Servus::Base.call` unwraps it and returns that Response as the outer service's result — see [Call Chain](/core/call-chain#_4-run-your-call-method). +## Handling a failure instead -Because the *original* failure flows through, callers don't need to care that the failure came from a sub-service. A `NotFoundError` from `Accounts::Lookup::Service` arrives at the controller as a 404 even when it was raised three services deep. - -### When not to use `call!` - -Use `call!` when the outer service has no better context to add and any sub-service failure should halt composition. Don't use it when you want to inspect the failure, try a fallback, or translate the error into something more specific to the outer service's domain. In those cases, call the sub-service directly and branch on `result.success?`. +When the outer service has something to add — a fallback, a retry, an error +specific to its own domain — branch on the result: ```ruby def call @@ -75,43 +58,62 @@ def call return success(charge_id: result.data.id) if result.success? return failure('Card declined', type: PaymentDeclinedError) if card_declined?(result.error) - # Let other failures pass through + # Let other failures pass through untouched result end ``` -## `run_service!` — outside services +Pass-through and handling share a shape, so moving between them is a one-line +change rather than a switch between two different calling conventions. -`run_service!` is the bang counterpart to `run_service` on `Servus::Helpers::ControllerHelpers`. Like `run_service`, it stores the full `Response` in `@result` so the rest of the action (views, callbacks, after-hooks) can read it the same way. It then returns the service's data on success and raises the failure's error otherwise. Use it wherever raising is preferable to rendering — background callbacks, rake tasks reachable through a controller context, or any path where a failure is a bug, not a render opportunity. +## Preconditions belong in guards -```ruby -class WebhooksController < ApplicationController - def stripe - event = Stripe::Webhook.construct_event(request.body.read, signature, secret) +Composition is for invoking other services. When you're enforcing a +precondition rather than calling something, reach for a +[guard](/features/guards) instead — guards halt the service without the caller +writing any branching at all. + +## Driving a service from outside - # Raises on failure — bubbles to the default exception middleware - run_service!(Payments::RecordWebhook::Service, event: event) +Controllers, jobs, rake tasks, and consoles aren't services, so they have no +`#call` to return from. `Servus::Helpers::ControllerHelpers` covers that +boundary: - head :ok +```ruby +class UsersController < ApplicationController + def create + run_service Services::CreateUser::Service, user_params end end ``` -### `run_service!` vs `run_service` +`run_service` stores the full `Response` in `@result` so views and downstream +helpers can read it, and renders a JSON error on failure using the error's +`http_status` and `api_error`. Override +[`render_service_error`](/rails/controllers) to change that format. -| Helper | Lives on | On success | On failure | -| --- | --- | --- | --- | -| `run_service` | `ControllerHelpers` | Sets `@result`, returns `Response` | Renders JSON error, returns `Response` | -| `run_service!` | `ControllerHelpers` | Sets `@result`, returns the result's `data` | Raises the failure's `ServiceError` | -| `call!` | `Servus::Base` | Returns the result's `data` | Halts outer service with failure `Response` | +Anywhere raising suits better than rendering — a webhook handler, a rake task — +call the service directly and raise: -`run_service` is the default for controller actions — it handles the JSON response for you. Reach for `run_service!` only when raising is what you actually want. +```ruby +result = Payments::RecordWebhook::Service.call(event: event) +raise result.error unless result.success? +``` -## The two public methods +## One way in -Servus exposes a small surface on purpose. For invocation, there are only two public methods anyone writing a service will ever need: +A service has a single public entry point: `.call(**args)`. A controller, a job, +an event router, another service — all invoke it identically and all get back +the same `Response`. -1. **`.call(**args)`** — the class-level entry point. Every service is invoked through this. -2. **`call!(SubService, **args)`** — the instance-level composition helper. Every sub-service invocation inside `#call` goes through this. +Servus previously shipped two helpers that wrapped that call: `call!` for +composing services and `run_service!` for driving one from a controller +context. Both returned `data` on success and diverted on failure — `call!` by +throwing to halt the outer service, `run_service!` by raising. Both are +**removed in 1.0.0**. -`run_service` / `run_service!` are integration helpers on the controller side, not part of the service's public interface. Keep the service surface to these two and compositions stay uniform across the codebase. +They read like ordinary method calls while hiding a non-local jump, and they +meant the same operation had two calling conventions depending on where you +stood. Writing `.call` and an explicit `return` or `raise` costs a line and +makes the control flow something you can see rather than something you have to +know. diff --git a/site/features/event-bus.md b/site/features/event-bus.md index bfefafe6..a49f21b7 100644 --- a/site/features/event-bus.md +++ b/site/features/event-bus.md @@ -63,6 +63,43 @@ def transfer_payload(result) end ``` +### Multiple events per trigger + +A trigger holds a list, not a single event. Declare `emits` as many times as you +need on the same trigger — each one fires in declaration order, and each gets +its own payload: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + emits :gold_transferred_event, on: :success + + emits :ledger_entry_recorded_event, on: :success do |result| + { amount: result.data.transferred, balance: result.data.from_balance } + end + + emits :vault_audited_event, on: :success, with: :audit_payload + + private + + def audit_payload(result) + { vault: @from_account.vault_id, moved: result.data.transferred } + end +end +``` + +The payloads are independent — the default (`result.data`), a block, and a +method reference can all appear on the same trigger. One failing schema stops +the whole emission sequence, since validation happens per event as it fires. + +Reach for this when a single outcome genuinely concerns several unrelated +domains and you want each to receive a payload shaped for it. When several +reactions want the *same* payload, prefer one event with multiple `enqueue` +declarations on its Event class — that keeps the fan-out in the event layer +where subscribers can be added without touching the service. + +To make same-trigger events mutually exclusive rather than sequential, put a +condition on each — see below. + ### Conditional emission Use `if:` or `unless:` to gate whether an event fires at runtime. When the condition is not met, the event is completely skipped — no payload is built, no validation runs, and nothing reaches the bus. @@ -170,14 +207,14 @@ end ::: tip Emission vs invocation conditions `if:`/`unless:` on `emits` gate the **event itself** — when the condition fails, the event never enters the bus and no handlers run. -The `if:`/`unless:` on `invoke` (inside an Event class) gate a **specific handler** — the event fires and reaches the bus, but only matching handlers are invoked. Use emission conditions when the entire event is irrelevant; use invocation conditions when only some handlers should react. +The `if:`/`unless:` on `enqueue` (inside an Event class) gate a **specific handler** — the event fires and reaches the bus, but only matching handlers are invoked. Use emission conditions when the entire event is irrelevant; use invocation conditions when only some handlers should react. ::: ## Handling events A service can emit events without knowing or caring whether anything is listening. The service's job ends when the event fires — it has no dependency on what happens next. -When you want to react to an event, you create an Event class. An Event class subscribes to a single event name and declares which services to invoke when that event fires. It inherits from `Servus::Event`, uses `event_name` to set (or override) the name, and uses `invoke` to wire up each response. The Event class's job is purely coordination — it maps the event payload to service arguments and decides whether to run sync or async. No business logic belongs here. +When you want to react to an event, you create an Event class. An Event class subscribes to a single event name and declares which services to invoke when that event fires. It inherits from `Servus::Event`, uses `event_name` to set (or override) the name, and uses `enqueue` to wire up each response. The Event class's job is purely coordination — it maps the event payload to service arguments and routes the resulting jobs. No business logic belongs here. Generate one with the Rails generator: @@ -195,41 +232,44 @@ Then declare what services should react to the event: class GoldTransferredEvent < Servus::Event # event name inferred as :gold_transferred_event from class name - invoke Ledger::RecordEntry::Service, async: true do |payload| + enqueue Ledger::RecordEntry::Service do |payload| { transfer: payload[:transfer] } end - invoke Ravens::SendReceipt::Service, async: true do |payload| + enqueue Ravens::SendReceipt::Service do |payload| { amount: payload[:transferred], from: payload[:from_balance] } end end ``` -Each `invoke` block maps the event payload to the service's keyword arguments. A single Event class can invoke multiple services — they all react to the same event. If no block is given, the full payload is passed through as params. +Each `enqueue` block maps the event payload to the service's keyword arguments. A single Event class can enqueue multiple services — they all react to the same event. If no block is given, the full payload is passed through as params. -### Sync vs async invocation +### Everything is enqueued -Event classes can invoke services synchronously (inline) or asynchronously (enqueued via ActiveJob): +Services declared with `enqueue` are always enqueued through ActiveJob. There is no inline option. ```ruby -# Synchronous (default) — runs inline -invoke IronBank::NotifyMasterOfCoin::Service do |payload| - { message: "Transfer of #{payload[:transferred]} gold dragons completed" } +enqueue Ravens::SendReceipt::Service do |payload| + { amount: payload[:transferred] } end -# Asynchronous — enqueued via ActiveJob -invoke Ravens::SendReceipt::Service, async: true do |payload| +# Route to a queue +enqueue Ravens::SendReceipt::Service, queue: :mailers do |payload| { amount: payload[:transferred] } end -# Async with a specific queue -invoke Ravens::SendReceipt::Service, async: true, queue: :mailers do |payload| +# Delay it +enqueue Ravens::SendReceipt::Service, wait: 5.minutes do |payload| { amount: payload[:transferred] } end ``` -::: warning Prefer async invocation -Synchronous invocations run inline during the emitting service's `after_call` phase — before the result is returned to the caller. If a sync invocation raises an exception, it propagates through the emitting service and the caller never receives the result. Async invocation avoids this entirely — the work is enqueued and runs independently. Use sync only when the follow-up must complete before the caller gets a response. +`queue:`, `wait:`, `wait_until:`, `priority:`, and `job_options:` are passed through to ActiveJob. + +Running a reaction inline would put its latency and its failures back into the emitting service — an exception in a follow-up would propagate through a service that already succeeded, and its caller would never receive the result. That is the coupling events exist to remove, which is why the choice is gone rather than merely discouraged. + +::: warning Events require ActiveJob +Because invocation always enqueues, an Event class that declares `enqueue` needs ActiveJob loaded. In Rails that is automatic. Elsewhere, emitting an event with a declaration raises `Servus::Events::Errors::AsyncBackendMissingError`. Servus's core — services, schemas, guards, and the bus itself — works without it. A job adapter for non-Rails hosts is planned. ::: ### Conditional invocation @@ -238,7 +278,7 @@ Invocations can be gated with `if:` or `unless:` lambdas that receive the event ```ruby # Only when the transfer exceeds 100 gold dragons -invoke Ravens::DispatchMessage::Service, async: true, if: ->(p) { p[:transferred] > 100 } do |payload| +enqueue Ravens::DispatchMessage::Service, if: ->(p) { p[:transferred] > 100 } do |payload| { message: "Large transfer of #{payload[:transferred]} gold dragons completed", destination: :iron_bank @@ -246,15 +286,15 @@ invoke Ravens::DispatchMessage::Service, async: true, if: ->(p) { p[:transferred end # Only when the transfer does NOT exceed 100 gold dragons -invoke Ravens::DispatchMessage::Service, async: true, unless: ->(p) { p[:transferred] > 100 } do |payload| +enqueue Ravens::DispatchMessage::Service, unless: ->(p) { p[:transferred] > 100 } do |payload| { message: "Transfer of #{payload[:transferred]} gold dragons completed", destination: :iron_bank } end -# Both conditions can be combined with sync or async -invoke Ravens::DispatchMessage::Service, if: ->(p) { p[:transferred] > 100 } do |payload| +# Conditions work the same on every declaration +enqueue Ravens::DispatchMessage::Service, if: ->(p) { p[:transferred] > 100 } do |payload| { message: "Large transfer of #{payload[:transferred]} gold dragons completed", destination: :iron_bank @@ -278,12 +318,37 @@ class GoldTransferredEvent < Servus::Event } } - invoke Ledger::RecordEntry::Service, async: true do |payload| + enqueue Ledger::RecordEntry::Service do |payload| { amount: payload[:transferred] } end end ``` +### Requiring a schema on every event + +Schemas are optional by default — an event with no schema emits unvalidated. To +make that impossible, turn on enforcement: + +```ruby +# config/initializers/servus.rb +Servus.configure do |config| + config.require_event_payload_schema = true +end +``` + +With the flag on, emitting an event whose Event class declares no `schema +payload:` raises `SchemaRequiredError`. So does emitting a name with **no Event +class registered at all** — that's the case where a payload cannot be validated +by anything, so it's the one the flag most needs to catch. + +::: warning The Event class must be loaded +Enforcement resolves the event name through the registry, and an Event class +registers itself when it loads. Rails' railtie loads `app/events/**/*_event.rb` +at boot, so following that naming convention is enough. An Event class in a +file that doesn't match — or a non-Rails host that never requires it — will look +unregistered and trip the raise even though it has a perfectly good schema. +::: + ## Emitting events without a service Event classes provide an `emit` class method for triggering events from controllers, jobs, or other code that isn't a Servus service: @@ -327,7 +392,7 @@ Each event name maps to exactly one Event class. Attempting to register a second When `Bus.emit` fires, it delegates to configured routers to resolve which services to invoke. Each router returns a list of `Invocation` objects; the Bus deduplicates by key (first wins) and executes. -Servus ships with `ClassRouter` as the default — it reads `invoke` declarations from Event classes. Applications can add additional routers (e.g. a data-driven router backed by a database) via configuration: +Servus ships with `ClassRouter` as the default — it reads `enqueue` declarations from Event classes. Applications can add additional routers (e.g. a data-driven router backed by a database) via configuration: ```ruby Servus.configure do |config| diff --git a/site/features/schema-validation.md b/site/features/schema-validation.md index e443f2ac..a18e7e84 100644 --- a/site/features/schema-validation.md +++ b/site/features/schema-validation.md @@ -1,12 +1,10 @@ # Schema Validation -Servus can validate a service's arguments before execution and its result after execution using [JSON Schema](https://json-schema.org/understanding-json-schema). Validation is opt-in — services work without schemas. Servus uses the [`json-schema`](https://github.com/voxpupuli/json-schema) gem (draft-04 by default). +Servus can validate a service's arguments before execution and its result after execution using [JSON Schema](https://json-schema.org/understanding-json-schema). Validation is opt-in — services work without schemas. Servus uses the [`json-schema`](https://github.com/voxpupuli/json-schema) gem, which supports up to draft-06 and defaults to it. ## Defining schemas -There are three ways to define schemas for a service. Servus checks them in this order — the first one found wins: - -### 1. The `schema` DSL (recommended) +Schemas are declared inline with the `schema` DSL, in the service class itself: ```ruby class Treasury::TransferGold::Service < Servus::Base @@ -45,37 +43,38 @@ schema arguments: { } ``` -### 2. Inline constants - -::: warning Deprecated — will be removed in v1.0.0 -Servus also checks for `ARGUMENTS_SCHEMA`, `RESULT_SCHEMA`, and `FAILURE_SCHEMA` constants on the service class. Migrate to the `schema` DSL before upgrading to v1.0.0. -::: - -### 3. JSON files +Calling `schema` again only touches the keys you pass, so you can declare each +one separately. Passing a key explicitly as `nil` raises — an explicit `nil` is +almost always a lookup that failed, and accepting it would leave the service +silently unvalidated. -For complex schemas, use JSON files. The framework looks for them at: - -``` -app/schemas/treasury/transfer_gold/arguments.json -app/schemas/treasury/transfer_gold/result.json -app/schemas/treasury/transfer_gold/failure.json -``` - -The path is derived from the service's class name — `Treasury::TransferGold::Service` becomes `treasury/transfer_gold`. The base directory defaults to `app/schemas` and can be configured: +Events declare a payload schema the same way: ```ruby -# config/initializers/servus.rb -Servus.configure do |config| - config.schemas_dir = "app/services" # colocates schemas next to service files - # or - config.schemas_dir = "config/schemas" # keeps schemas outside of app/ +class GoldTransferred < Servus::Event + schema payload: { + type: "object", + required: ["from_account", "gold_dragons"] + } end ``` -Schemas are cached after first load. In development, clear the cache when you change a file-based schema: +Subclasses inherit their parent's schemas, and can override any of them +without affecting the parent. + +## Sharing schemas between services + +Declaring schemas inline keeps a service's contract in the file that implements +it. To avoid re-typing the same shapes across services, register the shared +parts once and reference them with `$ref` — see [Shared Schemas](/features/shared-schemas). ```ruby -Servus::Support::Validator.clear_cache! +schema arguments: { + type: "object", + properties: { + gold_dragons: { "$ref" => "#/core/$defs/amount" } + } +} ``` ## What schemas buy you diff --git a/site/features/shared-schemas.md b/site/features/shared-schemas.md new file mode 100644 index 00000000..0e2bc013 --- /dev/null +++ b/site/features/shared-schemas.md @@ -0,0 +1,304 @@ +# Shared Schemas + +Servus schemas are declared inline, in the service that uses them. That keeps a +service's contract where you can see it. The cost is duplication: once a few +dozen services all take an amount, or return a timestamp, the same fragment of +JSON Schema gets copied everywhere — and drifts. + +Shared schemas fix that without moving contracts out of the service. You +register a reusable fragment under a key, and services reference into it with a +standard JSON Schema `$ref`. A service that references a shared type is still +declaring that type explicitly; it just names it once instead of restating it. + +## Registering a fragment + +A fragment is a plain Ruby hash. Register it from an initializer: + +```ruby +# config/initializers/servus_schemas.rb +Servus::Schema.register("core", { + "$defs" => { + "id" => { "type" => "integer", "minimum" => 1 }, + "amount" => { + "type" => "integer", + "minimum" => 0, + "description" => "An amount in minor units", + "example" => 1000 + }, + "timestamp" => { "type" => "string", "format" => "date-time" } + } +}) +``` + +That is the whole setup. There is no constant to name and no file to load, +because nothing ever references the fragment by constant — refs are strings, +resolved through the registry. + +### As fragments grow + +When one initializer stops being comfortable, split the fragments into files +under `config/schemas/` and require them. `config/` is not an autoload path, so +an explicit `require` is correct there: + +```ruby +# config/schemas/core.rb +CoreSchema = { "$defs" => { ... } }.freeze +``` + +```ruby +# config/initializers/servus_schemas.rb +require Rails.root.join("config/schemas/core") + +Servus::Schema.register("core", CoreSchema) +``` + +::: warning Don't put fragments in an autoloaded path +Avoid defining fragments in `app/`, or in `lib/` if you have +`config.autoload_lib` enabled. Zeitwerk only loads a constant when something +references it, and nothing ever references a fragment by name — so it would +never load, and never register. Explicitly `require`-ing an autoloaded file is +its own error. Keep fragments outside the autoload paths entirely. +::: + +If a fragment genuinely has to live somewhere reloadable, register it from +`to_prepare`. Re-registering an identical value is a no-op, so this is safe to +run on every reload: + +```ruby +Rails.application.config.to_prepare do + Servus::Schema.register("core", CoreSchema::DEFS) +end +``` + +Registering a *different* value for an existing key replaces it and logs a +warning. During development that's a reload; anywhere else it usually means two +libraries are claiming the same key. + +## Referencing a fragment + +Two forms are supported: + +```ruby +{ "$ref" => "#/core" } # the whole fragment +{ "$ref" => "#/core/$defs/amount" } # a path within it +``` + +Path segments are literal hash keys. There is no JSON Pointer escaping and no +array indexing. `$defs` has no special meaning to Servus — it's a conventional +place to keep definitions, and any key would work. + +`Servus::Schema.ref` builds these for you, which avoids typos in the prefix and +separator: + +```ruby +Servus::Schema.ref("core", "$defs", "amount") +# => { "$ref" => "#/core/$defs/amount" } +``` + +In a service: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + schema arguments: { + type: "object", + required: ["from_account", "gold_dragons"], + properties: { + from_account: { "$ref" => "#/core/$defs/id" }, + gold_dragons: { "$ref" => "#/core/$defs/amount" }, + requested_at: { "$ref" => "#/core/$defs/timestamp" } + } + } +end +``` + +## Reading the registry directly + +Nothing about the registry is tied to services or events — it is a standalone +store that those two happen to consume. Anything in your app can register +fragments and read them back, which is what makes it usable as a single source +for contracts that have no service behind them, such as controller request and +response shapes. + +`fetch` reads a fragment, or a definition within one, using the same addressing +a `$ref` uses: + +```ruby +Servus::Schema.fetch("models::trade") +# => the whole fragment + +Servus::Schema.fetch("models::trade", "$defs", "representation") +# => just that definition +``` + +A missing path raises `RefNotFoundError` listing what was available, rather than +returning nil the way `dig` would: + +```ruby +Servus::Schema.fetch("models::trade", "$defs", "reprsentation") +# => RefNotFoundError: "$defs/reprsentation" could not be resolved in schema +# fragment "models::trade": "reprsentation" is not present. +# Available keys: "representation". +``` + +`fetch` returns fragments as authored, with refs intact. `resolve` is the +compiled counterpart — same addressing, but the result is self-contained and +ready to validate against. This is usually what you want outside a service: + +```ruby +Servus::Schema.resolve("endpoints::trades::create", "$defs", "request") +# => { "type" => "object", "properties" => { "price" => { "type" => "integer" } } } +``` + +```ruby +# in a controller concern +def validate_request! + schema = Servus::Schema.resolve("endpoints::trades::create", "$defs", "request") + errors = JSON::Validator.fully_validate(schema, params.to_unsafe_h) + render_unprocessable(errors) if errors.any? +end +``` + +Results are memoized, so asking repeatedly for the same address is cheap. +`Servus::Schema.compile` is also public if you need to compile a schema you +built yourself rather than one from the registry. + +## Compiling everything as one asset + +`compile_all` returns every registered fragment with all refs resolved, keyed by +name. Fragment keys stay addressable and the result serializes straight to JSON, +so it works as a build input for an API description, a docs site, client +codegen, or a CI freshness check: + +```ruby +Servus::Schema.compile_all +# => { +# "core" => { "$defs" => { "amount" => { "type" => "integer" } } }, +# "models::trade" => { "$defs" => { "representation" => { ... } } }, +# "endpoints::trades::create" => { ... } +# } + +File.write("schema.json", JSON.pretty_generate(Servus::Schema.compile_all)) +``` + +Because it compiles everything, it also fails on any broken ref anywhere in the +registry — which makes it a useful thing to call in CI even if you throw the +result away. + +## Overriding with sibling keys + +Keys alongside a `$ref` override the fragment they resolve to. This is what +makes a shared fragment usable at a specific call site — you take the shape and +re-describe it: + +```ruby +gold_dragons: { + "$ref" => "#/core/$defs/amount", + "description" => "Dragons to move from one vault to the other", + "example" => 50 +} +``` + +::: warning This differs from modern JSON Schema +In JSON Schema 2019-09 and later, keys beside a `$ref` are an *additional* +subschema applied as an intersection — both must hold. Servus treats them as an +override, because that is what shared fragments are actually used for. Under +draft-06, which `json-schema` implements, siblings to `$ref` are ignored +entirely, so there is no established behaviour being contradicted here. +::: + +## What is not supported + +| Form | Why | +| --- | --- | +| `#/$defs/thing` | Local refs resolve against the enclosing document. Servus resolves against registered fragments, so there is no document to resolve against. Register the definition as a fragment instead. | +| `https://example.com/s.json` | Remote refs would mean network access during validation. | +| `./other.json#/thing` | File refs were removed in 1.0 along with the file-based schema tier. | +| `#/core/items/0` | Segments are literal hash keys, not JSON Pointer tokens — no array indexing. | + +Each raises `Servus::Schema::InvalidRefError` naming the specific form, rather +than failing later as a confusing lookup miss. + +## Errors you will see + +Every error names the ref, the schema being compiled, and the chain that led +there. + +**A key that is not registered** — the important one. A lookup that returned +`nil` here would leave the service running with no validation at all, so this +raises instead: + +``` +Servus::Schema::UnknownKeyError: + unknown schema key "cor". Did you mean: "core"? + while compiling Treasury::TransferGold::Service arguments schema +``` + +**A path that does not exist**, listing what was there: + +``` +Servus::Schema::RefNotFoundError: + "#/core/$defs/amonut" could not be resolved: "amonut" is not present in "core". + Available keys: "amount", "id", "timestamp". + while compiling Treasury::TransferGold::Service arguments schema +``` + +**A cycle**, naming every hop: + +``` +Servus::Schema::CircularReferenceError: + circular $ref detected: #/a/node -> #/b/node -> #/a/node +``` + +## How it works + +Compilation is lazy and memoized. A schema is compiled the first time it is +*read* — whether that read comes from validating a call, from the test example +builders, or from your own code asking a service for its contract. The result +is cached on the class. + +Resolved fragments are memoized globally, so a fragment referenced by two +hundred services is expanded once, not two hundred times. + +Registering a changed fragment invalidates every compiled schema that depends +on it, so you never have to track which services referenced what. In tests, +`Servus::Support::Validator.clear_cache!` clears the per-class cache. + +Fragments may carry `$schema` and `$id` at their root — those are stripped when +the fragment is spliced into another schema, since `json-schema` raises on a +`$schema` URI it does not recognise. + +## Testing + +Fragments are registered process-wide, so a spec that registers one should +restore the registry afterwards: + +```ruby +around do |example| + snapshot = Servus::Schema.snapshot + Servus::Schema.reset! + example.run +ensure + Servus::Schema.restore(snapshot) +end +``` + +Because `have_schema` now compiles, it also catches broken refs — a service +whose schema references an unregistered fragment fails the matcher rather than +passing and failing later in production: + +```ruby +it { expect(described_class).to have_schema(:arguments) } +``` + +Examples declared inside a fragment flow through to the example builders, so a +shared fragment can carry its own examples and every service referencing it +inherits them: + +```ruby +Servus::Schema.register("core", { + "$defs" => { "amount" => { "type" => "integer", "example" => 1000 } } +}) + +servus_arguments_example(Treasury::TransferGold::Service) +# => { gold_dragons: 1000, ... } +``` diff --git a/site/rails/autoloading.md b/site/rails/autoloading.md index e8fdff97..eb692df9 100644 --- a/site/rails/autoloading.md +++ b/site/rails/autoloading.md @@ -44,7 +44,11 @@ The Railtie also wires up additional features when their dependencies are availa | Extension | Loads when | What it adds | | --- | --- | --- | | Controller helpers | `ActionController` loads | `run_service` and `render_service_error` on all controllers | -| Async execution | `ActiveJob` loads | `.call_async` on all services | +| Async execution | `ActiveJob` loads | `.call_async` on all services, and event `enqueue` declarations | | Lazy resolvers | `ActiveRecord` loads | `lazily` DSL on all services | These are loaded via `ActiveSupport.on_load`, so they only activate when the corresponding Rails component is present. + +::: warning Events depend on ActiveJob +Since 1.0.0, event invocation always enqueues, so an Event class that declares `enqueue` needs ActiveJob. Without it, emitting the event raises `Servus::Events::Errors::AsyncBackendMissingError`. Servus's core — services, schemas, guards, and the bus itself — works without ActiveJob; only `enqueue` declarations require it. +::: diff --git a/site/rails/configuration.md b/site/rails/configuration.md index f42dcfde..0dd76207 100644 --- a/site/rails/configuration.md +++ b/site/rails/configuration.md @@ -7,12 +7,11 @@ Servus works without any configuration. All settings have sensible defaults. Whe Servus.configure do |config| # ── Directory Settings ────────────────────────────────────────────── - # Controls where Servus looks for file-based schemas, Event classes, - # guards, and services. These paths are relative to Rails.root. - # Generators also use these paths when creating new files. + # Controls where Servus looks for Event classes, guards, and services. + # These paths are relative to Rails.root. Generators also use these + # paths when creating new files. config.services_dir = "app/services" # default: "app/services" - config.schemas_dir = "app/schemas" # default: "app/schemas" config.events_dir = "app/events" # default: "app/events" config.guards_dir = "app/guards" # default: "app/guards" config.tests_dir = "spec" # default: "spec" @@ -20,7 +19,7 @@ Servus.configure do |config| # ── Routers ──────────────────────────────────────────────────────── # Ordered list of routers that resolve service invocations for events. # The Bus iterates in order, deduplicates by key, and executes. - # Defaults to [Servus::Events::ClassRouter.new] which reads invoke + # Defaults to [Servus::Events::ClassRouter.new] which reads enqueue # declarations from Event classes. config.routers = [ diff --git a/site/rails/generators.md b/site/rails/generators.md index a795bc5a..f773ee10 100644 --- a/site/rails/generators.md +++ b/site/rails/generators.md @@ -11,8 +11,6 @@ rails g servus:service treasury/transfer_gold from_account to_account gold_drago => create app/services/treasury/transfer_gold/service.rb => create spec/services/treasury/transfer_gold/service_spec.rb -=> create app/schemas/services/treasury/transfer_gold/result.json -=> create app/schemas/services/treasury/transfer_gold/arguments.json ``` | Argument | Description | @@ -70,12 +68,11 @@ The generated guard includes `http_status`, `error_code`, `message`, and a place ## Configuration -All generators respect the directory settings in `Servus.configure`. If you've changed `schemas_dir`, `events_dir`, `guards_dir`, or `tests_dir`, the generated file paths follow those settings: +All generators respect the directory settings in `Servus.configure`. If you've changed `events_dir`, `guards_dir`, or `tests_dir`, the generated file paths follow those settings: ```ruby # config/initializers/servus.rb Servus.configure do |config| - config.schemas_dir = "config/schemas" # default: "app/schemas" config.events_dir = "app/domain_events" # default: "app/events" config.guards_dir = "lib/guards" # default: "app/guards" config.services_dir = "app/services" # default: "app/services" diff --git a/site/reference/generators.md b/site/reference/generators.md index e9853516..680e5f14 100644 --- a/site/reference/generators.md +++ b/site/reference/generators.md @@ -11,8 +11,6 @@ rails g servus:service treasury/transfer_gold from_account to_account gold_drago => create app/services/treasury/transfer_gold/service.rb => create spec/services/treasury/transfer_gold/service_spec.rb -=> create app/schemas/services/treasury/transfer_gold/result.json -=> create app/schemas/services/treasury/transfer_gold/arguments.json ``` | Argument | Required | Description | @@ -22,7 +20,7 @@ rails g servus:service treasury/transfer_gold from_account to_account gold_drago | Option | Description | | --- | --- | -| `--no-docs` | Skip YARD documentation comments and TODO scaffolding | +| `--no-docs` | Skip YARD documentation comments and TODO scaffolding. The `schema` declaration is still generated — it is code, not documentation. | ### Generated service @@ -32,6 +30,23 @@ With parameters: # app/services/treasury/transfer_gold/service.rb module Treasury::TransferGold class Service < Servus::Base + schema( + arguments: { + type: 'object', + required: %w[from_account to_account gold_dragons], + properties: { + from_account: {}, + to_account: {}, + gold_dragons: {} + } + }, + result: { + type: 'object', + required: [], + properties: {} + } + ) + def initialize(from_account:, to_account:, gold_dragons:) @from_account = from_account @to_account = to_account @@ -87,44 +102,6 @@ RSpec.describe Treasury::TransferGold::Service do end ``` -### Generated schemas - -Arguments schema with the parameters declared as required: - -```json -// app/schemas/services/treasury/transfer_gold/arguments.json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Treasury::TransferGold Arguments", - "type": "object", - "properties": { - "from_account": { "type": "string", "description": "TODO" }, - "to_account": { "type": "string", "description": "TODO" }, - "gold_dragons": { "type": "string", "description": "TODO" } - }, - "required": ["from_account", "to_account", "gold_dragons"], - "additionalProperties": false -} -``` - -Result schema (empty, ready to fill in): - -```json -// app/schemas/services/treasury/transfer_gold/result.json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Treasury::TransferGold Result", - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": true -} -``` - -::: tip Inline schemas preferred -The generator creates JSON schema files for convenience, but the `schema` DSL is the recommended approach. You can delete the JSON files and define schemas inline — see [Schema Validation](/features/schema-validation). -::: - ### Destroy ```bash @@ -150,7 +127,7 @@ rails g servus:event gold_transferred | Option | Description | | --- | --- | -| `--no-docs` | Skip YARD documentation comments and TODO scaffolding | +| `--no-docs` | Skip YARD documentation comments and TODO scaffolding. The `schema` declaration is still generated — it is code, not documentation. | ### Generated Event class @@ -162,7 +139,7 @@ class GoldTransferredEvent < Servus::Event description: 'GoldTransferredEvent event payload', } - # invoke ExampleService, async: true do |payload| + # enqueue ExampleService do |payload| # { example_arg: payload[:example_field] } # end end @@ -208,7 +185,7 @@ rails g servus:guard eligible_transfer | Option | Description | | --- | --- | -| `--no-docs` | Skip YARD documentation comments and TODO scaffolding | +| `--no-docs` | Skip YARD documentation comments and TODO scaffolding. The `schema` declaration is still generated — it is code, not documentation. | ### Generated guard @@ -287,7 +264,6 @@ All generators respect the directory settings in `Servus.configure`: # config/initializers/servus.rb Servus.configure do |config| config.services_dir = "app/services" # default: "app/services" - config.schemas_dir = "app/schemas" # default: "app/schemas" config.events_dir = "app/events" # default: "app/events" config.guards_dir = "app/guards" # default: "app/guards" config.tests_dir = "spec" # default: "spec" diff --git a/site/testing/events.md b/site/testing/events.md index ff3b9bc8..7f342db8 100644 --- a/site/testing/events.md +++ b/site/testing/events.md @@ -47,7 +47,7 @@ expect { ## Testing Event classes -Event classes are tested by calling their `handle` class method with a payload. Use the `call_service` matcher to assert which services are invoked: +Event classes are tested by calling their `handle` class method with a payload. Services declared with `enqueue` are always enqueued, so assert with the `.async` chain — `call_service` without it asserts a synchronous `.call`, which an Event class never makes: ```ruby RSpec.describe GoldTransferredEvent do @@ -59,22 +59,16 @@ RSpec.describe GoldTransferredEvent do } end - it "invokes Ledger::RecordEntry" do - expect { - described_class.handle(payload) - }.to call_service(Ledger::RecordEntry::Service) - end - - it "invokes it asynchronously" do + it "enqueues Ledger::RecordEntry" do expect { described_class.handle(payload) }.to call_service(Ledger::RecordEntry::Service).async end - it "invokes it with the expected arguments" do + it "enqueues it with the expected arguments" do expect { described_class.handle(payload) - }.to call_service(Ledger::RecordEntry::Service).with( + }.to call_service(Ledger::RecordEntry::Service).async.with( transferred: payload[:transferred] ) end @@ -88,7 +82,7 @@ end ```ruby expect { described_class.handle(payload) -}.to call_service(Ravens::SendReceipt::Service) +}.to call_service(Ravens::SendReceipt::Service).async .with(amount: 50) .async ``` @@ -115,7 +109,7 @@ RSpec.describe GoldTransferredEvent do it "does not dispatch a raven" do expect { described_class.handle(payload) - }.not_to call_service(Ravens::DispatchMessage::Service) + }.not_to call_service(Ravens::DispatchMessage::Service).async end end end