11//! NATS sink for publishing events to a NATS subject.
22//!
3- //! Publishes each event as a JSON message to the configured subject.
4- //! The sink maintains a persistent connection with automatic reconnection.
3+ //! Publishes each event's payload as a JSON message to a subject determined by:
4+ //! 1. `topic` key in event metadata (from subscription's metadata/metadata_extensions)
5+ //! 2. Fallback to `subject` in sink config
56//!
6- //! # Payload Extensions
7+ //! # Dynamic Routing
78//!
8- //! This sink does not require any specific payload extensions. However, you can
9- //! use payload extensions to add routing metadata for NATS subject hierarchies:
9+ //! The target subject can be configured per-event using metadata_extensions:
1010//!
1111//! ```sql
12- //! payload_extensions = '[
13- //! {"key ": "subject_suffix ", "value ": "orders.created "}
12+ //! metadata_extensions = '[
13+ //! {"json_path ": "topic ", "expression ": "''events.'' || table_name "}
1414//! ]'
1515//! ```
1616
1717use async_nats:: Client ;
1818use etl:: error:: EtlResult ;
1919use serde:: { Deserialize , Serialize } ;
2020use std:: sync:: Arc ;
21- use tracing:: info;
2221
2322use crate :: sink:: Sink ;
2423use crate :: types:: TriggeredEvent ;
@@ -33,17 +32,18 @@ pub struct NatsSinkConfig {
3332 /// Contains credentials and should be treated as sensitive.
3433 pub url : String ,
3534
36- /// Subject to publish messages to.
37- pub subject : String ,
35+ /// Subject to publish messages to. Optional if provided via event metadata.
36+ #[ serde( default ) ]
37+ pub subject : Option < String > ,
3838}
3939
4040/// Configuration for the NATS sink without sensitive data.
4141///
4242/// Safe to serialize and log. Use this for debugging and metrics.
4343#[ derive( Clone , Debug , Serialize , Deserialize ) ]
4444pub struct NatsSinkConfigWithoutSecrets {
45- /// Subject to publish messages to.
46- pub subject : String ,
45+ /// Subject to publish messages to (if configured) .
46+ pub subject : Option < String > ,
4747}
4848
4949impl From < NatsSinkConfig > for NatsSinkConfigWithoutSecrets {
@@ -64,15 +64,15 @@ impl From<&NatsSinkConfig> for NatsSinkConfigWithoutSecrets {
6464
6565/// Sink that publishes events to a NATS subject.
6666///
67- /// Each event is serialized as JSON and published to the configured subject.
67+ /// Each event's payload is serialized as JSON and published to the subject.
6868/// The NATS client handles connection pooling and automatic reconnection.
6969#[ derive( Clone ) ]
7070pub struct NatsSink {
7171 /// Shared NATS client connection.
7272 client : Arc < Client > ,
7373
74- /// Subject to publish messages to.
75- subject : String ,
74+ /// Default subject to publish messages to. Can be overridden per-event via metadata .
75+ subject : Option < String > ,
7676}
7777
7878impl NatsSink {
@@ -91,6 +91,18 @@ impl NatsSink {
9191 subject : config. subject ,
9292 } )
9393 }
94+
95+ /// Resolves the subject for an event from metadata or config.
96+ fn resolve_subject < ' a > ( & ' a self , event : & ' a TriggeredEvent ) -> Option < & ' a str > {
97+ // First check event metadata for dynamic subject (using generic "topic" key).
98+ if let Some ( ref metadata) = event. metadata {
99+ if let Some ( topic) = metadata. get ( "topic" ) . and_then ( |v| v. as_str ( ) ) {
100+ return Some ( topic) ;
101+ }
102+ }
103+ // Fall back to config subject.
104+ self . subject . as_deref ( )
105+ }
94106}
95107
96108impl Sink for NatsSink {
@@ -103,44 +115,32 @@ impl Sink for NatsSink {
103115 return Ok ( ( ) ) ;
104116 }
105117
106- info ! (
107- "publishing {} events to NATS subject '{}'" ,
108- events. len( ) ,
109- self . subject
110- ) ;
111-
112118 for event in & events {
113- // Build JSON object manually since TriggeredEvent doesn't implement Serialize.
114- let mut json_obj = serde_json:: json!( {
115- "id" : event. id. id,
116- "created_at" : event. id. created_at. to_rfc3339_opts( chrono:: SecondsFormat :: Millis , true ) ,
117- "payload" : event. payload,
118- "stream_id" : format!( "{:?}" , event. stream_id) ,
119- } ) ;
120-
121- // Add optional fields.
122- if let Some ( ref metadata) = event. metadata {
123- json_obj[ "metadata" ] = metadata. clone ( ) ;
124- }
125- if let Some ( lsn) = event. lsn {
126- json_obj[ "lsn" ] = serde_json:: json!( lsn. to_string( ) ) ;
127- }
119+ // Resolve subject from event metadata or config.
120+ let subject = self . resolve_subject ( event) . ok_or_else ( || {
121+ etl:: etl_error!(
122+ etl:: error:: ErrorKind :: ConfigError ,
123+ "No subject configured" ,
124+ "Subject must be provided in sink config or event metadata (topic key)"
125+ )
126+ } ) ?;
128127
129- let payload = serde_json:: to_vec ( & json_obj) . map_err ( |e| {
128+ // Serialize payload to JSON.
129+ let payload = serde_json:: to_vec ( & event. payload ) . map_err ( |e| {
130130 etl:: etl_error!(
131131 etl:: error:: ErrorKind :: InvalidData ,
132- "Failed to serialize event to JSON" ,
132+ "Failed to serialize payload to JSON" ,
133133 e. to_string( )
134134 )
135135 } ) ?;
136136
137- // Publish to the configured subject.
137+ // Publish to the resolved subject.
138138 self . client
139- . publish ( self . subject . clone ( ) , payload. into ( ) )
139+ . publish ( subject. to_string ( ) , payload. into ( ) )
140140 . await
141141 . map_err ( |e| {
142142 etl:: etl_error!(
143- etl:: error:: ErrorKind :: InvalidData ,
143+ etl:: error:: ErrorKind :: DestinationError ,
144144 "Failed to publish event to NATS" ,
145145 e. to_string( )
146146 )
0 commit comments