From 8c1746b1d8058e972490c86bfce0e2306aa72979 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 13:50:22 -0500 Subject: [PATCH 01/12] feat: add microsvc handler message specs Implements the first slice of [[specs/async-microsvc-transports]]. Adds handler metadata, message envelopes, subscription planning, and projection handler envelope dispatch. --- src/microsvc/mod.rs | 12 +- src/microsvc/service.rs | 429 ++++++++++++++++-- .../handlers/record_seat_reserved.rs | 2 + .../checkout_saga_service/handlers/start.rs | 2 + .../checkout_saga_service/service.rs | 4 +- tests/distributed_read_model/main.rs | 10 +- .../projection_service/handlers/checkout.rs | 4 +- .../projection_service/handlers/mod.rs | 35 +- .../projection_service/handlers/seat.rs | 4 +- .../projection_service/mod.rs | 2 +- .../projection_service/service.rs | 87 +--- .../seat_inventory_service/handlers/add.rs | 2 + .../handlers/reserve_started_checkout_seat.rs | 2 + .../seat_inventory_service/service.rs | 4 +- .../board_service/handlers/board_add_card.rs | 2 + .../board_service/handlers/board_move_card.rs | 2 + .../board_service/handlers/board_open.rs | 2 + .../handlers/board_remove_card.rs | 2 + .../projections_service/handlers/board.rs | 4 +- .../projections_service/handlers/mod.rs | 35 +- .../projections_service/mod.rs | 68 +-- tests/microsvc/convention.rs | 1 + tests/microsvc/handlers/counter_create.rs | 2 + tests/microsvc/handlers/counter_increment.rs | 2 + tests/microsvc/handlers/whoami.rs | 2 + tests/sagas/handlers/inventory/init.rs | 2 + tests/sagas/handlers/inventory/reserve.rs | 2 + tests/sagas/handlers/orders/complete.rs | 2 + tests/sagas/handlers/orders/create.rs | 2 + tests/sagas/handlers/payments/process.rs | 2 + .../handlers/saga/on_inventory_reserved.rs | 2 + .../sagas/handlers/saga/on_order_completed.rs | 2 + tests/sagas/handlers/saga/on_order_created.rs | 2 + .../handlers/saga/on_payment_succeeded.rs | 2 + tests/sagas/handlers/saga/start.rs | 2 + 35 files changed, 472 insertions(+), 269 deletions(-) diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 489e763bd..ea5420c20 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -34,6 +34,7 @@ //! // src/handlers/order_create.rs //! //! pub const COMMAND: &str = "order.create"; +//! pub const SPEC: microsvc::HandlerSpec = microsvc::HandlerSpec::command(COMMAND); //! //! pub fn guard(ctx: µsvc::Context) -> bool { //! ctx.has_fields(&["id", "product_id"]) @@ -64,7 +65,10 @@ pub use dependencies::{ RepoReadModelDependencies, }; pub use error::HandlerError; -pub use service::{CommandRequest, CommandResponse, Service}; +pub use service::{ + CommandRequest, CommandResponse, DeliveryKind, HandlerInput, HandlerNames, HandlerSpec, + MessageEnvelope, MessageKind, Service, SubscriptionPlan, +}; pub use session::Session; // Bus transports (requires "bus" feature) @@ -86,7 +90,7 @@ pub use grpc::{grpc_server, serve_grpc, GrpcServeError}; /// Register handler modules with a service using the convention pattern. /// /// Each handler module must export: -/// - `COMMAND: &str` — the command name +/// - `SPEC: HandlerSpec` — the command or event metadata /// - `guard(ctx) -> bool` — input validation /// - `handle(ctx) -> Result` — the handler /// @@ -103,8 +107,8 @@ macro_rules! register_handlers { ($service:expr, $( $($seg:ident)::+ ),+ $(,)?) => { $service $( - .command_guarded( - $($seg)::+::COMMAND, + .handler( + $($seg)::+::SPEC, $($seg)::+::guard, $($seg)::+::handle, ) diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index ba71bcdd4..81ec1a8b6 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -19,7 +19,7 @@ //! ``` use std::collections::HashMap; -use std::{error::Error, fmt}; +use std::{error::Error, fmt, sync::Arc}; use serde_json::Value; @@ -28,13 +28,174 @@ use super::dependencies::{HasReadModelStore, HasRepo, RepoReadModelDependencies} use super::error::HandlerError; use super::session::Session; +#[cfg(feature = "bus")] +use crate::bus::Event; + type GuardFn = dyn Fn(&Context) -> bool + Send + Sync; type HandlerFn = dyn Fn(&Context) -> Result + Send + Sync; +/// The kind of message a handler consumes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub enum MessageKind { + /// A command addressed to one handler. + Command, + /// A published event that may be consumed by many handlers. + Event, +} + +/// How a handler expects the transport to deliver matching messages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryKind { + /// Point-to-point delivery, normally used for command queues. + PointToPoint, + /// Fan-out delivery, normally used for event subscriptions. + FanOut, +} + +/// The input shape delivered to a handler after a message is received. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandlerInput { + /// Decode the message payload bytes as JSON and pass that JSON value to + /// the handler. This is the default command-style input. + PayloadJson, + /// Pass the full [`MessageEnvelope`] to the handler as JSON. + MessageEnvelope, +} + +/// Static message names attached to a handler spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandlerNames { + /// A single command or event name. + One(&'static str), + /// Multiple event names handled by one projection-style handler. + Many(&'static [&'static str]), +} + +impl HandlerNames { + fn to_vec(self) -> Vec<&'static str> { + match self { + Self::One(name) => vec![name], + Self::Many(names) => names.to_vec(), + } + } +} + +/// Transport-visible metadata for a registered handler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HandlerSpec { + names: HandlerNames, + pub kind: MessageKind, + pub delivery: DeliveryKind, + pub input: HandlerInput, +} + +impl HandlerSpec { + /// A command handler that consumes JSON payloads. + pub const fn command(name: &'static str) -> Self { + Self { + names: HandlerNames::One(name), + kind: MessageKind::Command, + delivery: DeliveryKind::PointToPoint, + input: HandlerInput::PayloadJson, + } + } + + /// An event handler that consumes JSON payloads. + pub const fn event(name: &'static str) -> Self { + Self { + names: HandlerNames::One(name), + kind: MessageKind::Event, + delivery: DeliveryKind::FanOut, + input: HandlerInput::PayloadJson, + } + } + + /// An event handler that consumes several event names. + pub const fn events(names: &'static [&'static str]) -> Self { + Self { + names: HandlerNames::Many(names), + kind: MessageKind::Event, + delivery: DeliveryKind::FanOut, + input: HandlerInput::PayloadJson, + } + } + + /// Deliver the full message envelope to the handler. + pub const fn envelope(mut self) -> Self { + self.input = HandlerInput::MessageEnvelope; + self + } + + /// Deliver only the message JSON payload to the handler. + pub const fn payload_json(mut self) -> Self { + self.input = HandlerInput::PayloadJson; + self + } + + /// Message names consumed by this handler. + pub fn names(&self) -> Vec<&'static str> { + self.names.to_vec() + } +} + +/// Transport subscription metadata derived from registered handlers. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SubscriptionPlan { + /// Command names consumed by point-to-point command transports. + pub commands: Vec, + /// Event names consumed by fan-out event transports. + pub events: Vec, +} + +/// Serializable transport message envelope used by projection-style handlers +/// that need message ids, names, payload bytes, and metadata. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct MessageEnvelope { + pub id: String, + pub name: String, + pub kind: MessageKind, + pub payload: Vec, + pub content_type: String, + pub metadata: Vec<(String, String)>, +} + +#[cfg(feature = "bus")] +impl From<&Event> for MessageEnvelope { + fn from(event: &Event) -> Self { + Self { + id: event.id.clone(), + name: event.event_type.clone(), + kind: MessageKind::Event, + payload: event.payload.clone(), + content_type: "application/json".to_string(), + metadata: event.metadata.clone().unwrap_or_default(), + } + } +} + +#[cfg(feature = "bus")] +impl From for Event { + fn from(envelope: MessageEnvelope) -> Self { + let metadata = if envelope.metadata.is_empty() { + None + } else { + Some(envelope.metadata) + }; + + Self { + id: envelope.id, + event_type: envelope.name, + payload: envelope.payload, + metadata, + } + } +} + /// A registered command handler with optional guard. struct CommandHandler { - guard: Option>>, - handle: Box>, + input: HandlerInput, + guard: Option>>, + handle: Arc>, } /// A microservice that routes commands to handler functions. @@ -45,6 +206,7 @@ struct CommandHandler { pub struct Service { dependencies: D, handlers: HashMap>, + handler_specs: Vec, } impl Service { @@ -53,6 +215,7 @@ impl Service { Self { dependencies, handlers: HashMap::new(), + handler_specs: Vec::new(), } } @@ -75,36 +238,55 @@ impl Service { /// Register a command handler. /// /// Uses builder pattern — returns `self` for chaining. - pub fn command(mut self, name: &str, handler: F) -> Self + pub fn command(self, name: &'static str, handler: F) -> Self where F: Fn(&Context) -> Result + Send + Sync + 'static, { - self.handlers.insert( - name.to_string(), - CommandHandler { - guard: None, - handle: Box::new(handler), - }, - ); - self + self.register_handler(HandlerSpec::command(name), None, Arc::new(handler)) } /// Register a command handler with a guard function. /// /// The guard is called before the handler. If it returns `false`, /// the command is rejected with `HandlerError::GuardRejected`. - pub fn command_guarded(mut self, name: &str, guard: G, handler: F) -> Self + pub fn command_guarded(self, name: &'static str, guard: G, handler: F) -> Self where G: Fn(&Context) -> bool + Send + Sync + 'static, F: Fn(&Context) -> Result + Send + Sync + 'static, { - self.handlers.insert( - name.to_string(), - CommandHandler { - guard: Some(Box::new(guard)), - handle: Box::new(handler), - }, - ); + self.register_handler( + HandlerSpec::command(name), + Some(Arc::new(guard)), + Arc::new(handler), + ) + } + + /// Register a handler from a transport-visible spec. + pub fn handler(self, spec: HandlerSpec, guard: G, handler: F) -> Self + where + G: Fn(&Context) -> bool + Send + Sync + 'static, + F: Fn(&Context) -> Result + Send + Sync + 'static, + { + self.register_handler(spec, Some(Arc::new(guard)), Arc::new(handler)) + } + + fn register_handler( + mut self, + spec: HandlerSpec, + guard: Option>>, + handle: Arc>, + ) -> Self { + for name in spec.names() { + self.handlers.insert( + name.to_string(), + CommandHandler { + input: spec.input, + guard: guard.clone(), + handle: handle.clone(), + }, + ); + } + self.handler_specs.push(spec); self } @@ -132,7 +314,7 @@ impl Service { } } - (handler.handle)(&ctx) + (handler.handle.as_ref())(&ctx) } /// Dispatch a `CommandRequest`, returning a `CommandResponse`. @@ -150,22 +332,65 @@ impl Service { } } - /// Dispatch a bus `Event` as a command. - /// - /// Maps the event fields to a dispatch call: - /// - `event.event_type` → command name - /// - `event.payload` → JSON input (parsed from bytes) - /// - `event.metadata` → session variables + /// Dispatch a transport message. + pub fn dispatch_message(&self, message: &MessageEnvelope) -> Result { + let handler = self + .handlers + .get(&message.name) + .ok_or_else(|| HandlerError::UnknownCommand(message.name.clone()))?; + let input = message_to_handler_input(message, handler.input)?; + let session = message_to_session(message); + self.dispatch(&message.name, input, session) + } + + /// Dispatch a bus `Event` as a message. #[cfg(feature = "bus")] pub fn dispatch_event(&self, event: &crate::bus::Event) -> Result { - let input = event_to_json_input(event)?; - let session = event_to_session(event); - self.dispatch(&event.event_type, input, session) + self.dispatch_message(&MessageEnvelope::from(event)) } /// List registered command names. pub fn commands(&self) -> Vec<&str> { - self.handlers.keys().map(|s| s.as_str()).collect() + self.command_names() + } + + /// List registered command names. + pub fn command_names(&self) -> Vec<&str> { + names_by_kind(&self.handler_specs, MessageKind::Command) + } + + /// List registered event names. + pub fn event_names(&self) -> Vec<&str> { + names_by_kind(&self.handler_specs, MessageKind::Event) + } + + /// Return transport metadata for registered handlers. + pub fn handler_specs(&self) -> &[HandlerSpec] { + &self.handler_specs + } + + /// Return the command/event names a transport should subscribe to. + pub fn subscription_plan(&self) -> SubscriptionPlan { + let mut plan = SubscriptionPlan::default(); + + for spec in &self.handler_specs { + for name in spec.names() { + let bucket = match spec.kind { + MessageKind::Command => &mut plan.commands, + MessageKind::Event => &mut plan.events, + }; + if !bucket.iter().any(|existing| existing == name) { + bucket.push(name.to_string()); + } + } + } + + plan + } + + /// Return whether this service has a handler for the message name. + pub fn handles(&self, name: &str) -> bool { + self.handlers.contains_key(name) } /// Get a reference to the service dependencies. @@ -351,6 +576,9 @@ where /// multiple services need to react to the same events. /// /// Successfully handled events are acknowledged. Failed events are nacked. +/// Events with no registered handler are acknowledged and ignored; production +/// transports should use [`Service::subscription_plan`] to avoid delivering +/// unrelated event types to the service. /// /// ## Example /// @@ -397,6 +625,9 @@ where stats.polls += 1; match subscriber.poll(poll_interval.as_millis() as u64) { + Ok(Some(event)) if !service.handles(&event.event_type) => { + let _ = subscriber.ack(&event.id); + } Ok(Some(event)) => match service.dispatch_event(&event) { Ok(_) => { let _ = subscriber.ack(&event.id); @@ -422,31 +653,46 @@ where } // ============================================================================= -// Helpers: convert bus Event to dispatch inputs +// Helpers: convert transport messages to dispatch inputs // ============================================================================= -/// Parse a bus Event's payload as JSON. -#[cfg(feature = "bus")] -fn event_to_json_input(event: &crate::bus::Event) -> Result { - serde_json::from_slice::(&event.payload).map_err(|e| { +fn names_by_kind(specs: &[HandlerSpec], kind: MessageKind) -> Vec<&str> { + let mut names = Vec::new(); + + for spec in specs.iter().filter(|spec| spec.kind == kind) { + for name in spec.names() { + if !names.contains(&name) { + names.push(name); + } + } + } + + names +} + +fn message_to_handler_input( + message: &MessageEnvelope, + input: HandlerInput, +) -> Result { + match input { + HandlerInput::PayloadJson => message_to_json_input(message), + HandlerInput::MessageEnvelope => serde_json::to_value(message) + .map_err(|e| HandlerError::DecodeFailed(format!("invalid message envelope: {e}"))), + } +} + +fn message_to_json_input(message: &MessageEnvelope) -> Result { + serde_json::from_slice::(&message.payload).map_err(|e| { HandlerError::DecodeFailed(format!( - "invalid JSON payload for command '{}': {}", - event.event_type, e + "invalid JSON payload for message '{}': {}", + message.name, e )) }) } -/// Extract session variables from a bus Event's metadata. -#[cfg(feature = "bus")] -fn event_to_session(event: &crate::bus::Event) -> Session { - match &event.metadata { - Some(meta) => { - let vars: HashMap = - meta.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - Session::from_map(vars) - } - None => Session::new(), - } +fn message_to_session(message: &MessageEnvelope) -> Session { + let vars: HashMap = message.metadata.iter().cloned().collect(); + Session::from_map(vars) } #[cfg(test)] @@ -505,6 +751,91 @@ mod tests { assert_eq!(cmds, vec!["a", "b"]); } + #[test] + fn subscription_plan_separates_commands_and_events() { + const EVENTS: &[&str] = &["checkout.started", "seat.reserved"]; + + let service = test_service() + .command("checkout.start", |_| Ok(json!({}))) + .handler( + HandlerSpec::events(EVENTS).envelope(), + |_| true, + |_| Ok(json!({})), + ); + + assert_eq!( + service.subscription_plan(), + SubscriptionPlan { + commands: vec!["checkout.start".to_string()], + events: vec!["checkout.started".to_string(), "seat.reserved".to_string()], + } + ); + } + + #[test] + fn dispatch_message_delivers_payload_json_by_default() { + let service = test_service().handler( + HandlerSpec::event("checkout.started"), + |ctx| ctx.has_fields(&["checkout_id"]), + |ctx| { + Ok(json!({ + "checkout_id": ctx.raw_input()["checkout_id"].as_str().unwrap(), + "user_id": ctx.user_id()?, + })) + }, + ); + let message = MessageEnvelope { + id: "evt-1".to_string(), + name: "checkout.started".to_string(), + kind: MessageKind::Event, + payload: br#"{"checkout_id":"checkout-1"}"#.to_vec(), + content_type: "application/json".to_string(), + metadata: vec![("x-hasura-user-id".to_string(), "user-1".to_string())], + }; + + let result = service.dispatch_message(&message).unwrap(); + + assert_eq!( + result, + json!({ "checkout_id": "checkout-1", "user_id": "user-1" }) + ); + } + + #[test] + fn dispatch_message_can_deliver_full_envelope() { + let service = test_service().handler( + HandlerSpec::event("seat.reserved").envelope(), + |ctx| ctx.has_fields(&["id", "name", "payload"]), + |ctx| { + let envelope = ctx.input::()?; + Ok(json!({ + "event_id": envelope.id, + "name": envelope.name, + "metadata": envelope.metadata, + })) + }, + ); + let message = MessageEnvelope { + id: "evt-2".to_string(), + name: "seat.reserved".to_string(), + kind: MessageKind::Event, + payload: br#"{"seat_id":"A-7"}"#.to_vec(), + content_type: "application/json".to_string(), + metadata: vec![("correlation_id".to_string(), "checkout-1".to_string())], + }; + + let result = service.dispatch_message(&message).unwrap(); + + assert_eq!( + result, + json!({ + "event_id": "evt-2", + "name": "seat.reserved", + "metadata": [["correlation_id", "checkout-1"]], + }) + ); + } + #[test] fn guard_passes() { let service = test_service().command_guarded( diff --git a/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs b/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs index 72d724680..55ad3ec12 100644 --- a/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs +++ b/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs @@ -9,6 +9,8 @@ use crate::checkout::{ use crate::checkout_saga_service::CheckoutRepo; pub const EVENT: &str = seat_event::RESERVED; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::event(EVENT); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["checkout_id", "seat_id", "seat_category"]) diff --git a/tests/distributed_read_model/checkout_saga_service/handlers/start.rs b/tests/distributed_read_model/checkout_saga_service/handlers/start.rs index 50d23fefa..ab8531e07 100644 --- a/tests/distributed_read_model/checkout_saga_service/handlers/start.rs +++ b/tests/distributed_read_model/checkout_saga_service/handlers/start.rs @@ -8,6 +8,8 @@ use crate::checkout::{ use crate::checkout_saga_service::{CheckoutRepo, CheckoutSaga}; pub const COMMAND: &str = checkout_command::START; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["checkout_id", "seat_id", "seat_category"]) diff --git a/tests/distributed_read_model/checkout_saga_service/service.rs b/tests/distributed_read_model/checkout_saga_service/service.rs index 337ce10cf..82d0f3118 100644 --- a/tests/distributed_read_model/checkout_saga_service/service.rs +++ b/tests/distributed_read_model/checkout_saga_service/service.rs @@ -6,8 +6,8 @@ use super::{handlers, CheckoutRepo}; pub fn service(repo: CheckoutRepo) -> Arc> { let service = sourced_rust::register_handlers!(Service::with_repo(repo), handlers::start); - Arc::new(service.command_guarded( - handlers::record_seat_reserved::EVENT, + Arc::new(service.handler( + handlers::record_seat_reserved::SPEC, handlers::record_seat_reserved::guard, handlers::record_seat_reserved::handle, )) diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index 08ea276c7..148a94826 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -28,9 +28,7 @@ use checkout::{ SEAT_RESERVED_MESSAGE, }; use checkout_saga_service::CheckoutSaga; -use projection_service::{ - service as projection_service, subscriber as projection_subscriber, CHECKOUT_SCREEN_CONSUMER, -}; +use projection_service::{service as projection_service, CHECKOUT_SCREEN_CONSUMER}; use query_service::CheckoutQueryService; use read_models::{register_schemas, CheckoutView}; use seat_inventory_service::Seat; @@ -101,11 +99,7 @@ fn seat_checkout_saga_reserves_seat_and_projects_user_screen() { let read_store = InMemoryReadModelStore::new(); register_schemas(&read_store).expect("relational schemas should register"); let projection_svc = projection_service(read_store.clone()); - let projection_sub = microsvc::subscribe( - projection_svc.clone(), - projection_subscriber(queue.new_subscriber()), - poll, - ); + let projection_sub = microsvc::subscribe(projection_svc.clone(), queue.new_subscriber(), poll); let query_service = CheckoutQueryService::new(read_store.clone()); dispatch( diff --git a/tests/distributed_read_model/projection_service/handlers/checkout.rs b/tests/distributed_read_model/projection_service/handlers/checkout.rs index a017343f0..6249c7ef7 100644 --- a/tests/distributed_read_model/projection_service/handlers/checkout.rs +++ b/tests/distributed_read_model/projection_service/handlers/checkout.rs @@ -13,9 +13,11 @@ pub const EVENTS: &[&str] = &[ checkout_event::STARTED, checkout_event::SEAT_RESERVATION_COMPLETED, ]; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::events(EVENTS).envelope(); pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "event_type", "payload"]) + ctx.has_fields(&["id", "name", "payload"]) } pub fn handle(ctx: &Context) -> Result { diff --git a/tests/distributed_read_model/projection_service/handlers/mod.rs b/tests/distributed_read_model/projection_service/handlers/mod.rs index 49bef32af..9903d1f00 100644 --- a/tests/distributed_read_model/projection_service/handlers/mod.rs +++ b/tests/distributed_read_model/projection_service/handlers/mod.rs @@ -4,45 +4,14 @@ pub mod checkout; pub mod seat; -use serde::{Deserialize, Serialize}; use sourced_rust::bus::Event; -use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::microsvc::{Context, HandlerError, MessageEnvelope}; use sourced_rust::ReadModelError; use crate::projection_service::ProjectionDependencies; -#[derive(Debug, Deserialize, Serialize)] -pub struct ProjectionMessage { - pub id: String, - pub event_type: String, - pub payload: Vec, - pub metadata: Option>, -} - -impl From<&Event> for ProjectionMessage { - fn from(event: &Event) -> Self { - Self { - id: event.id.clone(), - event_type: event.event_type.clone(), - payload: event.payload.clone(), - metadata: event.metadata.clone(), - } - } -} - -impl From for Event { - fn from(message: ProjectionMessage) -> Self { - Self { - id: message.id, - event_type: message.event_type, - payload: message.payload, - metadata: message.metadata, - } - } -} - pub fn event(ctx: &Context) -> Result { - Ok(ctx.input::()?.into()) + Ok(ctx.input::()?.into()) } pub fn read_model_error(err: ReadModelError) -> HandlerError { diff --git a/tests/distributed_read_model/projection_service/handlers/seat.rs b/tests/distributed_read_model/projection_service/handlers/seat.rs index ce496ba9e..16e7867cc 100644 --- a/tests/distributed_read_model/projection_service/handlers/seat.rs +++ b/tests/distributed_read_model/projection_service/handlers/seat.rs @@ -7,9 +7,11 @@ use crate::projection_service::{ProjectionDependencies, CHECKOUT_SCREEN_CONSUMER use crate::read_models::{CheckoutStepView, SeatView}; pub const EVENTS: &[&str] = &[seat_event::ADDED, seat_event::RESERVED]; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::events(EVENTS).envelope(); pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "event_type", "payload"]) + ctx.has_fields(&["id", "name", "payload"]) } pub fn handle(ctx: &Context) -> Result { diff --git a/tests/distributed_read_model/projection_service/mod.rs b/tests/distributed_read_model/projection_service/mod.rs index 39ddec074..7b2c6fe65 100644 --- a/tests/distributed_read_model/projection_service/mod.rs +++ b/tests/distributed_read_model/projection_service/mod.rs @@ -2,6 +2,6 @@ mod service; pub mod handlers; -pub use service::{projects, service, subscriber, ProjectionDependencies}; +pub use service::{projects, service, ProjectionDependencies}; pub const CHECKOUT_SCREEN_CONSUMER: &str = "checkout-screen-projection"; diff --git a/tests/distributed_read_model/projection_service/service.rs b/tests/distributed_read_model/projection_service/service.rs index 2e5b8e21f..0912b19e5 100644 --- a/tests/distributed_read_model/projection_service/service.rs +++ b/tests/distributed_read_model/projection_service/service.rs @@ -1,85 +1,28 @@ use std::sync::Arc; -use serde_json::Value; -use sourced_rust::bus::{Event, PublishError, Subscriber}; -use sourced_rust::microsvc::{Context, HandlerError, Service}; +use sourced_rust::microsvc::Service; use sourced_rust::InMemoryReadModelStore; -use super::handlers::{self, ProjectionMessage}; +use super::handlers; pub type ProjectionDependencies = InMemoryReadModelStore; -type ProjectionGuard = fn(&Context) -> bool; -type ProjectionHandler = fn(&Context) -> Result; - -pub struct ProjectionSubscriber { - inner: S, -} - -impl Subscriber for ProjectionSubscriber -where - S: Subscriber, -{ - fn poll(&self, timeout_ms: u64) -> Result, PublishError> { - self.inner.poll(timeout_ms).and_then(|event| { - event - .map(|event| { - let payload = serde_json::to_vec(&ProjectionMessage::from(&event)) - .map_err(|err| PublishError::SerializationFailed(err.to_string()))?; - let mut wrapped = Event::new(event.id, event.event_type, payload); - wrapped.metadata = event.metadata; - Ok(wrapped) - }) - .transpose() - }) - } - - fn ack(&self, event_id: &str) -> Result<(), PublishError> { - self.inner.ack(event_id) - } - - fn nack(&self, event_id: &str, reason: &str) -> Result<(), PublishError> { - self.inner.nack(event_id, reason) - } -} - pub fn service(store: InMemoryReadModelStore) -> Arc> { - let service = Service::with_read_model_store(store); - let service = register_handler_events( - service, - handlers::checkout::EVENTS, - handlers::checkout::guard, - handlers::checkout::handle, - ); - let service = register_handler_events( - service, - handlers::seat::EVENTS, - handlers::seat::guard, - handlers::seat::handle, - ); - - Arc::new(service) -} - -pub fn subscriber(subscriber: S) -> ProjectionSubscriber -where - S: Subscriber, -{ - ProjectionSubscriber { inner: subscriber } + Arc::new( + Service::with_read_model_store(store) + .handler( + handlers::checkout::SPEC, + handlers::checkout::guard, + handlers::checkout::handle, + ) + .handler( + handlers::seat::SPEC, + handlers::seat::guard, + handlers::seat::handle, + ), + ) } pub fn projects(event_type: &str) -> bool { handlers::checkout::EVENTS.contains(&event_type) || handlers::seat::EVENTS.contains(&event_type) } - -fn register_handler_events( - mut service: Service, - events: &[&str], - guard: ProjectionGuard, - handle: ProjectionHandler, -) -> Service { - for event in events { - service = service.command_guarded(event, guard, handle); - } - service -} diff --git a/tests/distributed_read_model/seat_inventory_service/handlers/add.rs b/tests/distributed_read_model/seat_inventory_service/handlers/add.rs index d7507e2bd..3b5283541 100644 --- a/tests/distributed_read_model/seat_inventory_service/handlers/add.rs +++ b/tests/distributed_read_model/seat_inventory_service/handlers/add.rs @@ -6,6 +6,8 @@ use crate::checkout::{json_outbox_event, seat_command, seat_event, AddSeat, Seat use crate::seat_inventory_service::{Seat, SeatRepo}; pub const COMMAND: &str = seat_command::ADD; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["seat_id", "category"]) diff --git a/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs b/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs index 5e92680f6..12da3f2fc 100644 --- a/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs +++ b/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs @@ -9,6 +9,8 @@ use crate::checkout::{ use crate::seat_inventory_service::SeatRepo; pub const EVENT: &str = checkout_event::STARTED; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::event(EVENT); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["checkout_id", "seat_id", "seat_category"]) diff --git a/tests/distributed_read_model/seat_inventory_service/service.rs b/tests/distributed_read_model/seat_inventory_service/service.rs index 6d88f7345..c822f33f8 100644 --- a/tests/distributed_read_model/seat_inventory_service/service.rs +++ b/tests/distributed_read_model/seat_inventory_service/service.rs @@ -6,8 +6,8 @@ use super::{handlers, SeatRepo}; pub fn service(repo: SeatRepo) -> Arc> { let service = sourced_rust::register_handlers!(Service::with_repo(repo), handlers::add); - Arc::new(service.command_guarded( - handlers::reserve_started_checkout_seat::EVENT, + Arc::new(service.handler( + handlers::reserve_started_checkout_seat::SPEC, handlers::reserve_started_checkout_seat::guard, handlers::reserve_started_checkout_seat::handle, )) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs index 4333a4921..0cec960cc 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs @@ -5,6 +5,8 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{AddCard, Board, BoardRepo}; pub const COMMAND: &str = "board.add_card"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "card_id", "column", "title"]) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs index 4bca748d0..aefd88492 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs @@ -5,6 +5,8 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{Board, BoardRepo, MoveCard}; pub const COMMAND: &str = "board.move_card"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "card_id", "column"]) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_open.rs b/tests/distributed_read_model_board/board_service/handlers/board_open.rs index 9ff5385d5..e816207e8 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_open.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_open.rs @@ -5,6 +5,8 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{Board, BoardRepo, OpenBoard}; pub const COMMAND: &str = "board.open"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "name"]) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs index ecc7b550d..edd39f599 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs @@ -5,6 +5,8 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{Board, BoardRepo, RemoveCard}; pub const COMMAND: &str = "board.remove_card"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "card_id"]) diff --git a/tests/distributed_read_model_board/projections_service/handlers/board.rs b/tests/distributed_read_model_board/projections_service/handlers/board.rs index c441a22b9..dd9068122 100644 --- a/tests/distributed_read_model_board/projections_service/handlers/board.rs +++ b/tests/distributed_read_model_board/projections_service/handlers/board.rs @@ -19,9 +19,11 @@ pub const EVENTS: &[&str] = &[ "board.card_moved", "board.card_removed", ]; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::events(EVENTS).envelope(); pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "event_type", "payload"]) + ctx.has_fields(&["id", "name", "payload"]) } pub fn handle(ctx: &Context) -> Result { diff --git a/tests/distributed_read_model_board/projections_service/handlers/mod.rs b/tests/distributed_read_model_board/projections_service/handlers/mod.rs index c00caba57..5e7f44b63 100644 --- a/tests/distributed_read_model_board/projections_service/handlers/mod.rs +++ b/tests/distributed_read_model_board/projections_service/handlers/mod.rs @@ -3,42 +3,11 @@ pub mod board; -use serde::{Deserialize, Serialize}; use sourced_rust::bus::Event; -use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::microsvc::{Context, HandlerError, MessageEnvelope}; use crate::projections_service::ProjectionDependencies; -#[derive(Debug, Deserialize, Serialize)] -pub struct ProjectionMessage { - pub id: String, - pub event_type: String, - pub payload: Vec, - pub metadata: Option>, -} - -impl From<&Event> for ProjectionMessage { - fn from(event: &Event) -> Self { - Self { - id: event.id.clone(), - event_type: event.event_type.clone(), - payload: event.payload.clone(), - metadata: event.metadata.clone(), - } - } -} - -impl From for Event { - fn from(message: ProjectionMessage) -> Self { - Self { - id: message.id, - event_type: message.event_type, - payload: message.payload, - metadata: message.metadata, - } - } -} - pub fn event(ctx: &Context) -> Result { - Ok(ctx.input::()?.into()) + Ok(ctx.input::()?.into()) } diff --git a/tests/distributed_read_model_board/projections_service/mod.rs b/tests/distributed_read_model_board/projections_service/mod.rs index 670f1c048..0e0d2b9c1 100644 --- a/tests/distributed_read_model_board/projections_service/mod.rs +++ b/tests/distributed_read_model_board/projections_service/mod.rs @@ -9,87 +9,31 @@ use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use serde_json::Value; -use sourced_rust::bus::{Event, PublishError, Subscribable, Subscriber}; -use sourced_rust::microsvc::{self, Context, HandlerError, Service}; +use sourced_rust::bus::Subscribable; +use sourced_rust::microsvc::{self, HandlerError, Service}; use sourced_rust::{InMemoryQueue, InMemoryReadModelStore, ReadModelError, ReadModelWorkspaceExt}; use crate::read_models::{board_key, BoardView}; pub type ProjectionDependencies = InMemoryReadModelStore; -type ProjectionGuard = fn(&Context) -> bool; -type ProjectionHandler = fn(&Context) -> Result; - -pub struct ProjectionSubscriber { - inner: S, -} - -impl Subscriber for ProjectionSubscriber -where - S: Subscriber, -{ - fn poll(&self, timeout_ms: u64) -> Result, PublishError> { - self.inner.poll(timeout_ms).and_then(|event| { - event - .map(|event| { - let payload = serde_json::to_vec(&handlers::ProjectionMessage::from(&event)) - .map_err(|err| PublishError::SerializationFailed(err.to_string()))?; - let mut wrapped = Event::new(event.id, event.event_type, payload); - wrapped.metadata = event.metadata; - Ok(wrapped) - }) - .transpose() - }) - } - - fn ack(&self, event_id: &str) -> Result<(), PublishError> { - self.inner.ack(event_id) - } - - fn nack(&self, event_id: &str, reason: &str) -> Result<(), PublishError> { - self.inner.nack(event_id, reason) - } -} - pub fn start_board_projection_service( queue: InMemoryQueue, store: InMemoryReadModelStore, ) -> microsvc::TransportHandle { microsvc::subscribe( service(store), - subscriber(queue.new_subscriber()), + queue.new_subscriber(), Duration::from_millis(10), ) } pub fn service(store: InMemoryReadModelStore) -> Arc> { - let service = register_handler_events( - Service::with_read_model_store(store), - handlers::board::EVENTS, + Arc::new(Service::with_read_model_store(store).handler( + handlers::board::SPEC, handlers::board::guard, handlers::board::handle, - ); - Arc::new(service) -} - -pub fn subscriber(subscriber: S) -> ProjectionSubscriber -where - S: Subscriber, -{ - ProjectionSubscriber { inner: subscriber } -} - -fn register_handler_events( - mut service: Service, - events: &[&str], - guard: ProjectionGuard, - handle: ProjectionHandler, -) -> Service { - for event in events { - service = service.command_guarded(event, guard, handle); - } - service + )) } fn read_model_error(err: ReadModelError) -> HandlerError { diff --git a/tests/microsvc/convention.rs b/tests/microsvc/convention.rs index 4770bc65f..c391b0601 100644 --- a/tests/microsvc/convention.rs +++ b/tests/microsvc/convention.rs @@ -2,6 +2,7 @@ //! //! Each handler lives in its own file under `handlers/` and exports: //! - `COMMAND: &str` — the command name +//! - `SPEC: HandlerSpec` — the handler's command/event metadata //! - `guard(ctx) -> bool` — input validation //! - `handle(ctx) -> Result` — the handler //! diff --git a/tests/microsvc/handlers/counter_create.rs b/tests/microsvc/handlers/counter_create.rs index 90db1dbcb..e115e7def 100644 --- a/tests/microsvc/handlers/counter_create.rs +++ b/tests/microsvc/handlers/counter_create.rs @@ -14,6 +14,8 @@ use super::Repo; use crate::models::counter::Counter; pub const COMMAND: &str = "counter.create"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); #[derive(Deserialize)] pub struct Input { diff --git a/tests/microsvc/handlers/counter_increment.rs b/tests/microsvc/handlers/counter_increment.rs index 3c369435d..5b4583983 100644 --- a/tests/microsvc/handlers/counter_increment.rs +++ b/tests/microsvc/handlers/counter_increment.rs @@ -9,6 +9,8 @@ use super::Repo; use crate::models::counter::Counter; pub const COMMAND: &str = "counter.increment"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); #[derive(Deserialize)] pub struct Input { diff --git a/tests/microsvc/handlers/whoami.rs b/tests/microsvc/handlers/whoami.rs index e811c481d..ebd5204be 100644 --- a/tests/microsvc/handlers/whoami.rs +++ b/tests/microsvc/handlers/whoami.rs @@ -8,6 +8,8 @@ use sourced_rust::microsvc::{Context, HandlerError}; use super::Repo; pub const COMMAND: &str = "whoami"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(_ctx: &Context) -> bool { true diff --git a/tests/sagas/handlers/inventory/init.rs b/tests/sagas/handlers/inventory/init.rs index 7f3279470..ea8905b56 100644 --- a/tests/sagas/handlers/inventory/init.rs +++ b/tests/sagas/handlers/inventory/init.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "InitInventory"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["sku", "stock"]) diff --git a/tests/sagas/handlers/inventory/reserve.rs b/tests/sagas/handlers/inventory/reserve.rs index 3353620c3..7270f1bb7 100644 --- a/tests/sagas/handlers/inventory/reserve.rs +++ b/tests/sagas/handlers/inventory/reserve.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "ReserveInventory"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "sku", "quantity"]) diff --git a/tests/sagas/handlers/orders/complete.rs b/tests/sagas/handlers/orders/complete.rs index 697c2cda9..9777b1a02 100644 --- a/tests/sagas/handlers/orders/complete.rs +++ b/tests/sagas/handlers/orders/complete.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "CompleteOrder"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/orders/create.rs b/tests/sagas/handlers/orders/create.rs index ed193c557..56d14b001 100644 --- a/tests/sagas/handlers/orders/create.rs +++ b/tests/sagas/handlers/orders/create.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "CreateOrder"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "customer_id", "items"]) diff --git a/tests/sagas/handlers/payments/process.rs b/tests/sagas/handlers/payments/process.rs index 695383d1b..d9b814adc 100644 --- a/tests/sagas/handlers/payments/process.rs +++ b/tests/sagas/handlers/payments/process.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "ProcessPayment"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "amount_cents"]) diff --git a/tests/sagas/handlers/saga/on_inventory_reserved.rs b/tests/sagas/handlers/saga/on_inventory_reserved.rs index b1cfb1787..abe5c4eef 100644 --- a/tests/sagas/handlers/saga/on_inventory_reserved.rs +++ b/tests/sagas/handlers/saga/on_inventory_reserved.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "InventoryReserved"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/on_order_completed.rs b/tests/sagas/handlers/saga/on_order_completed.rs index f64655725..796044b0d 100644 --- a/tests/sagas/handlers/saga/on_order_completed.rs +++ b/tests/sagas/handlers/saga/on_order_completed.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "OrderCompleted"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/on_order_created.rs b/tests/sagas/handlers/saga/on_order_created.rs index 28f5d3a72..44465d9d7 100644 --- a/tests/sagas/handlers/saga/on_order_created.rs +++ b/tests/sagas/handlers/saga/on_order_created.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "OrderCreated"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/on_payment_succeeded.rs b/tests/sagas/handlers/saga/on_payment_succeeded.rs index 4609a8710..98a32304a 100644 --- a/tests/sagas/handlers/saga/on_payment_succeeded.rs +++ b/tests/sagas/handlers/saga/on_payment_succeeded.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "PaymentSucceeded"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/start.rs b/tests/sagas/handlers/saga/start.rs index 89983fb3f..e94645a3c 100644 --- a/tests/sagas/handlers/saga/start.rs +++ b/tests/sagas/handlers/saga/start.rs @@ -1,6 +1,8 @@ use super::*; pub const COMMAND: &str = "StartSaga"; +pub const SPEC: sourced_rust::microsvc::HandlerSpec = + sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "customer_id", "items", "total_cents"]) From 7af408f1f9f08c6ad901517aac780dd19bade9b8 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 14:07:24 -0500 Subject: [PATCH 02/12] refactor: derive handler specs during registration Removes per-handler SPEC constants and has register_handlers! construct HandlerSpec values from COMMAND, EVENT, and EVENTS constants. --- src/microsvc/mod.rs | 97 +++++++++++++++++-- .../handlers/record_seat_reserved.rs | 2 - .../checkout_saga_service/handlers/start.rs | 2 - .../checkout_saga_service/service.rs | 9 +- .../projection_service/handlers/checkout.rs | 2 - .../projection_service/handlers/seat.rs | 2 - .../projection_service/service.rs | 18 +--- .../seat_inventory_service/handlers/add.rs | 2 - .../handlers/reserve_started_checkout_seat.rs | 2 - .../seat_inventory_service/service.rs | 9 +- .../board_service/handlers/board_add_card.rs | 2 - .../board_service/handlers/board_move_card.rs | 2 - .../board_service/handlers/board_open.rs | 2 - .../handlers/board_remove_card.rs | 2 - .../projections_service/handlers/board.rs | 2 - .../projections_service/mod.rs | 7 +- tests/microsvc/convention.rs | 1 - tests/microsvc/handlers/counter_create.rs | 2 - tests/microsvc/handlers/counter_increment.rs | 2 - tests/microsvc/handlers/whoami.rs | 2 - tests/sagas/handlers/inventory/init.rs | 2 - tests/sagas/handlers/inventory/reserve.rs | 2 - tests/sagas/handlers/orders/complete.rs | 2 - tests/sagas/handlers/orders/create.rs | 2 - tests/sagas/handlers/payments/process.rs | 2 - .../handlers/saga/on_inventory_reserved.rs | 2 - .../sagas/handlers/saga/on_order_completed.rs | 2 - tests/sagas/handlers/saga/on_order_created.rs | 2 - .../handlers/saga/on_payment_succeeded.rs | 2 - tests/sagas/handlers/saga/start.rs | 2 - 30 files changed, 105 insertions(+), 84 deletions(-) diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index ea5420c20..96ba9027d 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -34,7 +34,6 @@ //! // src/handlers/order_create.rs //! //! pub const COMMAND: &str = "order.create"; -//! pub const SPEC: microsvc::HandlerSpec = microsvc::HandlerSpec::command(COMMAND); //! //! pub fn guard(ctx: µsvc::Context) -> bool { //! ctx.has_fields(&["id", "product_id"]) @@ -89,8 +88,13 @@ pub use grpc::{grpc_server, serve_grpc, GrpcServeError}; /// Register handler modules with a service using the convention pattern. /// -/// Each handler module must export: -/// - `SPEC: HandlerSpec` — the command or event metadata +/// Command handler modules must export: +/// - `COMMAND: &str` — the command name +/// - `guard(ctx) -> bool` — input validation +/// - `handle(ctx) -> Result` — the handler +/// +/// Event handler modules must export: +/// - `EVENT: &str` or `EVENTS: &[&str]` — event names /// - `guard(ctx) -> bool` — input validation /// - `handle(ctx) -> Result` — the handler /// @@ -100,18 +104,95 @@ pub use grpc::{grpc_server, serve_grpc, GrpcServeError}; /// microsvc::Service::with_repo(HashMapRepository::new()), /// handlers::counter_create, /// handlers::counter_increment, +/// event handlers::counter_rebuilt, +/// events handlers::counter_projection => envelope, /// ); /// ``` #[macro_export] macro_rules! register_handlers { - ($service:expr, $( $($seg:ident)::+ ),+ $(,)?) => { + ($service:expr $(,)?) => { $service - $( - .handler( - $($seg)::+::SPEC, + }; + ($service:expr, $($rest:tt)+) => { + $crate::__register_handlers!($service, $($rest)+) + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __register_handlers { + ($service:expr, command $($seg:ident)::+ $(, $($rest:tt)*)?) => { + $crate::__register_handlers_continue!( + $service.handler( + $crate::microsvc::HandlerSpec::command($($seg)::+::COMMAND), + $($seg)::+::guard, + $($seg)::+::handle, + ) + $(, $($rest)*)? + ) + }; + ($service:expr, event $($seg:ident)::+ => envelope $(, $($rest:tt)*)?) => { + $crate::__register_handlers_continue!( + $service.handler( + $crate::microsvc::HandlerSpec::event($($seg)::+::EVENT).envelope(), + $($seg)::+::guard, + $($seg)::+::handle, + ) + $(, $($rest)*)? + ) + }; + ($service:expr, events $($seg:ident)::+ => envelope $(, $($rest:tt)*)?) => { + $crate::__register_handlers_continue!( + $service.handler( + $crate::microsvc::HandlerSpec::events($($seg)::+::EVENTS).envelope(), + $($seg)::+::guard, + $($seg)::+::handle, + ) + $(, $($rest)*)? + ) + }; + ($service:expr, event $($seg:ident)::+ $(, $($rest:tt)*)?) => { + $crate::__register_handlers_continue!( + $service.handler( + $crate::microsvc::HandlerSpec::event($($seg)::+::EVENT), + $($seg)::+::guard, + $($seg)::+::handle, + ) + $(, $($rest)*)? + ) + }; + ($service:expr, events $($seg:ident)::+ $(, $($rest:tt)*)?) => { + $crate::__register_handlers_continue!( + $service.handler( + $crate::microsvc::HandlerSpec::events($($seg)::+::EVENTS), + $($seg)::+::guard, + $($seg)::+::handle, + ) + $(, $($rest)*)? + ) + }; + ($service:expr, $($seg:ident)::+ $(, $($rest:tt)*)?) => { + $crate::__register_handlers_continue!( + $service.handler( + $crate::microsvc::HandlerSpec::command($($seg)::+::COMMAND), $($seg)::+::guard, $($seg)::+::handle, ) - )+ + $(, $($rest)*)? + ) + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __register_handlers_continue { + ($service:expr) => { + $service + }; + ($service:expr,) => { + $service + }; + ($service:expr, $($rest:tt)+) => { + $crate::__register_handlers!($service, $($rest)+) }; } diff --git a/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs b/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs index 55ad3ec12..72d724680 100644 --- a/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs +++ b/tests/distributed_read_model/checkout_saga_service/handlers/record_seat_reserved.rs @@ -9,8 +9,6 @@ use crate::checkout::{ use crate::checkout_saga_service::CheckoutRepo; pub const EVENT: &str = seat_event::RESERVED; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::event(EVENT); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["checkout_id", "seat_id", "seat_category"]) diff --git a/tests/distributed_read_model/checkout_saga_service/handlers/start.rs b/tests/distributed_read_model/checkout_saga_service/handlers/start.rs index ab8531e07..50d23fefa 100644 --- a/tests/distributed_read_model/checkout_saga_service/handlers/start.rs +++ b/tests/distributed_read_model/checkout_saga_service/handlers/start.rs @@ -8,8 +8,6 @@ use crate::checkout::{ use crate::checkout_saga_service::{CheckoutRepo, CheckoutSaga}; pub const COMMAND: &str = checkout_command::START; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["checkout_id", "seat_id", "seat_category"]) diff --git a/tests/distributed_read_model/checkout_saga_service/service.rs b/tests/distributed_read_model/checkout_saga_service/service.rs index 82d0f3118..f257a1421 100644 --- a/tests/distributed_read_model/checkout_saga_service/service.rs +++ b/tests/distributed_read_model/checkout_saga_service/service.rs @@ -5,11 +5,10 @@ use sourced_rust::microsvc::Service; use super::{handlers, CheckoutRepo}; pub fn service(repo: CheckoutRepo) -> Arc> { - let service = sourced_rust::register_handlers!(Service::with_repo(repo), handlers::start); - Arc::new(service.handler( - handlers::record_seat_reserved::SPEC, - handlers::record_seat_reserved::guard, - handlers::record_seat_reserved::handle, + Arc::new(sourced_rust::register_handlers!( + Service::with_repo(repo), + handlers::start, + event handlers::record_seat_reserved, )) } diff --git a/tests/distributed_read_model/projection_service/handlers/checkout.rs b/tests/distributed_read_model/projection_service/handlers/checkout.rs index 6249c7ef7..ada87e9b2 100644 --- a/tests/distributed_read_model/projection_service/handlers/checkout.rs +++ b/tests/distributed_read_model/projection_service/handlers/checkout.rs @@ -13,8 +13,6 @@ pub const EVENTS: &[&str] = &[ checkout_event::STARTED, checkout_event::SEAT_RESERVATION_COMPLETED, ]; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::events(EVENTS).envelope(); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "name", "payload"]) diff --git a/tests/distributed_read_model/projection_service/handlers/seat.rs b/tests/distributed_read_model/projection_service/handlers/seat.rs index 16e7867cc..f0f3e693c 100644 --- a/tests/distributed_read_model/projection_service/handlers/seat.rs +++ b/tests/distributed_read_model/projection_service/handlers/seat.rs @@ -7,8 +7,6 @@ use crate::projection_service::{ProjectionDependencies, CHECKOUT_SCREEN_CONSUMER use crate::read_models::{CheckoutStepView, SeatView}; pub const EVENTS: &[&str] = &[seat_event::ADDED, seat_event::RESERVED]; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::events(EVENTS).envelope(); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "name", "payload"]) diff --git a/tests/distributed_read_model/projection_service/service.rs b/tests/distributed_read_model/projection_service/service.rs index 0912b19e5..367c32fc9 100644 --- a/tests/distributed_read_model/projection_service/service.rs +++ b/tests/distributed_read_model/projection_service/service.rs @@ -8,19 +8,11 @@ use super::handlers; pub type ProjectionDependencies = InMemoryReadModelStore; pub fn service(store: InMemoryReadModelStore) -> Arc> { - Arc::new( - Service::with_read_model_store(store) - .handler( - handlers::checkout::SPEC, - handlers::checkout::guard, - handlers::checkout::handle, - ) - .handler( - handlers::seat::SPEC, - handlers::seat::guard, - handlers::seat::handle, - ), - ) + Arc::new(sourced_rust::register_handlers!( + Service::with_read_model_store(store), + events handlers::checkout => envelope, + events handlers::seat => envelope, + )) } pub fn projects(event_type: &str) -> bool { diff --git a/tests/distributed_read_model/seat_inventory_service/handlers/add.rs b/tests/distributed_read_model/seat_inventory_service/handlers/add.rs index 3b5283541..d7507e2bd 100644 --- a/tests/distributed_read_model/seat_inventory_service/handlers/add.rs +++ b/tests/distributed_read_model/seat_inventory_service/handlers/add.rs @@ -6,8 +6,6 @@ use crate::checkout::{json_outbox_event, seat_command, seat_event, AddSeat, Seat use crate::seat_inventory_service::{Seat, SeatRepo}; pub const COMMAND: &str = seat_command::ADD; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["seat_id", "category"]) diff --git a/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs b/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs index 12da3f2fc..5e92680f6 100644 --- a/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs +++ b/tests/distributed_read_model/seat_inventory_service/handlers/reserve_started_checkout_seat.rs @@ -9,8 +9,6 @@ use crate::checkout::{ use crate::seat_inventory_service::SeatRepo; pub const EVENT: &str = checkout_event::STARTED; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::event(EVENT); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["checkout_id", "seat_id", "seat_category"]) diff --git a/tests/distributed_read_model/seat_inventory_service/service.rs b/tests/distributed_read_model/seat_inventory_service/service.rs index c822f33f8..19380aaae 100644 --- a/tests/distributed_read_model/seat_inventory_service/service.rs +++ b/tests/distributed_read_model/seat_inventory_service/service.rs @@ -5,10 +5,9 @@ use sourced_rust::microsvc::Service; use super::{handlers, SeatRepo}; pub fn service(repo: SeatRepo) -> Arc> { - let service = sourced_rust::register_handlers!(Service::with_repo(repo), handlers::add); - Arc::new(service.handler( - handlers::reserve_started_checkout_seat::SPEC, - handlers::reserve_started_checkout_seat::guard, - handlers::reserve_started_checkout_seat::handle, + Arc::new(sourced_rust::register_handlers!( + Service::with_repo(repo), + handlers::add, + event handlers::reserve_started_checkout_seat, )) } diff --git a/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs index 0cec960cc..4333a4921 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs @@ -5,8 +5,6 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{AddCard, Board, BoardRepo}; pub const COMMAND: &str = "board.add_card"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "card_id", "column", "title"]) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs index aefd88492..4bca748d0 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs @@ -5,8 +5,6 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{Board, BoardRepo, MoveCard}; pub const COMMAND: &str = "board.move_card"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "card_id", "column"]) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_open.rs b/tests/distributed_read_model_board/board_service/handlers/board_open.rs index e816207e8..9ff5385d5 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_open.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_open.rs @@ -5,8 +5,6 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{Board, BoardRepo, OpenBoard}; pub const COMMAND: &str = "board.open"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "name"]) diff --git a/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs index edd39f599..ecc7b550d 100644 --- a/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs +++ b/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs @@ -5,8 +5,6 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::board_service::{Board, BoardRepo, RemoveCard}; pub const COMMAND: &str = "board.remove_card"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "card_id"]) diff --git a/tests/distributed_read_model_board/projections_service/handlers/board.rs b/tests/distributed_read_model_board/projections_service/handlers/board.rs index dd9068122..663a04b0d 100644 --- a/tests/distributed_read_model_board/projections_service/handlers/board.rs +++ b/tests/distributed_read_model_board/projections_service/handlers/board.rs @@ -19,8 +19,6 @@ pub const EVENTS: &[&str] = &[ "board.card_moved", "board.card_removed", ]; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::events(EVENTS).envelope(); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["id", "name", "payload"]) diff --git a/tests/distributed_read_model_board/projections_service/mod.rs b/tests/distributed_read_model_board/projections_service/mod.rs index 0e0d2b9c1..75f0f42d4 100644 --- a/tests/distributed_read_model_board/projections_service/mod.rs +++ b/tests/distributed_read_model_board/projections_service/mod.rs @@ -29,10 +29,9 @@ pub fn start_board_projection_service( } pub fn service(store: InMemoryReadModelStore) -> Arc> { - Arc::new(Service::with_read_model_store(store).handler( - handlers::board::SPEC, - handlers::board::guard, - handlers::board::handle, + Arc::new(sourced_rust::register_handlers!( + Service::with_read_model_store(store), + events handlers::board => envelope, )) } diff --git a/tests/microsvc/convention.rs b/tests/microsvc/convention.rs index c391b0601..4770bc65f 100644 --- a/tests/microsvc/convention.rs +++ b/tests/microsvc/convention.rs @@ -2,7 +2,6 @@ //! //! Each handler lives in its own file under `handlers/` and exports: //! - `COMMAND: &str` — the command name -//! - `SPEC: HandlerSpec` — the handler's command/event metadata //! - `guard(ctx) -> bool` — input validation //! - `handle(ctx) -> Result` — the handler //! diff --git a/tests/microsvc/handlers/counter_create.rs b/tests/microsvc/handlers/counter_create.rs index e115e7def..90db1dbcb 100644 --- a/tests/microsvc/handlers/counter_create.rs +++ b/tests/microsvc/handlers/counter_create.rs @@ -14,8 +14,6 @@ use super::Repo; use crate::models::counter::Counter; pub const COMMAND: &str = "counter.create"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); #[derive(Deserialize)] pub struct Input { diff --git a/tests/microsvc/handlers/counter_increment.rs b/tests/microsvc/handlers/counter_increment.rs index 5b4583983..3c369435d 100644 --- a/tests/microsvc/handlers/counter_increment.rs +++ b/tests/microsvc/handlers/counter_increment.rs @@ -9,8 +9,6 @@ use super::Repo; use crate::models::counter::Counter; pub const COMMAND: &str = "counter.increment"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); #[derive(Deserialize)] pub struct Input { diff --git a/tests/microsvc/handlers/whoami.rs b/tests/microsvc/handlers/whoami.rs index ebd5204be..e811c481d 100644 --- a/tests/microsvc/handlers/whoami.rs +++ b/tests/microsvc/handlers/whoami.rs @@ -8,8 +8,6 @@ use sourced_rust::microsvc::{Context, HandlerError}; use super::Repo; pub const COMMAND: &str = "whoami"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(_ctx: &Context) -> bool { true diff --git a/tests/sagas/handlers/inventory/init.rs b/tests/sagas/handlers/inventory/init.rs index ea8905b56..7f3279470 100644 --- a/tests/sagas/handlers/inventory/init.rs +++ b/tests/sagas/handlers/inventory/init.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "InitInventory"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["sku", "stock"]) diff --git a/tests/sagas/handlers/inventory/reserve.rs b/tests/sagas/handlers/inventory/reserve.rs index 7270f1bb7..3353620c3 100644 --- a/tests/sagas/handlers/inventory/reserve.rs +++ b/tests/sagas/handlers/inventory/reserve.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "ReserveInventory"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "sku", "quantity"]) diff --git a/tests/sagas/handlers/orders/complete.rs b/tests/sagas/handlers/orders/complete.rs index 9777b1a02..697c2cda9 100644 --- a/tests/sagas/handlers/orders/complete.rs +++ b/tests/sagas/handlers/orders/complete.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "CompleteOrder"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/orders/create.rs b/tests/sagas/handlers/orders/create.rs index 56d14b001..ed193c557 100644 --- a/tests/sagas/handlers/orders/create.rs +++ b/tests/sagas/handlers/orders/create.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "CreateOrder"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "customer_id", "items"]) diff --git a/tests/sagas/handlers/payments/process.rs b/tests/sagas/handlers/payments/process.rs index d9b814adc..695383d1b 100644 --- a/tests/sagas/handlers/payments/process.rs +++ b/tests/sagas/handlers/payments/process.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "ProcessPayment"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "amount_cents"]) diff --git a/tests/sagas/handlers/saga/on_inventory_reserved.rs b/tests/sagas/handlers/saga/on_inventory_reserved.rs index abe5c4eef..b1cfb1787 100644 --- a/tests/sagas/handlers/saga/on_inventory_reserved.rs +++ b/tests/sagas/handlers/saga/on_inventory_reserved.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "InventoryReserved"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/on_order_completed.rs b/tests/sagas/handlers/saga/on_order_completed.rs index 796044b0d..f64655725 100644 --- a/tests/sagas/handlers/saga/on_order_completed.rs +++ b/tests/sagas/handlers/saga/on_order_completed.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "OrderCompleted"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/on_order_created.rs b/tests/sagas/handlers/saga/on_order_created.rs index 44465d9d7..28f5d3a72 100644 --- a/tests/sagas/handlers/saga/on_order_created.rs +++ b/tests/sagas/handlers/saga/on_order_created.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "OrderCreated"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/on_payment_succeeded.rs b/tests/sagas/handlers/saga/on_payment_succeeded.rs index 98a32304a..4609a8710 100644 --- a/tests/sagas/handlers/saga/on_payment_succeeded.rs +++ b/tests/sagas/handlers/saga/on_payment_succeeded.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "PaymentSucceeded"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/handlers/saga/start.rs b/tests/sagas/handlers/saga/start.rs index e94645a3c..89983fb3f 100644 --- a/tests/sagas/handlers/saga/start.rs +++ b/tests/sagas/handlers/saga/start.rs @@ -1,8 +1,6 @@ use super::*; pub const COMMAND: &str = "StartSaga"; -pub const SPEC: sourced_rust::microsvc::HandlerSpec = - sourced_rust::microsvc::HandlerSpec::command(COMMAND); pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id", "customer_id", "items", "total_cents"]) From 19710aeca5c6248bd6620ce90710f5298a66fec7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 14:24:07 -0500 Subject: [PATCH 03/12] refactor: remove microsvc command registration aliases Drops the compatibility Service::command, Service::command_guarded, and Service::commands APIs and updates tests/docs to use HandlerSpec registration. --- src/microsvc/grpc.rs | 8 +- src/microsvc/http.rs | 8 +- src/microsvc/mod.rs | 9 +-- src/microsvc/service.rs | 143 +++++++++++++++++++---------------- tests/microsvc/basic.rs | 70 ++++++++++------- tests/microsvc/convention.rs | 2 +- tests/microsvc/session.rs | 26 ++++--- 7 files changed, 149 insertions(+), 117 deletions(-) diff --git a/src/microsvc/grpc.rs b/src/microsvc/grpc.rs index 6ea2c6c32..300ea6898 100644 --- a/src/microsvc/grpc.rs +++ b/src/microsvc/grpc.rs @@ -15,8 +15,10 @@ //! use sourced_rust::{microsvc, HashMapRepository}; //! //! let service = Arc::new( -//! microsvc::Service::with_repo(HashMapRepository::new()) -//! .command("counter.create", |ctx| { /* ... */ }) +//! sourced_rust::register_handlers!( +//! microsvc::Service::with_repo(HashMapRepository::new()), +//! handlers::counter_create, +//! ) //! ); //! //! // Get the server to compose with other tonic routes @@ -180,7 +182,7 @@ impl CommandService for GrpcHandler { ) -> Result, Status> { let commands: Vec = self .service - .commands() + .command_names() .into_iter() .map(|s| s.to_string()) .collect(); diff --git a/src/microsvc/http.rs b/src/microsvc/http.rs index 5c4e2f323..4889187e3 100644 --- a/src/microsvc/http.rs +++ b/src/microsvc/http.rs @@ -14,8 +14,10 @@ //! use sourced_rust::{microsvc, HashMapRepository}; //! //! let service = Arc::new( -//! microsvc::Service::with_repo(HashMapRepository::new()) -//! .command("counter.create", |ctx| { /* ... */ }) +//! sourced_rust::register_handlers!( +//! microsvc::Service::with_repo(HashMapRepository::new()), +//! handlers::counter_create, +//! ) //! ); //! //! // Get the router to compose with other axum routes @@ -60,7 +62,7 @@ pub async fn serve( async fn health_handler( State(service): State>>, ) -> impl IntoResponse { - let commands: Vec<&str> = service.commands(); + let commands: Vec<&str> = service.command_names(); Json(json!({ "ok": true, "commands": commands })) } diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 96ba9027d..6e4d1b3bd 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -12,11 +12,10 @@ //! use serde_json::json; //! //! let service = Arc::new( -//! microsvc::Service::with_repo(HashMapRepository::new()) -//! .command("order.create", |ctx| { -//! let input = ctx.input::()?; -//! Ok(json!({ "id": input.id })) -//! }) +//! sourced_rust::register_handlers!( +//! microsvc::Service::with_repo(HashMapRepository::new()), +//! handlers::order_create, +//! ) //! ); //! //! // Direct dispatch diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index 81ec1a8b6..f22b89c12 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -9,8 +9,8 @@ //! use sourced_rust::microsvc; //! use serde_json::json; //! -//! let service = microsvc::in_memory() -//! .command("order.create", |ctx| { +//! let service = microsvc::Service::new(()) +//! .handler(microsvc::HandlerSpec::command("order.create"), |_| true, |ctx| { //! let input = ctx.input::()?; //! Ok(json!({ "id": input.id })) //! }); @@ -235,32 +235,6 @@ impl Service { Self::new(read_model_store) } - /// Register a command handler. - /// - /// Uses builder pattern — returns `self` for chaining. - pub fn command(self, name: &'static str, handler: F) -> Self - where - F: Fn(&Context) -> Result + Send + Sync + 'static, - { - self.register_handler(HandlerSpec::command(name), None, Arc::new(handler)) - } - - /// Register a command handler with a guard function. - /// - /// The guard is called before the handler. If it returns `false`, - /// the command is rejected with `HandlerError::GuardRejected`. - pub fn command_guarded(self, name: &'static str, guard: G, handler: F) -> Self - where - G: Fn(&Context) -> bool + Send + Sync + 'static, - F: Fn(&Context) -> Result + Send + Sync + 'static, - { - self.register_handler( - HandlerSpec::command(name), - Some(Arc::new(guard)), - Arc::new(handler), - ) - } - /// Register a handler from a transport-visible spec. pub fn handler(self, spec: HandlerSpec, guard: G, handler: F) -> Self where @@ -349,11 +323,6 @@ impl Service { self.dispatch_message(&MessageEnvelope::from(event)) } - /// List registered command names. - pub fn commands(&self) -> Vec<&str> { - self.command_names() - } - /// List registered command names. pub fn command_names(&self) -> Vec<&str> { names_by_kind(&self.handler_specs, MessageKind::Command) @@ -505,8 +474,10 @@ impl Drop for TransportHandle { /// use sourced_rust::bus::{InMemoryQueue, Sender, Event}; /// /// let service = Arc::new( -/// microsvc::in_memory() -/// .command("counter.create", handlers::counter_create::handle) +/// sourced_rust::register_handlers!( +/// microsvc::Service::with_repo(repo), +/// handlers::counter_create, +/// ) /// ); /// /// let queue = InMemoryQueue::new(); @@ -588,8 +559,10 @@ where /// use sourced_rust::bus::InMemoryQueue; /// /// let service = Arc::new( -/// microsvc::in_memory() -/// .command("order.created", handlers::on_order_created::handle) +/// sourced_rust::register_handlers!( +/// microsvc::Service::new(()), +/// event handlers::on_order_created, +/// ) /// ); /// /// let queue = InMemoryQueue::new(); @@ -706,22 +679,30 @@ mod tests { #[test] fn dispatch_returns_handler_result() { - let service = test_service().command("ping", |_ctx| Ok(json!({ "pong": true }))); + let service = test_service().handler( + HandlerSpec::command("ping"), + |_| true, + |_ctx| Ok(json!({ "pong": true })), + ); let result = service.dispatch("ping", json!({}), Session::new()).unwrap(); assert_eq!(result, json!({ "pong": true })); } #[test] fn unknown_command() { - let service = test_service().command("ping", |_ctx| Ok(json!({}))); + let service = + test_service().handler(HandlerSpec::command("ping"), |_| true, |_ctx| Ok(json!({}))); let result = service.dispatch("unknown", json!({}), Session::new()); assert!(matches!(result, Err(HandlerError::UnknownCommand(ref s)) if s == "unknown")); } #[test] fn handler_error_propagates() { - let service = - test_service().command("fail", |_ctx| Err(HandlerError::Rejected("nope".into()))); + let service = test_service().handler( + HandlerSpec::command("fail"), + |_| true, + |_ctx| Err(HandlerError::Rejected("nope".into())), + ); let result = service.dispatch("fail", json!({}), Session::new()); assert!(matches!(result, Err(HandlerError::Rejected(ref s)) if s == "nope")); } @@ -733,20 +714,24 @@ mod tests { _name: String, } - let service = test_service().command("typed", |ctx| { - let _input = ctx.input::()?; - Ok(json!({})) - }); + let service = test_service().handler( + HandlerSpec::command("typed"), + |_| true, + |ctx| { + let _input = ctx.input::()?; + Ok(json!({})) + }, + ); let result = service.dispatch("typed", json!({ "wrong": 1 }), Session::new()); assert!(matches!(result, Err(HandlerError::DecodeFailed(_)))); } #[test] - fn commands_list() { + fn command_names_list() { let service = test_service() - .command("a", |_| Ok(json!({}))) - .command("b", |_| Ok(json!({}))); - let mut cmds = service.commands(); + .handler(HandlerSpec::command("a"), |_| true, |_| Ok(json!({}))) + .handler(HandlerSpec::command("b"), |_| true, |_| Ok(json!({}))); + let mut cmds = service.command_names(); cmds.sort(); assert_eq!(cmds, vec!["a", "b"]); } @@ -756,7 +741,11 @@ mod tests { const EVENTS: &[&str] = &["checkout.started", "seat.reserved"]; let service = test_service() - .command("checkout.start", |_| Ok(json!({}))) + .handler( + HandlerSpec::command("checkout.start"), + |_| true, + |_| Ok(json!({})), + ) .handler( HandlerSpec::events(EVENTS).envelope(), |_| true, @@ -838,8 +827,8 @@ mod tests { #[test] fn guard_passes() { - let service = test_service().command_guarded( - "greet", + let service = test_service().handler( + HandlerSpec::command("greet"), |ctx| ctx.has_fields(&["name"]), |ctx| { let name = ctx.raw_input()["name"].as_str().unwrap(); @@ -854,8 +843,8 @@ mod tests { #[test] fn guard_rejects() { - let service = test_service().command_guarded( - "greet", + let service = test_service().handler( + HandlerSpec::command("greet"), |ctx| ctx.has_fields(&["name"]), |_ctx| panic!("handler should not run"), ); @@ -865,8 +854,8 @@ mod tests { #[test] fn guard_checks_session() { - let service = test_service().command_guarded( - "admin", + let service = test_service().handler( + HandlerSpec::command("admin"), |ctx| ctx.role() == Some("admin"), |_ctx| Ok(json!({ "ok": true })), ); @@ -884,7 +873,11 @@ mod tests { #[test] fn dispatch_request_success() { - let service = test_service().command("ping", |_ctx| Ok(json!({ "pong": true }))); + let service = test_service().handler( + HandlerSpec::command("ping"), + |_| true, + |_ctx| Ok(json!({ "pong": true })), + ); let request = CommandRequest { command: "ping".to_string(), input: json!({}), @@ -898,11 +891,19 @@ mod tests { #[test] fn dispatch_request_error_codes() { let service = test_service() - .command("reject", |_| Err(HandlerError::Rejected("no".into()))) - .command("unauth", |ctx| { - let _ = ctx.user_id()?; - Ok(json!({})) - }); + .handler( + HandlerSpec::command("reject"), + |_| true, + |_| Err(HandlerError::Rejected("no".into())), + ) + .handler( + HandlerSpec::command("unauth"), + |_| true, + |ctx| { + let _ = ctx.user_id()?; + Ok(json!({})) + }, + ); let resp = service.dispatch_request(&CommandRequest { command: "unknown".to_string(), @@ -928,10 +929,14 @@ mod tests { #[test] fn dispatch_request_passes_session() { - let service = test_service().command("whoami", |ctx| { - let user_id = ctx.user_id()?; - Ok(json!({ "user_id": user_id })) - }); + let service = test_service().handler( + HandlerSpec::command("whoami"), + |_| true, + |ctx| { + let user_id = ctx.user_id()?; + Ok(json!({ "user_id": user_id })) + }, + ); let mut vars = HashMap::new(); vars.insert("x-hasura-user-id".to_string(), "user-99".to_string()); let request = CommandRequest { @@ -954,7 +959,11 @@ mod tests { #[cfg(feature = "bus")] #[test] fn dispatch_event_rejects_non_json_payload() { - let service = test_service().command("ping", |_ctx| Ok(json!({ "ok": true }))); + let service = test_service().handler( + HandlerSpec::command("ping"), + |_| true, + |_ctx| Ok(json!({ "ok": true })), + ); let event = crate::bus::Event::with_string_payload("evt-1", "ping", "not-json"); let result = service.dispatch_event(&event); assert!(matches!(result, Err(HandlerError::DecodeFailed(_)))); diff --git a/tests/microsvc/basic.rs b/tests/microsvc/basic.rs index cd0403b98..41e35d055 100644 --- a/tests/microsvc/basic.rs +++ b/tests/microsvc/basic.rs @@ -1,7 +1,7 @@ //! Basic microsvc integration tests — exercises dispatch with a real repository. use serde_json::json; -use sourced_rust::microsvc::{HandlerError, Service, Session}; +use sourced_rust::microsvc::{HandlerError, HandlerSpec, Service, Session}; use sourced_rust::{AggregateBuilder, HashMapRepository}; use crate::models::counter::{Counter, CreateCounter, DecrementCounter, IncrementCounter}; @@ -9,34 +9,46 @@ use crate::models::counter::{Counter, CreateCounter, DecrementCounter, Increment #[test] fn full_lifecycle() { let service = Service::with_repo(HashMapRepository::new()) - .command("counter.create", |ctx| { - let input = ctx.input::()?; - let counter_repo = ctx.repo().clone().aggregate::(); - let mut counter = Counter::default(); - counter.create(input.id.clone())?; - counter_repo.commit(&mut counter)?; - Ok(json!({ "id": input.id })) - }) - .command("counter.increment", |ctx| { - let input = ctx.input::()?; - let counter_repo = ctx.repo().clone().aggregate::(); - let mut counter: Counter = counter_repo - .get(&input.id)? - .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.increment(input.amount)?; - counter_repo.commit(&mut counter)?; - Ok(json!({ "value": counter.value })) - }) - .command("counter.decrement", |ctx| { - let input = ctx.input::()?; - let counter_repo = ctx.repo().clone().aggregate::(); - let mut counter: Counter = counter_repo - .get(&input.id)? - .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.decrement(input.amount)?; - counter_repo.commit(&mut counter)?; - Ok(json!({ "value": counter.value })) - }); + .handler( + HandlerSpec::command("counter.create"), + |_| true, + |ctx| { + let input = ctx.input::()?; + let counter_repo = ctx.repo().clone().aggregate::(); + let mut counter = Counter::default(); + counter.create(input.id.clone())?; + counter_repo.commit(&mut counter)?; + Ok(json!({ "id": input.id })) + }, + ) + .handler( + HandlerSpec::command("counter.increment"), + |_| true, + |ctx| { + let input = ctx.input::()?; + let counter_repo = ctx.repo().clone().aggregate::(); + let mut counter: Counter = counter_repo + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + counter.increment(input.amount)?; + counter_repo.commit(&mut counter)?; + Ok(json!({ "value": counter.value })) + }, + ) + .handler( + HandlerSpec::command("counter.decrement"), + |_| true, + |ctx| { + let input = ctx.input::()?; + let counter_repo = ctx.repo().clone().aggregate::(); + let mut counter: Counter = counter_repo + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + counter.decrement(input.amount)?; + counter_repo.commit(&mut counter)?; + Ok(json!({ "value": counter.value })) + }, + ); // Create let result = service diff --git a/tests/microsvc/convention.rs b/tests/microsvc/convention.rs index 4770bc65f..1eab23511 100644 --- a/tests/microsvc/convention.rs +++ b/tests/microsvc/convention.rs @@ -26,7 +26,7 @@ fn register_handlers_and_dispatch() { handlers::counter_increment, ); - let mut cmds = service.commands(); + let mut cmds = service.command_names(); cmds.sort(); assert_eq!(cmds, vec!["counter.create", "counter.increment"]); diff --git a/tests/microsvc/session.rs b/tests/microsvc/session.rs index 2ad591297..fd821e33d 100644 --- a/tests/microsvc/session.rs +++ b/tests/microsvc/session.rs @@ -1,15 +1,19 @@ //! Session integration tests — exercises session variables through dispatch. use serde_json::json; -use sourced_rust::microsvc::{HandlerError, Service, Session}; +use sourced_rust::microsvc::{HandlerError, HandlerSpec, Service, Session}; use std::collections::HashMap; #[test] fn handler_accesses_user_id() { - let service = Service::new(()).command("whoami", |ctx| { - let user_id = ctx.user_id()?; - Ok(json!({ "user_id": user_id })) - }); + let service = Service::new(()).handler( + HandlerSpec::command("whoami"), + |_| true, + |ctx| { + let user_id = ctx.user_id()?; + Ok(json!({ "user_id": user_id })) + }, + ); let mut vars = HashMap::new(); vars.insert("x-hasura-user-id".to_string(), "user-42".to_string()); @@ -21,10 +25,14 @@ fn handler_accesses_user_id() { #[test] fn missing_user_id_returns_unauthorized() { - let service = Service::new(()).command("whoami", |ctx| { - let _user_id = ctx.user_id()?; - Ok(json!({})) - }); + let service = Service::new(()).handler( + HandlerSpec::command("whoami"), + |_| true, + |ctx| { + let _user_id = ctx.user_id()?; + Ok(json!({})) + }, + ); let result = service.dispatch("whoami", json!({}), Session::new()); assert!(matches!(result, Err(HandlerError::Unauthorized(_)))); From e6cf9ca2afa73864ed8886a739b81220d1e3bed5 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 14:57:14 -0500 Subject: [PATCH 04/12] feat: add microsvc fluent handler registration Adds HandlerBuilder so command/event registration can use .handle(...) or .guarded(...), with envelope selection before registration. --- src/microsvc/grpc.rs | 7 +- src/microsvc/http.rs | 7 +- src/microsvc/mod.rs | 34 +++--- src/microsvc/service.rs | 233 ++++++++++++++++++++++---------------- tests/microsvc/basic.rs | 73 ++++++------ tests/microsvc/session.rs | 26 ++--- 6 files changed, 200 insertions(+), 180 deletions(-) diff --git a/src/microsvc/grpc.rs b/src/microsvc/grpc.rs index 300ea6898..7e45db362 100644 --- a/src/microsvc/grpc.rs +++ b/src/microsvc/grpc.rs @@ -15,10 +15,9 @@ //! use sourced_rust::{microsvc, HashMapRepository}; //! //! let service = Arc::new( -//! sourced_rust::register_handlers!( -//! microsvc::Service::with_repo(HashMapRepository::new()), -//! handlers::counter_create, -//! ) +//! microsvc::Service::with_repo(HashMapRepository::new()) +//! .command("counter.create") +//! .handle(|ctx| { /* ... */ }) //! ); //! //! // Get the server to compose with other tonic routes diff --git a/src/microsvc/http.rs b/src/microsvc/http.rs index 4889187e3..111a01bad 100644 --- a/src/microsvc/http.rs +++ b/src/microsvc/http.rs @@ -14,10 +14,9 @@ //! use sourced_rust::{microsvc, HashMapRepository}; //! //! let service = Arc::new( -//! sourced_rust::register_handlers!( -//! microsvc::Service::with_repo(HashMapRepository::new()), -//! handlers::counter_create, -//! ) +//! microsvc::Service::with_repo(HashMapRepository::new()) +//! .command("counter.create") +//! .handle(|ctx| { /* ... */ }) //! ); //! //! // Get the router to compose with other axum routes diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 6e4d1b3bd..11afe47a0 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -1,6 +1,6 @@ //! microsvc — Convention-based microservice command handler framework. //! -//! Build microservices by registering command handlers on a `Service`. +//! Build microservices by registering command and event handlers on a `Service`. //! Each handler receives a `Context` with access to the input payload, //! session variables, and the service dependencies. //! @@ -12,10 +12,12 @@ //! use serde_json::json; //! //! let service = Arc::new( -//! sourced_rust::register_handlers!( -//! microsvc::Service::with_repo(HashMapRepository::new()), -//! handlers::order_create, -//! ) +//! microsvc::Service::with_repo(HashMapRepository::new()) +//! .command("order.create") +//! .handle(|ctx| { +//! let input = ctx.input::()?; +//! Ok(json!({ "id": input.id })) +//! }) //! ); //! //! // Direct dispatch @@ -64,8 +66,8 @@ pub use dependencies::{ }; pub use error::HandlerError; pub use service::{ - CommandRequest, CommandResponse, DeliveryKind, HandlerInput, HandlerNames, HandlerSpec, - MessageEnvelope, MessageKind, Service, SubscriptionPlan, + CommandRequest, CommandResponse, DeliveryKind, HandlerBuilder, HandlerInput, HandlerNames, + HandlerSpec, MessageEnvelope, MessageKind, Service, SubscriptionPlan, }; pub use session::Session; @@ -122,8 +124,7 @@ macro_rules! register_handlers { macro_rules! __register_handlers { ($service:expr, command $($seg:ident)::+ $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( - $service.handler( - $crate::microsvc::HandlerSpec::command($($seg)::+::COMMAND), + $service.command($($seg)::+::COMMAND).guarded( $($seg)::+::guard, $($seg)::+::handle, ) @@ -132,8 +133,7 @@ macro_rules! __register_handlers { }; ($service:expr, event $($seg:ident)::+ => envelope $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( - $service.handler( - $crate::microsvc::HandlerSpec::event($($seg)::+::EVENT).envelope(), + $service.event($($seg)::+::EVENT).envelope().guarded( $($seg)::+::guard, $($seg)::+::handle, ) @@ -142,8 +142,7 @@ macro_rules! __register_handlers { }; ($service:expr, events $($seg:ident)::+ => envelope $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( - $service.handler( - $crate::microsvc::HandlerSpec::events($($seg)::+::EVENTS).envelope(), + $service.events($($seg)::+::EVENTS).envelope().guarded( $($seg)::+::guard, $($seg)::+::handle, ) @@ -152,8 +151,7 @@ macro_rules! __register_handlers { }; ($service:expr, event $($seg:ident)::+ $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( - $service.handler( - $crate::microsvc::HandlerSpec::event($($seg)::+::EVENT), + $service.event($($seg)::+::EVENT).guarded( $($seg)::+::guard, $($seg)::+::handle, ) @@ -162,8 +160,7 @@ macro_rules! __register_handlers { }; ($service:expr, events $($seg:ident)::+ $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( - $service.handler( - $crate::microsvc::HandlerSpec::events($($seg)::+::EVENTS), + $service.events($($seg)::+::EVENTS).guarded( $($seg)::+::guard, $($seg)::+::handle, ) @@ -172,8 +169,7 @@ macro_rules! __register_handlers { }; ($service:expr, $($seg:ident)::+ $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( - $service.handler( - $crate::microsvc::HandlerSpec::command($($seg)::+::COMMAND), + $service.command($($seg)::+::COMMAND).guarded( $($seg)::+::guard, $($seg)::+::handle, ) diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index f22b89c12..75c252658 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -1,6 +1,6 @@ -//! Service — command handler registry and dispatch for microsvc. +//! Service — handler registry and dispatch for microsvc. //! -//! `Service` holds service dependencies and a set of named command handlers. +//! `Service` holds service dependencies and a set of named command/event handlers. //! Each handler receives a `Context` and returns `Result`. //! //! ## Example @@ -10,7 +10,8 @@ //! use serde_json::json; //! //! let service = microsvc::Service::new(()) -//! .handler(microsvc::HandlerSpec::command("order.create"), |_| true, |ctx| { +//! .command("order.create") +//! .handle(|ctx| { //! let input = ctx.input::()?; //! Ok(json!({ "id": input.id })) //! }); @@ -191,13 +192,53 @@ impl From for Event { } } -/// A registered command handler with optional guard. -struct CommandHandler { +/// A registered handler with optional guard. +struct RegisteredHandler { input: HandlerInput, guard: Option>>, handle: Arc>, } +/// Builder returned by [`Service::command`], [`Service::event`], +/// [`Service::events`], and [`Service::handler`]. +pub struct HandlerBuilder { + service: Service, + spec: HandlerSpec, +} + +impl HandlerBuilder { + /// Deliver the full message envelope to the handler. + pub fn envelope(mut self) -> Self { + self.spec = self.spec.envelope(); + self + } + + /// Deliver only the message JSON payload to the handler. + pub fn payload_json(mut self) -> Self { + self.spec = self.spec.payload_json(); + self + } + + /// Register a handler without a guard. + pub fn handle(self, handler: F) -> Service + where + F: Fn(&Context) -> Result + Send + Sync + 'static, + { + self.service + .register_handler(self.spec, None, Arc::new(handler)) + } + + /// Register a handler with a guard. + pub fn guarded(self, guard: G, handler: F) -> Service + where + G: Fn(&Context) -> bool + Send + Sync + 'static, + F: Fn(&Context) -> Result + Send + Sync + 'static, + { + self.service + .register_handler(self.spec, Some(Arc::new(guard)), Arc::new(handler)) + } +} + /// A microservice that routes commands to handler functions. /// /// Generic over `D`, the service dependency type. Prefer @@ -205,7 +246,7 @@ struct CommandHandler { /// [`Service::with_repo_and_read_model_store`] for common dependency shapes. pub struct Service { dependencies: D, - handlers: HashMap>, + handlers: HashMap>, handler_specs: Vec, } @@ -235,13 +276,28 @@ impl Service { Self::new(read_model_store) } - /// Register a handler from a transport-visible spec. - pub fn handler(self, spec: HandlerSpec, guard: G, handler: F) -> Self - where - G: Fn(&Context) -> bool + Send + Sync + 'static, - F: Fn(&Context) -> Result + Send + Sync + 'static, - { - self.register_handler(spec, Some(Arc::new(guard)), Arc::new(handler)) + /// Start registering a command handler that consumes JSON payload input. + pub fn command(self, name: &'static str) -> HandlerBuilder { + self.handler(HandlerSpec::command(name)) + } + + /// Start registering an event handler that consumes JSON payload input. + pub fn event(self, name: &'static str) -> HandlerBuilder { + self.handler(HandlerSpec::event(name)) + } + + /// Start registering an event handler for several event names that consume JSON + /// payload input. + pub fn events(self, names: &'static [&'static str]) -> HandlerBuilder { + self.handler(HandlerSpec::events(names)) + } + + /// Start registering a handler from a transport-visible spec. + pub fn handler(self, spec: HandlerSpec) -> HandlerBuilder { + HandlerBuilder { + service: self, + spec, + } } fn register_handler( @@ -253,7 +309,7 @@ impl Service { for name in spec.names() { self.handlers.insert( name.to_string(), - CommandHandler { + RegisteredHandler { input: spec.input, guard: guard.clone(), handle: handle.clone(), @@ -679,30 +735,25 @@ mod tests { #[test] fn dispatch_returns_handler_result() { - let service = test_service().handler( - HandlerSpec::command("ping"), - |_| true, - |_ctx| Ok(json!({ "pong": true })), - ); + let service = test_service() + .command("ping") + .handle(|_ctx| Ok(json!({ "pong": true }))); let result = service.dispatch("ping", json!({}), Session::new()).unwrap(); assert_eq!(result, json!({ "pong": true })); } #[test] fn unknown_command() { - let service = - test_service().handler(HandlerSpec::command("ping"), |_| true, |_ctx| Ok(json!({}))); + let service = test_service().command("ping").handle(|_ctx| Ok(json!({}))); let result = service.dispatch("unknown", json!({}), Session::new()); assert!(matches!(result, Err(HandlerError::UnknownCommand(ref s)) if s == "unknown")); } #[test] fn handler_error_propagates() { - let service = test_service().handler( - HandlerSpec::command("fail"), - |_| true, - |_ctx| Err(HandlerError::Rejected("nope".into())), - ); + let service = test_service() + .command("fail") + .handle(|_ctx| Err(HandlerError::Rejected("nope".into()))); let result = service.dispatch("fail", json!({}), Session::new()); assert!(matches!(result, Err(HandlerError::Rejected(ref s)) if s == "nope")); } @@ -714,14 +765,10 @@ mod tests { _name: String, } - let service = test_service().handler( - HandlerSpec::command("typed"), - |_| true, - |ctx| { - let _input = ctx.input::()?; - Ok(json!({})) - }, - ); + let service = test_service().command("typed").handle(|ctx| { + let _input = ctx.input::()?; + Ok(json!({})) + }); let result = service.dispatch("typed", json!({ "wrong": 1 }), Session::new()); assert!(matches!(result, Err(HandlerError::DecodeFailed(_)))); } @@ -729,8 +776,10 @@ mod tests { #[test] fn command_names_list() { let service = test_service() - .handler(HandlerSpec::command("a"), |_| true, |_| Ok(json!({}))) - .handler(HandlerSpec::command("b"), |_| true, |_| Ok(json!({}))); + .command("a") + .handle(|_| Ok(json!({}))) + .command("b") + .handle(|_| Ok(json!({}))); let mut cmds = service.command_names(); cmds.sort(); assert_eq!(cmds, vec!["a", "b"]); @@ -741,16 +790,10 @@ mod tests { const EVENTS: &[&str] = &["checkout.started", "seat.reserved"]; let service = test_service() - .handler( - HandlerSpec::command("checkout.start"), - |_| true, - |_| Ok(json!({})), - ) - .handler( - HandlerSpec::events(EVENTS).envelope(), - |_| true, - |_| Ok(json!({})), - ); + .command("checkout.start") + .handle(|_| Ok(json!({}))) + .handler(HandlerSpec::events(EVENTS).envelope()) + .guarded(|_| true, |_| Ok(json!({}))); assert_eq!( service.subscription_plan(), @@ -762,17 +805,35 @@ mod tests { } #[test] - fn dispatch_message_delivers_payload_json_by_default() { - let service = test_service().handler( - HandlerSpec::event("checkout.started"), - |ctx| ctx.has_fields(&["checkout_id"]), - |ctx| { - Ok(json!({ - "checkout_id": ctx.raw_input()["checkout_id"].as_str().unwrap(), - "user_id": ctx.user_id()?, - })) - }, + fn event_conveniences_record_event_names() { + const EVENTS: &[&str] = &["seat.added", "seat.reserved"]; + + let service = test_service() + .event("checkout.started") + .handle(|_| Ok(json!({}))) + .events(EVENTS) + .handle(|_| Ok(json!({}))); + + let mut events = service.event_names(); + events.sort(); + assert_eq!( + events, + vec!["checkout.started", "seat.added", "seat.reserved"] ); + } + + #[test] + fn dispatch_message_delivers_payload_json_by_default() { + let service = test_service().event("checkout.started").handle(|ctx| { + if !ctx.has_fields(&["checkout_id"]) { + return Err(HandlerError::Rejected("missing checkout_id".into())); + } + + Ok(json!({ + "checkout_id": ctx.raw_input()["checkout_id"].as_str().unwrap(), + "user_id": ctx.user_id()?, + })) + }); let message = MessageEnvelope { id: "evt-1".to_string(), name: "checkout.started".to_string(), @@ -792,8 +853,7 @@ mod tests { #[test] fn dispatch_message_can_deliver_full_envelope() { - let service = test_service().handler( - HandlerSpec::event("seat.reserved").envelope(), + let service = test_service().event("seat.reserved").envelope().guarded( |ctx| ctx.has_fields(&["id", "name", "payload"]), |ctx| { let envelope = ctx.input::()?; @@ -827,8 +887,7 @@ mod tests { #[test] fn guard_passes() { - let service = test_service().handler( - HandlerSpec::command("greet"), + let service = test_service().command("greet").guarded( |ctx| ctx.has_fields(&["name"]), |ctx| { let name = ctx.raw_input()["name"].as_str().unwrap(); @@ -843,8 +902,7 @@ mod tests { #[test] fn guard_rejects() { - let service = test_service().handler( - HandlerSpec::command("greet"), + let service = test_service().command("greet").guarded( |ctx| ctx.has_fields(&["name"]), |_ctx| panic!("handler should not run"), ); @@ -854,8 +912,7 @@ mod tests { #[test] fn guard_checks_session() { - let service = test_service().handler( - HandlerSpec::command("admin"), + let service = test_service().command("admin").guarded( |ctx| ctx.role() == Some("admin"), |_ctx| Ok(json!({ "ok": true })), ); @@ -873,11 +930,9 @@ mod tests { #[test] fn dispatch_request_success() { - let service = test_service().handler( - HandlerSpec::command("ping"), - |_| true, - |_ctx| Ok(json!({ "pong": true })), - ); + let service = test_service() + .command("ping") + .handle(|_ctx| Ok(json!({ "pong": true }))); let request = CommandRequest { command: "ping".to_string(), input: json!({}), @@ -891,19 +946,13 @@ mod tests { #[test] fn dispatch_request_error_codes() { let service = test_service() - .handler( - HandlerSpec::command("reject"), - |_| true, - |_| Err(HandlerError::Rejected("no".into())), - ) - .handler( - HandlerSpec::command("unauth"), - |_| true, - |ctx| { - let _ = ctx.user_id()?; - Ok(json!({})) - }, - ); + .command("reject") + .handle(|_| Err(HandlerError::Rejected("no".into()))) + .command("unauth") + .handle(|ctx| { + let _ = ctx.user_id()?; + Ok(json!({})) + }); let resp = service.dispatch_request(&CommandRequest { command: "unknown".to_string(), @@ -929,14 +978,10 @@ mod tests { #[test] fn dispatch_request_passes_session() { - let service = test_service().handler( - HandlerSpec::command("whoami"), - |_| true, - |ctx| { - let user_id = ctx.user_id()?; - Ok(json!({ "user_id": user_id })) - }, - ); + let service = test_service().command("whoami").handle(|ctx| { + let user_id = ctx.user_id()?; + Ok(json!({ "user_id": user_id })) + }); let mut vars = HashMap::new(); vars.insert("x-hasura-user-id".to_string(), "user-99".to_string()); let request = CommandRequest { @@ -959,11 +1004,9 @@ mod tests { #[cfg(feature = "bus")] #[test] fn dispatch_event_rejects_non_json_payload() { - let service = test_service().handler( - HandlerSpec::command("ping"), - |_| true, - |_ctx| Ok(json!({ "ok": true })), - ); + let service = test_service() + .command("ping") + .handle(|_ctx| Ok(json!({ "ok": true }))); let event = crate::bus::Event::with_string_payload("evt-1", "ping", "not-json"); let result = service.dispatch_event(&event); assert!(matches!(result, Err(HandlerError::DecodeFailed(_)))); diff --git a/tests/microsvc/basic.rs b/tests/microsvc/basic.rs index 41e35d055..141dcbb0b 100644 --- a/tests/microsvc/basic.rs +++ b/tests/microsvc/basic.rs @@ -1,7 +1,7 @@ //! Basic microsvc integration tests — exercises dispatch with a real repository. use serde_json::json; -use sourced_rust::microsvc::{HandlerError, HandlerSpec, Service, Session}; +use sourced_rust::microsvc::{HandlerError, Service, Session}; use sourced_rust::{AggregateBuilder, HashMapRepository}; use crate::models::counter::{Counter, CreateCounter, DecrementCounter, IncrementCounter}; @@ -9,46 +9,37 @@ use crate::models::counter::{Counter, CreateCounter, DecrementCounter, Increment #[test] fn full_lifecycle() { let service = Service::with_repo(HashMapRepository::new()) - .handler( - HandlerSpec::command("counter.create"), - |_| true, - |ctx| { - let input = ctx.input::()?; - let counter_repo = ctx.repo().clone().aggregate::(); - let mut counter = Counter::default(); - counter.create(input.id.clone())?; - counter_repo.commit(&mut counter)?; - Ok(json!({ "id": input.id })) - }, - ) - .handler( - HandlerSpec::command("counter.increment"), - |_| true, - |ctx| { - let input = ctx.input::()?; - let counter_repo = ctx.repo().clone().aggregate::(); - let mut counter: Counter = counter_repo - .get(&input.id)? - .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.increment(input.amount)?; - counter_repo.commit(&mut counter)?; - Ok(json!({ "value": counter.value })) - }, - ) - .handler( - HandlerSpec::command("counter.decrement"), - |_| true, - |ctx| { - let input = ctx.input::()?; - let counter_repo = ctx.repo().clone().aggregate::(); - let mut counter: Counter = counter_repo - .get(&input.id)? - .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.decrement(input.amount)?; - counter_repo.commit(&mut counter)?; - Ok(json!({ "value": counter.value })) - }, - ); + .command("counter.create") + .handle(|ctx| { + let input = ctx.input::()?; + let counter_repo = ctx.repo().clone().aggregate::(); + let mut counter = Counter::default(); + counter.create(input.id.clone())?; + counter_repo.commit(&mut counter)?; + Ok(json!({ "id": input.id })) + }) + .command("counter.increment") + .handle(|ctx| { + let input = ctx.input::()?; + let counter_repo = ctx.repo().clone().aggregate::(); + let mut counter: Counter = counter_repo + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + counter.increment(input.amount)?; + counter_repo.commit(&mut counter)?; + Ok(json!({ "value": counter.value })) + }) + .command("counter.decrement") + .handle(|ctx| { + let input = ctx.input::()?; + let counter_repo = ctx.repo().clone().aggregate::(); + let mut counter: Counter = counter_repo + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + counter.decrement(input.amount)?; + counter_repo.commit(&mut counter)?; + Ok(json!({ "value": counter.value })) + }); // Create let result = service diff --git a/tests/microsvc/session.rs b/tests/microsvc/session.rs index fd821e33d..d48ea188a 100644 --- a/tests/microsvc/session.rs +++ b/tests/microsvc/session.rs @@ -1,19 +1,15 @@ //! Session integration tests — exercises session variables through dispatch. use serde_json::json; -use sourced_rust::microsvc::{HandlerError, HandlerSpec, Service, Session}; +use sourced_rust::microsvc::{HandlerError, Service, Session}; use std::collections::HashMap; #[test] fn handler_accesses_user_id() { - let service = Service::new(()).handler( - HandlerSpec::command("whoami"), - |_| true, - |ctx| { - let user_id = ctx.user_id()?; - Ok(json!({ "user_id": user_id })) - }, - ); + let service = Service::new(()).command("whoami").handle(|ctx| { + let user_id = ctx.user_id()?; + Ok(json!({ "user_id": user_id })) + }); let mut vars = HashMap::new(); vars.insert("x-hasura-user-id".to_string(), "user-42".to_string()); @@ -25,14 +21,10 @@ fn handler_accesses_user_id() { #[test] fn missing_user_id_returns_unauthorized() { - let service = Service::new(()).handler( - HandlerSpec::command("whoami"), - |_| true, - |ctx| { - let _user_id = ctx.user_id()?; - Ok(json!({})) - }, - ); + let service = Service::new(()).command("whoami").handle(|ctx| { + let _user_id = ctx.user_id()?; + Ok(json!({})) + }); let result = service.dispatch("whoami", json!({}), Session::new()); assert!(matches!(result, Err(HandlerError::Unauthorized(_)))); From 6bdf20abd253b930e57c700658f9bcc68e313fdc Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:31:07 -0500 Subject: [PATCH 05/12] refactor: expose messages on microsvc context Removes envelope input modes so handlers always get ctx.message() plus ctx.input::() for JSON payload decoding. --- src/microsvc/context.rs | 35 ++- src/microsvc/mod.rs | 24 +- src/microsvc/service.rs | 293 +++++++++++------- .../projection_service/handlers/checkout.rs | 2 +- .../projection_service/handlers/mod.rs | 4 +- .../projection_service/handlers/seat.rs | 2 +- .../projection_service/service.rs | 4 +- .../projections_service/handlers/board.rs | 2 +- .../projections_service/handlers/mod.rs | 4 +- .../projections_service/mod.rs | 2 +- 10 files changed, 223 insertions(+), 149 deletions(-) diff --git a/src/microsvc/context.rs b/src/microsvc/context.rs index 50c4febd0..43f81aacd 100644 --- a/src/microsvc/context.rs +++ b/src/microsvc/context.rs @@ -1,16 +1,18 @@ -//! Context passed to command handlers. +//! Context passed to handlers. //! -//! Carries the parsed input, session variables, and a reference to the service -//! dependencies. Handlers access everything they need through the context. +//! Carries the message, parsed JSON payload when available, session variables, +//! and a reference to the service dependencies. Handlers access everything they +//! need through the context. use serde::de::DeserializeOwned; use serde_json::Value; use super::dependencies::{HasReadModelStore, HasRepo}; use super::error::HandlerError; +use super::service::Message; use super::session::Session; -/// The context passed to every command handler. +/// The context passed to every handler. /// /// Generic over `D` (the service dependency type) so handlers can access the /// repository, read-model store, or custom dependencies the service is @@ -28,9 +30,9 @@ use super::session::Session; /// } /// ``` pub struct Context<'a, D> { - /// The command name being handled. - command_name: String, - /// Raw JSON input from the request. + /// Message being handled. + message: Message, + /// Raw JSON payload input, when the payload is JSON. input: Value, /// Session variables (user ID, role, etc.). session: Session, @@ -41,13 +43,13 @@ pub struct Context<'a, D> { impl<'a, D> Context<'a, D> { /// Create a new context. pub(crate) fn new( - command_name: String, + message: Message, input: Value, session: Session, dependencies: &'a D, ) -> Self { Self { - command_name, + message, input, session, dependencies, @@ -56,8 +58,7 @@ impl<'a, D> Context<'a, D> { /// Deserialize the input payload into a typed struct. pub fn input(&self) -> Result { - serde_json::from_value(self.input.clone()) - .map_err(|e| HandlerError::DecodeFailed(e.to_string())) + self.message.payload_json() } /// Get the raw JSON input. @@ -67,7 +68,17 @@ impl<'a, D> Context<'a, D> { /// Get the command name. pub fn command_name(&self) -> &str { - &self.command_name + self.message.name() + } + + /// Get the message name. + pub fn message_name(&self) -> &str { + self.message.name() + } + + /// Get the full message, including id, raw payload bytes, and metadata. + pub fn message(&self) -> &Message { + &self.message } /// Get the session. diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 11afe47a0..35d78fc12 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -66,8 +66,8 @@ pub use dependencies::{ }; pub use error::HandlerError; pub use service::{ - CommandRequest, CommandResponse, DeliveryKind, HandlerBuilder, HandlerInput, HandlerNames, - HandlerSpec, MessageEnvelope, MessageKind, Service, SubscriptionPlan, + CommandRequest, CommandResponse, DeliveryKind, HandlerBuilder, HandlerNames, HandlerSpec, + Message, MessageKind, Service, SubscriptionPlan, }; pub use session::Session; @@ -106,7 +106,7 @@ pub use grpc::{grpc_server, serve_grpc, GrpcServeError}; /// handlers::counter_create, /// handlers::counter_increment, /// event handlers::counter_rebuilt, -/// events handlers::counter_projection => envelope, +/// events handlers::counter_projection, /// ); /// ``` #[macro_export] @@ -131,24 +131,6 @@ macro_rules! __register_handlers { $(, $($rest)*)? ) }; - ($service:expr, event $($seg:ident)::+ => envelope $(, $($rest:tt)*)?) => { - $crate::__register_handlers_continue!( - $service.event($($seg)::+::EVENT).envelope().guarded( - $($seg)::+::guard, - $($seg)::+::handle, - ) - $(, $($rest)*)? - ) - }; - ($service:expr, events $($seg:ident)::+ => envelope $(, $($rest:tt)*)?) => { - $crate::__register_handlers_continue!( - $service.events($($seg)::+::EVENTS).envelope().guarded( - $($seg)::+::guard, - $($seg)::+::handle, - ) - $(, $($rest)*)? - ) - }; ($service:expr, event $($seg:ident)::+ $(, $($rest:tt)*)?) => { $crate::__register_handlers_continue!( $service.event($($seg)::+::EVENT).guarded( diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index 75c252658..8d49dc837 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -53,16 +53,6 @@ pub enum DeliveryKind { FanOut, } -/// The input shape delivered to a handler after a message is received. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HandlerInput { - /// Decode the message payload bytes as JSON and pass that JSON value to - /// the handler. This is the default command-style input. - PayloadJson, - /// Pass the full [`MessageEnvelope`] to the handler as JSON. - MessageEnvelope, -} - /// Static message names attached to a handler spec. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HandlerNames { @@ -87,7 +77,6 @@ pub struct HandlerSpec { names: HandlerNames, pub kind: MessageKind, pub delivery: DeliveryKind, - pub input: HandlerInput, } impl HandlerSpec { @@ -97,7 +86,6 @@ impl HandlerSpec { names: HandlerNames::One(name), kind: MessageKind::Command, delivery: DeliveryKind::PointToPoint, - input: HandlerInput::PayloadJson, } } @@ -107,7 +95,6 @@ impl HandlerSpec { names: HandlerNames::One(name), kind: MessageKind::Event, delivery: DeliveryKind::FanOut, - input: HandlerInput::PayloadJson, } } @@ -117,22 +104,9 @@ impl HandlerSpec { names: HandlerNames::Many(names), kind: MessageKind::Event, delivery: DeliveryKind::FanOut, - input: HandlerInput::PayloadJson, } } - /// Deliver the full message envelope to the handler. - pub const fn envelope(mut self) -> Self { - self.input = HandlerInput::MessageEnvelope; - self - } - - /// Deliver only the message JSON payload to the handler. - pub const fn payload_json(mut self) -> Self { - self.input = HandlerInput::PayloadJson; - self - } - /// Message names consumed by this handler. pub fn names(&self) -> Vec<&'static str> { self.names.to_vec() @@ -148,11 +122,10 @@ pub struct SubscriptionPlan { pub events: Vec, } -/// Serializable transport message envelope used by projection-style handlers -/// that need message ids, names, payload bytes, and metadata. +/// Serializable transport message used by handlers. #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] -pub struct MessageEnvelope { - pub id: String, +pub struct Message { + pub id: Option, pub name: String, pub kind: MessageKind, pub payload: Vec, @@ -161,10 +134,10 @@ pub struct MessageEnvelope { } #[cfg(feature = "bus")] -impl From<&Event> for MessageEnvelope { +impl From<&Event> for Message { fn from(event: &Event) -> Self { Self { - id: event.id.clone(), + id: Some(event.id.clone()), name: event.event_type.clone(), kind: MessageKind::Event, payload: event.payload.clone(), @@ -175,26 +148,111 @@ impl From<&Event> for MessageEnvelope { } #[cfg(feature = "bus")] -impl From for Event { - fn from(envelope: MessageEnvelope) -> Self { - let metadata = if envelope.metadata.is_empty() { +impl TryFrom<&Message> for Event { + type Error = HandlerError; + + fn try_from(message: &Message) -> Result { + let id = message + .id + .clone() + .ok_or_else(|| HandlerError::Rejected("message id is required".into()))?; + let metadata = if message.metadata.is_empty() { None } else { - Some(envelope.metadata) + Some(message.metadata.clone()) }; - Self { - id: envelope.id, - event_type: envelope.name, - payload: envelope.payload, + Ok(Self { + id, + event_type: message.name.clone(), + payload: message.payload.clone(), metadata, + }) + } +} + +impl Message { + /// Create a transport message. + pub fn new(name: impl Into, kind: MessageKind, payload: Vec) -> Self { + Self { + id: None, + name: name.into(), + kind, + payload, + content_type: "application/json".to_string(), + metadata: Vec::new(), } } + + /// Add a durable message id. + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Add metadata. + pub fn with_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.push((key.into(), value.into())); + self + } + + /// Get the durable message id, if this message has one. + pub fn id(&self) -> Option<&str> { + self.id.as_deref() + } + + /// Get the message name. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the raw payload bytes. + pub fn payload(&self) -> &[u8] { + &self.payload + } + + /// Get a metadata value by key. + pub fn metadata(&self, key: &str) -> Option<&str> { + self.metadata + .iter() + .find(|(existing, _)| existing == key) + .map(|(_, value)| value.as_str()) + } + + /// Get the correlation id, if present. + pub fn correlation_id(&self) -> Option<&str> { + self.metadata("correlation_id") + } + + /// Get the causation id, if present. + pub fn causation_id(&self) -> Option<&str> { + self.metadata("causation_id") + } + + /// Decode the raw payload as JSON. + pub fn payload_json(&self) -> Result { + serde_json::from_slice(&self.payload).map_err(|e| { + HandlerError::DecodeFailed(format!( + "invalid JSON payload for message '{}': {}", + self.name, e + )) + }) + } + + /// Decode the raw payload as bitcode. + pub fn payload_bitcode(&self) -> Result { + bitcode::deserialize(&self.payload).map_err(|e| { + HandlerError::DecodeFailed(format!( + "invalid bitcode payload for message '{}': {}", + self.name, e + )) + }) + } } /// A registered handler with optional guard. struct RegisteredHandler { - input: HandlerInput, + kind: MessageKind, guard: Option>>, handle: Arc>, } @@ -207,18 +265,6 @@ pub struct HandlerBuilder { } impl HandlerBuilder { - /// Deliver the full message envelope to the handler. - pub fn envelope(mut self) -> Self { - self.spec = self.spec.envelope(); - self - } - - /// Deliver only the message JSON payload to the handler. - pub fn payload_json(mut self) -> Self { - self.spec = self.spec.payload_json(); - self - } - /// Register a handler without a guard. pub fn handle(self, handler: F) -> Service where @@ -310,7 +356,7 @@ impl Service { self.handlers.insert( name.to_string(), RegisteredHandler { - input: spec.input, + kind: spec.kind, guard: guard.clone(), handle: handle.clone(), }, @@ -330,21 +376,29 @@ impl Service { input: Value, session: Session, ) -> Result { - let handler = self + let kind = self .handlers .get(command) - .ok_or_else(|| HandlerError::UnknownCommand(command.to_string()))?; - - let ctx = Context::new(command.to_string(), input, session, &self.dependencies); - - // Run guard if present - if let Some(guard) = &handler.guard { - if !guard(&ctx) { - return Err(HandlerError::GuardRejected(command.to_string())); - } - } + .ok_or_else(|| HandlerError::UnknownCommand(command.to_string()))? + .kind; + let payload = serde_json::to_vec(&input).map_err(|e| { + HandlerError::DecodeFailed(format!("invalid JSON input for command '{command}': {e}")) + })?; + let metadata = session + .variables() + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let message = Message { + id: None, + name: command.to_string(), + kind, + payload, + content_type: "application/json".to_string(), + metadata, + }; - (handler.handle.as_ref())(&ctx) + self.invoke(message, input, session) } /// Dispatch a `CommandRequest`, returning a `CommandResponse`. @@ -363,20 +417,46 @@ impl Service { } /// Dispatch a transport message. - pub fn dispatch_message(&self, message: &MessageEnvelope) -> Result { - let handler = self - .handlers - .get(&message.name) - .ok_or_else(|| HandlerError::UnknownCommand(message.name.clone()))?; - let input = message_to_handler_input(message, handler.input)?; + pub fn dispatch_message(&self, message: &Message) -> Result { + if !self.handlers.contains_key(&message.name) { + return Err(HandlerError::UnknownCommand(message.name.clone())); + } + + let input = match message_to_json_input(message) { + Ok(input) => input, + Err(_) => Value::Null, + }; let session = message_to_session(message); - self.dispatch(&message.name, input, session) + self.invoke(message.clone(), input, session) } /// Dispatch a bus `Event` as a message. #[cfg(feature = "bus")] pub fn dispatch_event(&self, event: &crate::bus::Event) -> Result { - self.dispatch_message(&MessageEnvelope::from(event)) + self.dispatch_message(&Message::from(event)) + } + + fn invoke( + &self, + message: Message, + input: Value, + session: Session, + ) -> Result { + let handler = self + .handlers + .get(&message.name) + .ok_or_else(|| HandlerError::UnknownCommand(message.name.clone()))?; + let name = message.name.clone(); + let ctx = Context::new(message, input, session, &self.dependencies); + + // Run guard if present + if let Some(guard) = &handler.guard { + if !guard(&ctx) { + return Err(HandlerError::GuardRejected(name)); + } + } + + (handler.handle.as_ref())(&ctx) } /// List registered command names. @@ -699,18 +779,7 @@ fn names_by_kind(specs: &[HandlerSpec], kind: MessageKind) -> Vec<&str> { names } -fn message_to_handler_input( - message: &MessageEnvelope, - input: HandlerInput, -) -> Result { - match input { - HandlerInput::PayloadJson => message_to_json_input(message), - HandlerInput::MessageEnvelope => serde_json::to_value(message) - .map_err(|e| HandlerError::DecodeFailed(format!("invalid message envelope: {e}"))), - } -} - -fn message_to_json_input(message: &MessageEnvelope) -> Result { +fn message_to_json_input(message: &Message) -> Result { serde_json::from_slice::(&message.payload).map_err(|e| { HandlerError::DecodeFailed(format!( "invalid JSON payload for message '{}': {}", @@ -719,7 +788,7 @@ fn message_to_json_input(message: &MessageEnvelope) -> Result Session { +fn message_to_session(message: &Message) -> Session { let vars: HashMap = message.metadata.iter().cloned().collect(); Session::from_map(vars) } @@ -792,7 +861,7 @@ mod tests { let service = test_service() .command("checkout.start") .handle(|_| Ok(json!({}))) - .handler(HandlerSpec::events(EVENTS).envelope()) + .events(EVENTS) .guarded(|_| true, |_| Ok(json!({}))); assert_eq!( @@ -830,12 +899,13 @@ mod tests { } Ok(json!({ + "event_id": ctx.message().id(), "checkout_id": ctx.raw_input()["checkout_id"].as_str().unwrap(), "user_id": ctx.user_id()?, })) }); - let message = MessageEnvelope { - id: "evt-1".to_string(), + let message = Message { + id: Some("evt-1".to_string()), name: "checkout.started".to_string(), kind: MessageKind::Event, payload: br#"{"checkout_id":"checkout-1"}"#.to_vec(), @@ -847,25 +917,27 @@ mod tests { assert_eq!( result, - json!({ "checkout_id": "checkout-1", "user_id": "user-1" }) + json!({ "event_id": "evt-1", "checkout_id": "checkout-1", "user_id": "user-1" }) ); } #[test] - fn dispatch_message_can_deliver_full_envelope() { - let service = test_service().event("seat.reserved").envelope().guarded( - |ctx| ctx.has_fields(&["id", "name", "payload"]), + fn dispatch_message_always_exposes_message_metadata() { + let service = test_service().event("seat.reserved").guarded( + |ctx| ctx.message().id().is_some(), |ctx| { - let envelope = ctx.input::()?; + let input: Value = ctx.input()?; + let message = ctx.message(); Ok(json!({ - "event_id": envelope.id, - "name": envelope.name, - "metadata": envelope.metadata, + "event_id": message.id(), + "name": message.name(), + "correlation_id": message.correlation_id(), + "seat_id": input["seat_id"].as_str().unwrap(), })) }, ); - let message = MessageEnvelope { - id: "evt-2".to_string(), + let message = Message { + id: Some("evt-2".to_string()), name: "seat.reserved".to_string(), kind: MessageKind::Event, payload: br#"{"seat_id":"A-7"}"#.to_vec(), @@ -880,7 +952,8 @@ mod tests { json!({ "event_id": "evt-2", "name": "seat.reserved", - "metadata": [["correlation_id", "checkout-1"]], + "correlation_id": "checkout-1", + "seat_id": "A-7", }) ); } @@ -1003,13 +1076,21 @@ mod tests { #[cfg(feature = "bus")] #[test] - fn dispatch_event_rejects_non_json_payload() { - let service = test_service() - .command("ping") - .handle(|_ctx| Ok(json!({ "ok": true }))); + fn dispatch_event_exposes_raw_payload_without_requiring_json() { + let service = test_service().command("ping").handle(|ctx| { + let payload = std::str::from_utf8(ctx.message().payload()) + .map_err(|err| HandlerError::DecodeFailed(err.to_string()))?; + Ok(json!({ + "event_id": ctx.message().id(), + "payload": payload, + })) + }); let event = crate::bus::Event::with_string_payload("evt-1", "ping", "not-json"); let result = service.dispatch_event(&event); - assert!(matches!(result, Err(HandlerError::DecodeFailed(_)))); + assert_eq!( + result.unwrap(), + json!({ "event_id": "evt-1", "payload": "not-json" }) + ); } #[cfg(feature = "bus")] diff --git a/tests/distributed_read_model/projection_service/handlers/checkout.rs b/tests/distributed_read_model/projection_service/handlers/checkout.rs index ada87e9b2..b8edfcb75 100644 --- a/tests/distributed_read_model/projection_service/handlers/checkout.rs +++ b/tests/distributed_read_model/projection_service/handlers/checkout.rs @@ -15,7 +15,7 @@ pub const EVENTS: &[&str] = &[ ]; pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "name", "payload"]) + ctx.message().id().is_some() } pub fn handle(ctx: &Context) -> Result { diff --git a/tests/distributed_read_model/projection_service/handlers/mod.rs b/tests/distributed_read_model/projection_service/handlers/mod.rs index 9903d1f00..acb8dc8d8 100644 --- a/tests/distributed_read_model/projection_service/handlers/mod.rs +++ b/tests/distributed_read_model/projection_service/handlers/mod.rs @@ -5,13 +5,13 @@ pub mod checkout; pub mod seat; use sourced_rust::bus::Event; -use sourced_rust::microsvc::{Context, HandlerError, MessageEnvelope}; +use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::ReadModelError; use crate::projection_service::ProjectionDependencies; pub fn event(ctx: &Context) -> Result { - Ok(ctx.input::()?.into()) + Event::try_from(ctx.message()) } pub fn read_model_error(err: ReadModelError) -> HandlerError { diff --git a/tests/distributed_read_model/projection_service/handlers/seat.rs b/tests/distributed_read_model/projection_service/handlers/seat.rs index f0f3e693c..b83979396 100644 --- a/tests/distributed_read_model/projection_service/handlers/seat.rs +++ b/tests/distributed_read_model/projection_service/handlers/seat.rs @@ -9,7 +9,7 @@ use crate::read_models::{CheckoutStepView, SeatView}; pub const EVENTS: &[&str] = &[seat_event::ADDED, seat_event::RESERVED]; pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "name", "payload"]) + ctx.message().id().is_some() } pub fn handle(ctx: &Context) -> Result { diff --git a/tests/distributed_read_model/projection_service/service.rs b/tests/distributed_read_model/projection_service/service.rs index 367c32fc9..964c084be 100644 --- a/tests/distributed_read_model/projection_service/service.rs +++ b/tests/distributed_read_model/projection_service/service.rs @@ -10,8 +10,8 @@ pub type ProjectionDependencies = InMemoryReadModelStore; pub fn service(store: InMemoryReadModelStore) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_read_model_store(store), - events handlers::checkout => envelope, - events handlers::seat => envelope, + events handlers::checkout, + events handlers::seat, )) } diff --git a/tests/distributed_read_model_board/projections_service/handlers/board.rs b/tests/distributed_read_model_board/projections_service/handlers/board.rs index 663a04b0d..02cac1c15 100644 --- a/tests/distributed_read_model_board/projections_service/handlers/board.rs +++ b/tests/distributed_read_model_board/projections_service/handlers/board.rs @@ -21,7 +21,7 @@ pub const EVENTS: &[&str] = &[ ]; pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "name", "payload"]) + ctx.message().id().is_some() } pub fn handle(ctx: &Context) -> Result { diff --git a/tests/distributed_read_model_board/projections_service/handlers/mod.rs b/tests/distributed_read_model_board/projections_service/handlers/mod.rs index 5e7f44b63..8c94afc29 100644 --- a/tests/distributed_read_model_board/projections_service/handlers/mod.rs +++ b/tests/distributed_read_model_board/projections_service/handlers/mod.rs @@ -4,10 +4,10 @@ pub mod board; use sourced_rust::bus::Event; -use sourced_rust::microsvc::{Context, HandlerError, MessageEnvelope}; +use sourced_rust::microsvc::{Context, HandlerError}; use crate::projections_service::ProjectionDependencies; pub fn event(ctx: &Context) -> Result { - Ok(ctx.input::()?.into()) + Event::try_from(ctx.message()) } diff --git a/tests/distributed_read_model_board/projections_service/mod.rs b/tests/distributed_read_model_board/projections_service/mod.rs index 75f0f42d4..bb2c247bb 100644 --- a/tests/distributed_read_model_board/projections_service/mod.rs +++ b/tests/distributed_read_model_board/projections_service/mod.rs @@ -31,7 +31,7 @@ pub fn start_board_projection_service( pub fn service(store: InMemoryReadModelStore) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_read_model_store(store), - events handlers::board => envelope, + events handlers::board, )) } From b0e77fddcfe9302e0374717c7f40a9893b42a31d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:45:14 -0500 Subject: [PATCH 06/12] fix: key microsvc handlers by message kind --- src/microsvc/service.rs | 103 +++++++++++++++++++------- tests/microsvc/transport_subscribe.rs | 19 +++-- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index 8d49dc837..7ccee272c 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -36,7 +36,7 @@ type GuardFn = dyn Fn(&Context) -> bool + Send + Sync; type HandlerFn = dyn Fn(&Context) -> Result + Send + Sync; /// The kind of message a handler consumes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)] pub enum MessageKind { /// A command addressed to one handler. Command, @@ -136,14 +136,7 @@ pub struct Message { #[cfg(feature = "bus")] impl From<&Event> for Message { fn from(event: &Event) -> Self { - Self { - id: Some(event.id.clone()), - name: event.event_type.clone(), - kind: MessageKind::Event, - payload: event.payload.clone(), - content_type: "application/json".to_string(), - metadata: event.metadata.clone().unwrap_or_default(), - } + Self::from_bus_event(event, MessageKind::Event) } } @@ -172,6 +165,19 @@ impl TryFrom<&Message> for Event { } impl Message { + /// Create a transport message from a bus event using an explicit message kind. + #[cfg(feature = "bus")] + pub fn from_bus_event(event: &Event, kind: MessageKind) -> Self { + Self { + id: Some(event.id.clone()), + name: event.event_type.clone(), + kind, + payload: event.payload.clone(), + content_type: "application/json".to_string(), + metadata: event.metadata.clone().unwrap_or_default(), + } + } + /// Create a transport message. pub fn new(name: impl Into, kind: MessageKind, payload: Vec) -> Self { Self { @@ -252,7 +258,6 @@ impl Message { /// A registered handler with optional guard. struct RegisteredHandler { - kind: MessageKind, guard: Option>>, handle: Arc>, } @@ -292,7 +297,7 @@ impl HandlerBuilder { /// [`Service::with_repo_and_read_model_store`] for common dependency shapes. pub struct Service { dependencies: D, - handlers: HashMap>, + handlers: HashMap<(MessageKind, String), RegisteredHandler>, handler_specs: Vec, } @@ -354,9 +359,8 @@ impl Service { ) -> Self { for name in spec.names() { self.handlers.insert( - name.to_string(), + handler_key(spec.kind, name), RegisteredHandler { - kind: spec.kind, guard: guard.clone(), handle: handle.clone(), }, @@ -376,11 +380,10 @@ impl Service { input: Value, session: Session, ) -> Result { - let kind = self - .handlers - .get(command) - .ok_or_else(|| HandlerError::UnknownCommand(command.to_string()))? - .kind; + if !self.handles_message(MessageKind::Command, command) { + return Err(HandlerError::UnknownCommand(command.to_string())); + } + let payload = serde_json::to_vec(&input).map_err(|e| { HandlerError::DecodeFailed(format!("invalid JSON input for command '{command}': {e}")) })?; @@ -392,7 +395,7 @@ impl Service { let message = Message { id: None, name: command.to_string(), - kind, + kind: MessageKind::Command, payload, content_type: "application/json".to_string(), metadata, @@ -418,7 +421,7 @@ impl Service { /// Dispatch a transport message. pub fn dispatch_message(&self, message: &Message) -> Result { - if !self.handlers.contains_key(&message.name) { + if !self.handles_message(message.kind, &message.name) { return Err(HandlerError::UnknownCommand(message.name.clone())); } @@ -436,6 +439,19 @@ impl Service { self.dispatch_message(&Message::from(event)) } + #[cfg(feature = "bus")] + fn dispatch_listened_event(&self, event: &crate::bus::Event) -> Result { + let kind = if self.handles_message(MessageKind::Command, &event.event_type) { + MessageKind::Command + } else if self.handles_message(MessageKind::Event, &event.event_type) { + MessageKind::Event + } else { + return Err(HandlerError::UnknownCommand(event.event_type.clone())); + }; + + self.dispatch_message(&Message::from_bus_event(event, kind)) + } + fn invoke( &self, message: Message, @@ -444,7 +460,7 @@ impl Service { ) -> Result { let handler = self .handlers - .get(&message.name) + .get(&handler_key(message.kind, &message.name)) .ok_or_else(|| HandlerError::UnknownCommand(message.name.clone()))?; let name = message.name.clone(); let ctx = Context::new(message, input, session, &self.dependencies); @@ -495,7 +511,19 @@ impl Service { /// Return whether this service has a handler for the message name. pub fn handles(&self, name: &str) -> bool { - self.handlers.contains_key(name) + self.handlers + .keys() + .any(|(_, registered_name)| registered_name == name) + } + + /// Return whether this service has a handler for this message kind and name. + pub fn handles_message(&self, kind: MessageKind, name: &str) -> bool { + self.handlers.contains_key(&handler_key(kind, name)) + } + + /// Return whether this service has an event handler for the message name. + pub fn handles_event(&self, name: &str) -> bool { + self.handles_message(MessageKind::Event, name) } /// Get a reference to the service dependencies. @@ -658,7 +686,7 @@ where stats.polls += 1; match listener.listen(&queue_name, poll_interval.as_millis() as u64) { - Ok(Some(event)) => match service.dispatch_event(&event) { + Ok(Some(event)) => match service.dispatch_listened_event(&event) { Ok(_) => stats.handled += 1, Err(_) => stats.failed += 1, }, @@ -734,7 +762,7 @@ where stats.polls += 1; match subscriber.poll(poll_interval.as_millis() as u64) { - Ok(Some(event)) if !service.handles(&event.event_type) => { + Ok(Some(event)) if !service.handles_event(&event.event_type) => { let _ = subscriber.ack(&event.id); } Ok(Some(event)) => match service.dispatch_event(&event) { @@ -779,6 +807,10 @@ fn names_by_kind(specs: &[HandlerSpec], kind: MessageKind) -> Vec<&str> { names } +fn handler_key(kind: MessageKind, name: &str) -> (MessageKind, String) { + (kind, name.to_string()) +} + fn message_to_json_input(message: &Message) -> Result { serde_json::from_slice::(&message.payload).map_err(|e| { HandlerError::DecodeFailed(format!( @@ -891,6 +923,27 @@ mod tests { ); } + #[test] + fn command_and_event_handlers_can_share_a_name() { + let service = test_service() + .command("shared") + .handle(|ctx| Ok(json!({ "kind": format!("{:?}", ctx.message().kind) }))) + .event("shared") + .handle(|ctx| Ok(json!({ "event_id": ctx.message().id() }))); + let event_message = + Message::new("shared", MessageKind::Event, br#"{}"#.to_vec()).with_id("evt-1"); + + let command_result = service + .dispatch("shared", json!({}), Session::new()) + .unwrap(); + let event_result = service.dispatch_message(&event_message).unwrap(); + + assert_eq!(command_result, json!({ "kind": "Command" })); + assert_eq!(event_result, json!({ "event_id": "evt-1" })); + assert!(service.handles_message(MessageKind::Command, "shared")); + assert!(service.handles_message(MessageKind::Event, "shared")); + } + #[test] fn dispatch_message_delivers_payload_json_by_default() { let service = test_service().event("checkout.started").handle(|ctx| { @@ -1077,7 +1130,7 @@ mod tests { #[cfg(feature = "bus")] #[test] fn dispatch_event_exposes_raw_payload_without_requiring_json() { - let service = test_service().command("ping").handle(|ctx| { + let service = test_service().event("ping").handle(|ctx| { let payload = std::str::from_utf8(ctx.message().payload()) .map_err(|err| HandlerError::DecodeFailed(err.to_string()))?; Ok(json!({ diff --git a/tests/microsvc/transport_subscribe.rs b/tests/microsvc/transport_subscribe.rs index cd138cb61..4e07b1ecf 100644 --- a/tests/microsvc/transport_subscribe.rs +++ b/tests/microsvc/transport_subscribe.rs @@ -16,12 +16,19 @@ use crate::handlers::Repo; use crate::models::counter::Counter; fn counter_service() -> Arc> { - Arc::new(sourced_rust::register_handlers!( - Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, - handlers::counter_increment, - handlers::whoami, - )) + Arc::new( + Service::with_repo(HashMapRepository::new().queued().aggregate::()) + .event(handlers::counter_create::COMMAND) + .guarded( + handlers::counter_create::guard, + handlers::counter_create::handle, + ) + .event(handlers::counter_increment::COMMAND) + .guarded( + handlers::counter_increment::guard, + handlers::counter_increment::handle, + ), + ) } #[test] From 04b865ce6ce87af0d76956eaaebfce4cf51423a6 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:46:08 -0500 Subject: [PATCH 07/12] fix: normalize microsvc message metadata --- src/microsvc/service.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index 7ccee272c..fd9dd3adf 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -221,7 +221,7 @@ impl Message { pub fn metadata(&self, key: &str) -> Option<&str> { self.metadata .iter() - .find(|(existing, _)| existing == key) + .find(|(existing, _)| existing.eq_ignore_ascii_case(key)) .map(|(_, value)| value.as_str()) } @@ -821,7 +821,11 @@ fn message_to_json_input(message: &Message) -> Result { } fn message_to_session(message: &Message) -> Session { - let vars: HashMap = message.metadata.iter().cloned().collect(); + let vars: HashMap = message + .metadata + .iter() + .map(|(key, value)| (key.to_ascii_lowercase(), value.clone())) + .collect(); Session::from_map(vars) } @@ -963,7 +967,7 @@ mod tests { kind: MessageKind::Event, payload: br#"{"checkout_id":"checkout-1"}"#.to_vec(), content_type: "application/json".to_string(), - metadata: vec![("x-hasura-user-id".to_string(), "user-1".to_string())], + metadata: vec![("X-Hasura-User-Id".to_string(), "user-1".to_string())], }; let result = service.dispatch_message(&message).unwrap(); @@ -995,7 +999,7 @@ mod tests { kind: MessageKind::Event, payload: br#"{"seat_id":"A-7"}"#.to_vec(), content_type: "application/json".to_string(), - metadata: vec![("correlation_id".to_string(), "checkout-1".to_string())], + metadata: vec![("Correlation_ID".to_string(), "checkout-1".to_string())], }; let result = service.dispatch_message(&message).unwrap(); From 347c4658b4f614763e744d8c11046e8c7a90546c Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:47:08 -0500 Subject: [PATCH 08/12] fix: register inventory reserved saga handler as event --- .../handlers/saga/on_inventory_reserved.rs | 2 +- tests/sagas/microsvc_saga.rs | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/sagas/handlers/saga/on_inventory_reserved.rs b/tests/sagas/handlers/saga/on_inventory_reserved.rs index b1cfb1787..172884859 100644 --- a/tests/sagas/handlers/saga/on_inventory_reserved.rs +++ b/tests/sagas/handlers/saga/on_inventory_reserved.rs @@ -1,6 +1,6 @@ use super::*; -pub const COMMAND: &str = "InventoryReserved"; +pub const EVENT: &str = "InventoryReserved"; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/microsvc_saga.rs b/tests/sagas/microsvc_saga.rs index cfad25699..ba7e5b4e8 100644 --- a/tests/sagas/microsvc_saga.rs +++ b/tests/sagas/microsvc_saga.rs @@ -25,6 +25,14 @@ use sourced_rust::{ use super::handlers; use super::order::{Inventory, Order, OrderFulfillmentSaga, OrderStatus, Payment, SagaStatus}; +fn event_message(name: &str, input: serde_json::Value) -> microsvc::Message { + microsvc::Message::new( + name, + microsvc::MessageKind::Event, + serde_json::to_vec(&input).unwrap(), + ) +} + // ============================================================================ // Test 1: Orchestrated — test runner dispatches to each service in sequence // ============================================================================ @@ -52,7 +60,7 @@ fn saga_orchestrated() { ), handlers::saga::start, handlers::saga::on_order_created, - handlers::saga::on_inventory_reserved, + event handlers::saga::on_inventory_reserved, handlers::saga::on_payment_succeeded, handlers::saga::on_order_completed, ); @@ -140,11 +148,10 @@ fn saga_orchestrated() { // 6. Saga: inventory reserved → outbox(ProcessPayment) saga_svc - .dispatch( + .dispatch_message(&event_message( "InventoryReserved", json!({ "saga_id": "saga-001", "order_id": "order-001" }), - s(), - ) + )) .unwrap(); // 7. Process payment → outbox(PaymentSucceeded) @@ -245,7 +252,7 @@ fn saga_distributed() { Service::with_repo(saga_repo.queued().aggregate::()), handlers::saga::start, handlers::saga::on_order_created, - handlers::saga::on_inventory_reserved, + event handlers::saga::on_inventory_reserved, handlers::saga::on_payment_succeeded, handlers::saga::on_order_completed, )); From 66d2e9b5f55d57bae6a27b6362e284626830c91d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:47:46 -0500 Subject: [PATCH 09/12] fix: register order completed saga handler as event --- tests/sagas/handlers/saga/on_order_completed.rs | 2 +- tests/sagas/microsvc_saga.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/sagas/handlers/saga/on_order_completed.rs b/tests/sagas/handlers/saga/on_order_completed.rs index f64655725..02158e1f9 100644 --- a/tests/sagas/handlers/saga/on_order_completed.rs +++ b/tests/sagas/handlers/saga/on_order_completed.rs @@ -1,6 +1,6 @@ use super::*; -pub const COMMAND: &str = "OrderCompleted"; +pub const EVENT: &str = "OrderCompleted"; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/microsvc_saga.rs b/tests/sagas/microsvc_saga.rs index ba7e5b4e8..d215e089d 100644 --- a/tests/sagas/microsvc_saga.rs +++ b/tests/sagas/microsvc_saga.rs @@ -62,7 +62,7 @@ fn saga_orchestrated() { handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, handlers::saga::on_payment_succeeded, - handlers::saga::on_order_completed, + event handlers::saga::on_order_completed, ); let order_svc = sourced_rust::register_handlers!( @@ -187,11 +187,10 @@ fn saga_orchestrated() { // 10. Saga: order completed → saga done saga_svc - .dispatch( + .dispatch_message(&event_message( "OrderCompleted", json!({ "saga_id": "saga-001", "order_id": "order-001" }), - s(), - ) + )) .unwrap(); // === Verify final state — typed repos return aggregates directly === @@ -254,7 +253,7 @@ fn saga_distributed() { handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, handlers::saga::on_payment_succeeded, - handlers::saga::on_order_completed, + event handlers::saga::on_order_completed, )); let saga_listen = microsvc::listen(saga_svc.clone(), "saga", queue.clone(), poll); From 2f06149572e4611b72d86d3fbdd5c6168b01f016 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:48:23 -0500 Subject: [PATCH 10/12] fix: register order created saga handler as event --- tests/sagas/handlers/saga/on_order_created.rs | 2 +- tests/sagas/microsvc_saga.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/sagas/handlers/saga/on_order_created.rs b/tests/sagas/handlers/saga/on_order_created.rs index 28f5d3a72..1608f4d70 100644 --- a/tests/sagas/handlers/saga/on_order_created.rs +++ b/tests/sagas/handlers/saga/on_order_created.rs @@ -1,6 +1,6 @@ use super::*; -pub const COMMAND: &str = "OrderCreated"; +pub const EVENT: &str = "OrderCreated"; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/microsvc_saga.rs b/tests/sagas/microsvc_saga.rs index d215e089d..acd80b557 100644 --- a/tests/sagas/microsvc_saga.rs +++ b/tests/sagas/microsvc_saga.rs @@ -59,7 +59,7 @@ fn saga_orchestrated() { .aggregate::() ), handlers::saga::start, - handlers::saga::on_order_created, + event handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, handlers::saga::on_payment_succeeded, event handlers::saga::on_order_completed, @@ -125,11 +125,10 @@ fn saga_orchestrated() { // 4. Saga: order created → outbox(ReserveInventory) saga_svc - .dispatch( + .dispatch_message(&event_message( "OrderCreated", json!({ "saga_id": "saga-001", "order_id": "order-001" }), - s(), - ) + )) .unwrap(); // 5. Reserve inventory → outbox(InventoryReserved) @@ -250,7 +249,7 @@ fn saga_distributed() { let saga_svc = Arc::new(sourced_rust::register_handlers!( Service::with_repo(saga_repo.queued().aggregate::()), handlers::saga::start, - handlers::saga::on_order_created, + event handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, handlers::saga::on_payment_succeeded, event handlers::saga::on_order_completed, From eff86d02a6e5d9deb2745ec3cab6776c613c3da4 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 15:49:01 -0500 Subject: [PATCH 11/12] fix: register payment succeeded saga handler as event --- tests/sagas/handlers/saga/on_payment_succeeded.rs | 2 +- tests/sagas/microsvc_saga.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/sagas/handlers/saga/on_payment_succeeded.rs b/tests/sagas/handlers/saga/on_payment_succeeded.rs index 4609a8710..3aacafb59 100644 --- a/tests/sagas/handlers/saga/on_payment_succeeded.rs +++ b/tests/sagas/handlers/saga/on_payment_succeeded.rs @@ -1,6 +1,6 @@ use super::*; -pub const COMMAND: &str = "PaymentSucceeded"; +pub const EVENT: &str = "PaymentSucceeded"; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["saga_id", "order_id"]) diff --git a/tests/sagas/microsvc_saga.rs b/tests/sagas/microsvc_saga.rs index acd80b557..1f37cec10 100644 --- a/tests/sagas/microsvc_saga.rs +++ b/tests/sagas/microsvc_saga.rs @@ -61,7 +61,7 @@ fn saga_orchestrated() { handlers::saga::start, event handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, - handlers::saga::on_payment_succeeded, + event handlers::saga::on_payment_succeeded, event handlers::saga::on_order_completed, ); @@ -168,11 +168,10 @@ fn saga_orchestrated() { // 8. Saga: payment succeeded → outbox(CompleteOrder) saga_svc - .dispatch( + .dispatch_message(&event_message( "PaymentSucceeded", json!({ "saga_id": "saga-001", "order_id": "order-001" }), - s(), - ) + )) .unwrap(); // 9. Complete order → outbox(OrderCompleted) @@ -251,7 +250,7 @@ fn saga_distributed() { handlers::saga::start, event handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, - handlers::saga::on_payment_succeeded, + event handlers::saga::on_payment_succeeded, event handlers::saga::on_order_completed, )); let saga_listen = microsvc::listen(saga_svc.clone(), "saga", queue.clone(), poll); From c7aa8fe8c0b3eeb1e883651c9ad5a1adeae65bed Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 27 May 2026 16:11:23 -0500 Subject: [PATCH 12/12] refactor: require explicit handler registration kind --- README.md | 8 +++---- src/microsvc/mod.rs | 14 +++++------ src/microsvc/service.rs | 2 +- .../checkout_saga_service/service.rs | 2 +- .../seat_inventory_service/service.rs | 2 +- .../board_service/service.rs | 8 +++---- tests/microsvc/convention.rs | 16 ++++++------- tests/microsvc/transport_grpc.rs | 6 ++--- tests/microsvc/transport_http.rs | 6 ++--- tests/microsvc/transport_listen.rs | 10 ++++---- tests/sagas/microsvc_saga.rs | 24 +++++++++---------- 11 files changed, 48 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index db950f918..71c9cb03f 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ use sourced_rust::{microsvc, HashMapRepository, Queueable}; fn main() -> Result<(), Box> { let service = Arc::new(sourced_rust::register_handlers!( microsvc::Service::with_repo(HashMapRepository::new().queued()), - handlers::todo_create, - handlers::todo_complete, + command handlers::todo_create, + command handlers::todo_complete, )); // Direct dispatch @@ -1031,8 +1031,8 @@ Register them with the `register_handlers!` macro: ```rust let service = sourced_rust::register_handlers!( microsvc::Service::with_repo(HashMapRepository::new().queued()), - handlers::counter_create, - handlers::counter_increment, + command handlers::counter_create, + command handlers::counter_increment, ); ``` diff --git a/src/microsvc/mod.rs b/src/microsvc/mod.rs index 35d78fc12..b691a3faf 100644 --- a/src/microsvc/mod.rs +++ b/src/microsvc/mod.rs @@ -89,6 +89,8 @@ pub use grpc::{grpc_server, serve_grpc, GrpcServeError}; /// Register handler modules with a service using the convention pattern. /// +/// Each handler entry must be prefixed with `command`, `event`, or `events`. +/// /// Command handler modules must export: /// - `COMMAND: &str` — the command name /// - `guard(ctx) -> bool` — input validation @@ -103,8 +105,8 @@ pub use grpc::{grpc_server, serve_grpc, GrpcServeError}; /// ```ignore /// let service = sourced_rust::register_handlers!( /// microsvc::Service::with_repo(HashMapRepository::new()), -/// handlers::counter_create, -/// handlers::counter_increment, +/// command handlers::counter_create, +/// command handlers::counter_increment, /// event handlers::counter_rebuilt, /// events handlers::counter_projection, /// ); @@ -150,12 +152,8 @@ macro_rules! __register_handlers { ) }; ($service:expr, $($seg:ident)::+ $(, $($rest:tt)*)?) => { - $crate::__register_handlers_continue!( - $service.command($($seg)::+::COMMAND).guarded( - $($seg)::+::guard, - $($seg)::+::handle, - ) - $(, $($rest)*)? + compile_error!( + "register_handlers! entries must be prefixed with `command`, `event`, or `events`" ) }; } diff --git a/src/microsvc/service.rs b/src/microsvc/service.rs index fd9dd3adf..e33fa304e 100644 --- a/src/microsvc/service.rs +++ b/src/microsvc/service.rs @@ -640,7 +640,7 @@ impl Drop for TransportHandle { /// let service = Arc::new( /// sourced_rust::register_handlers!( /// microsvc::Service::with_repo(repo), -/// handlers::counter_create, +/// command handlers::counter_create, /// ) /// ); /// diff --git a/tests/distributed_read_model/checkout_saga_service/service.rs b/tests/distributed_read_model/checkout_saga_service/service.rs index f257a1421..81d518e73 100644 --- a/tests/distributed_read_model/checkout_saga_service/service.rs +++ b/tests/distributed_read_model/checkout_saga_service/service.rs @@ -7,7 +7,7 @@ use super::{handlers, CheckoutRepo}; pub fn service(repo: CheckoutRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_repo(repo), - handlers::start, + command handlers::start, event handlers::record_seat_reserved, )) } diff --git a/tests/distributed_read_model/seat_inventory_service/service.rs b/tests/distributed_read_model/seat_inventory_service/service.rs index 19380aaae..2fff3283c 100644 --- a/tests/distributed_read_model/seat_inventory_service/service.rs +++ b/tests/distributed_read_model/seat_inventory_service/service.rs @@ -7,7 +7,7 @@ use super::{handlers, SeatRepo}; pub fn service(repo: SeatRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_repo(repo), - handlers::add, + command handlers::add, event handlers::reserve_started_checkout_seat, )) } diff --git a/tests/distributed_read_model_board/board_service/service.rs b/tests/distributed_read_model_board/board_service/service.rs index f5313a71f..240feee33 100644 --- a/tests/distributed_read_model_board/board_service/service.rs +++ b/tests/distributed_read_model_board/board_service/service.rs @@ -7,9 +7,9 @@ use super::{handlers, BoardRepo}; pub fn model_service(repo: BoardRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_repo(repo), - handlers::board_open, - handlers::board_add_card, - handlers::board_move_card, - handlers::board_remove_card, + command handlers::board_open, + command handlers::board_add_card, + command handlers::board_move_card, + command handlers::board_remove_card, )) } diff --git a/tests/microsvc/convention.rs b/tests/microsvc/convention.rs index 1eab23511..236abce41 100644 --- a/tests/microsvc/convention.rs +++ b/tests/microsvc/convention.rs @@ -22,8 +22,8 @@ use crate::models::counter::Counter; fn register_handlers_and_dispatch() { let service = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, - handlers::counter_increment, + command handlers::counter_create, + command handlers::counter_increment, ); let mut cmds = service.command_names(); @@ -55,7 +55,7 @@ fn register_handlers_and_dispatch() { fn guard_rejects_bad_input() { let service = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, + command handlers::counter_create, ); let result = service.dispatch("counter.create", json!({ "wrong": 1 }), Session::new()); @@ -66,7 +66,7 @@ fn guard_rejects_bad_input() { fn handler_rejects_duplicate_create() { let service = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, + command handlers::counter_create, ); service @@ -85,7 +85,7 @@ fn handler_rejects_duplicate_create() { fn create_persists_outbox_message() { let service = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, + command handlers::counter_create, ); let result = service @@ -109,7 +109,7 @@ fn create_persists_outbox_message() { fn duplicate_create_leaves_single_outbox_message() { let service = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, + command handlers::counter_create, ); service @@ -134,8 +134,8 @@ fn duplicate_create_leaves_single_outbox_message() { fn increment_persists_outbox_message() { let service = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, - handlers::counter_increment, + command handlers::counter_create, + command handlers::counter_increment, ); service diff --git a/tests/microsvc/transport_grpc.rs b/tests/microsvc/transport_grpc.rs index 59f23de03..525cb9c6d 100644 --- a/tests/microsvc/transport_grpc.rs +++ b/tests/microsvc/transport_grpc.rs @@ -20,9 +20,9 @@ use crate::models::counter::Counter; fn counter_service() -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, - handlers::counter_increment, - handlers::whoami, + command handlers::counter_create, + command handlers::counter_increment, + command handlers::whoami, )) } diff --git a/tests/microsvc/transport_http.rs b/tests/microsvc/transport_http.rs index d9962e189..1466f07e2 100644 --- a/tests/microsvc/transport_http.rs +++ b/tests/microsvc/transport_http.rs @@ -15,9 +15,9 @@ use crate::models::counter::Counter; fn counter_service() -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, - handlers::counter_increment, - handlers::whoami, + command handlers::counter_create, + command handlers::counter_increment, + command handlers::whoami, )) } diff --git a/tests/microsvc/transport_listen.rs b/tests/microsvc/transport_listen.rs index 45ebddc07..52d0971b4 100644 --- a/tests/microsvc/transport_listen.rs +++ b/tests/microsvc/transport_listen.rs @@ -19,9 +19,9 @@ use crate::models::counter::Counter; fn counter_service() -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::counter_create, - handlers::counter_increment, - handlers::whoami, + command handlers::counter_create, + command handlers::counter_increment, + command handlers::whoami, )) } @@ -155,12 +155,12 @@ fn multiple_services_on_different_queues() { let service_a = Arc::new(sourced_rust::register_handlers!( Service::with_repo(store.clone().queued().aggregate::()), - handlers::counter_create, + command handlers::counter_create, )); let service_b = Arc::new(sourced_rust::register_handlers!( Service::with_repo(store.queued().aggregate::()), - handlers::counter_increment, + command handlers::counter_increment, )); let handle_a = microsvc::listen( diff --git a/tests/sagas/microsvc_saga.rs b/tests/sagas/microsvc_saga.rs index 1f37cec10..307be7e57 100644 --- a/tests/sagas/microsvc_saga.rs +++ b/tests/sagas/microsvc_saga.rs @@ -58,7 +58,7 @@ fn saga_orchestrated() { .queued() .aggregate::() ), - handlers::saga::start, + command handlers::saga::start, event handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, event handlers::saga::on_payment_succeeded, @@ -67,19 +67,19 @@ fn saga_orchestrated() { let order_svc = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::orders::create, - handlers::orders::complete, + command handlers::orders::create, + command handlers::orders::complete, ); let inventory_svc = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::inventory::init, - handlers::inventory::reserve, + command handlers::inventory::init, + command handlers::inventory::reserve, ); let payment_svc = sourced_rust::register_handlers!( Service::with_repo(HashMapRepository::new().queued().aggregate::()), - handlers::payments::process, + command handlers::payments::process, ); let s = Session::new; @@ -247,7 +247,7 @@ fn saga_distributed() { OutboxWorkerThread::spawn_routed(saga_repo.outbox_store(), queue.clone(), poll); let saga_svc = Arc::new(sourced_rust::register_handlers!( Service::with_repo(saga_repo.queued().aggregate::()), - handlers::saga::start, + command handlers::saga::start, event handlers::saga::on_order_created, event handlers::saga::on_inventory_reserved, event handlers::saga::on_payment_succeeded, @@ -261,8 +261,8 @@ fn saga_distributed() { OutboxWorkerThread::spawn_routed(order_repo.outbox_store(), queue.clone(), poll); let order_svc = Arc::new(sourced_rust::register_handlers!( Service::with_repo(order_repo.queued().aggregate::()), - handlers::orders::create, - handlers::orders::complete, + command handlers::orders::create, + command handlers::orders::complete, )); let order_listen = microsvc::listen(order_svc.clone(), "orders", queue.clone(), poll); @@ -281,8 +281,8 @@ fn saga_distributed() { let inventory_svc = Arc::new(sourced_rust::register_handlers!( Service::with_repo(inventory_repo.queued().aggregate::()), - handlers::inventory::init, - handlers::inventory::reserve, + command handlers::inventory::init, + command handlers::inventory::reserve, )); let inventory_listen = microsvc::listen(inventory_svc.clone(), "inventory", queue.clone(), poll); @@ -293,7 +293,7 @@ fn saga_distributed() { OutboxWorkerThread::spawn_routed(payment_repo.outbox_store(), queue.clone(), poll); let payment_svc = Arc::new(sourced_rust::register_handlers!( Service::with_repo(payment_repo.queued().aggregate::()), - handlers::payments::process, + command handlers::payments::process, )); let payment_listen = microsvc::listen(payment_svc.clone(), "payments", queue.clone(), poll);