Skip to main content

netget/client/stomp/
actions.rs

1//! STOMP 1.2 client actions.
2//!
3//! The division of labour mirrors the server side (`src/server/stomp/`): everything
4//! mechanical is Rust and is never asked of the model — frame boundaries, header escaping,
5//! the `content-length`/NUL body rule, the `accept-version` handshake and the receipt that
6//! makes `DISCONNECT` graceful. What the model decides is *content*: which destination to
7//! subscribe to, what to publish, whether a delivered message deserves an `ACK` or a `NACK`,
8//! and when the session is over.
9//!
10//! The codec itself is [`crate::server::stomp::frame`], reused verbatim rather than copied.
11//! Its two private helpers (`decode_body`, `extra_headers`) are not reachable from here, so
12//! their client-side equivalents live in this file; see `src/client/stomp/CLAUDE.md`.
13
14use crate::llm::actions::{
15    client_trait::{Client, ClientActionResult},
16    protocol_trait::Protocol,
17    ActionDefinition, Parameter, ParameterDefinition,
18};
19use crate::protocol::EventType;
20use crate::server::stomp::frame::StompFrame;
21use crate::state::app_state::AppState;
22use anyhow::{Context, Result};
23use serde_json::json;
24use std::sync::LazyLock;
25
26/// The only protocol version this client offers in `accept-version`, and the only one it will
27/// accept back in `CONNECTED`.
28///
29/// Offering `1.0,1.1,1.2` would mean being prepared to speak whichever the broker picks, and
30/// this client implements 1.2 only — the `ack` header on `ACK`/`NACK` is 1.2-shaped, and 1.1
31/// wants `message-id`/`subscription` instead. Advertising a dialect we cannot speak is the
32/// same class of lie as advertising heart-beats we do not send.
33pub const STOMP_ACCEPT_VERSION: &str = "1.2";
34
35/// Heart-beating is negotiated off, unconditionally.
36///
37/// This client runs no heart-beat timer and never emits a bare EOL, so any other value would
38/// promise a broker something it will not get — and a broker that believes it will hear from
39/// us every N ms tears the connection down when it does not. `0` in our send position also
40/// makes the negotiated interval zero in both directions per the spec's formula, so a
41/// compliant broker stops expecting them. There is deliberately no startup parameter for it.
42pub const STOMP_HEARTBEAT: &str = "0,0";
43
44/// The `receipt` header the `disconnect` action puts on its `DISCONNECT` frame.
45///
46/// The spec's graceful shutdown is DISCONNECT-with-receipt, then wait for the matching
47/// `RECEIPT`, then close — that is the only way a publisher learns the broker durably took
48/// everything it sent. Fixing the id here rather than letting the model choose one means the
49/// read loop can recognise the acknowledgement of *its own* shutdown without tracking any
50/// state, and it works identically for a model-produced action and one injected from the
51/// dashboard.
52pub const DISCONNECT_RECEIPT_ID: &str = "netget-stomp-disconnect";
53
54/// Raised once the broker's `CONNECTED` has been received and validated.
55///
56/// This fires *after* the handshake has been checked, never before: a session that did not
57/// produce a well-formed `CONNECTED` is refused in `connect()` and no event is raised at all.
58pub static STOMP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
59    EventType::new(
60        "stomp_connected",
61        "The STOMP session is open: the broker answered CONNECT with a valid CONNECTED frame",
62        json!({
63            "type": "send_stomp_subscribe",
64            "destination": "/queue/test",
65            "id": "sub-0",
66            "ack_mode": "auto"
67        }),
68    )
69    .with_parameters(vec![
70        Parameter {
71            name: "version".to_string(),
72            type_hint: "string".to_string(),
73            description: "Protocol version the broker agreed to (always 1.2 here)".to_string(),
74            required: true,
75        },
76        Parameter {
77            name: "session".to_string(),
78            type_hint: "string".to_string(),
79            description: "Broker-assigned session identifier, empty if the broker sent none"
80                .to_string(),
81            required: false,
82        },
83        Parameter {
84            name: "server".to_string(),
85            type_hint: "string".to_string(),
86            description: "Broker software identification, empty if the broker sent none"
87                .to_string(),
88            required: false,
89        },
90    ])
91    .with_actions(vec![
92        send_stomp_subscribe_action(),
93        send_stomp_send_action(),
94        disconnect_action(),
95        wait_for_more_action(),
96    ])
97});
98
99/// Raised for every `MESSAGE` frame the broker delivers to one of our subscriptions.
100pub static STOMP_CLIENT_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
101    EventType::new(
102        "stomp_message_received",
103        "A MESSAGE frame was delivered on one of this client's subscriptions",
104        json!({
105            "type": "send_stomp_send",
106            "destination": "/queue/replies",
107            "body": "got it",
108            "encoding": "utf8"
109        }),
110    )
111    .with_parameters(vec![
112        Parameter {
113            name: "destination".to_string(),
114            type_hint: "string".to_string(),
115            description: "Destination the message was published to".to_string(),
116            required: true,
117        },
118        Parameter {
119            name: "message_id".to_string(),
120            type_hint: "string".to_string(),
121            description: "Broker-assigned message identifier".to_string(),
122            required: true,
123        },
124        Parameter {
125            name: "subscription".to_string(),
126            type_hint: "string".to_string(),
127            description: "Id of the subscription this delivery belongs to - the same id that \
128                          was passed to send_stomp_subscribe"
129                .to_string(),
130            required: true,
131        },
132        Parameter {
133            name: "headers".to_string(),
134            type_hint: "object".to_string(),
135            description: "Every other header on the frame, as a flat string map. When the \
136                          subscription uses ack_mode \"client\" or \"client-individual\" the \
137                          broker puts an \"ack\" header here, and its value is what \
138                          send_stomp_ack / send_stomp_nack take as their 'id'."
139                .to_string(),
140            required: false,
141        },
142        Parameter {
143            name: "body".to_string(),
144            type_hint: "string".to_string(),
145            description: "The message body, encoded as body_encoding says".to_string(),
146            required: false,
147        },
148        Parameter {
149            name: "body_encoding".to_string(),
150            type_hint: "string".to_string(),
151            description: "\"utf8\" when the body was printable text, \"hex\" when it was \
152                          binary. Pass both fields straight into send_stomp_send to republish \
153                          the exact bytes."
154                .to_string(),
155            required: true,
156        },
157    ])
158    .with_actions(vec![
159        send_stomp_send_action(),
160        send_stomp_ack_action(),
161        send_stomp_nack_action(),
162        send_stomp_subscribe_action(),
163        send_stomp_unsubscribe_action(),
164        wait_for_more_action(),
165        disconnect_action(),
166    ])
167});
168
169/// Raised for every `RECEIPT` frame, except the one acknowledging our own `DISCONNECT`.
170///
171/// That one ends the session, so asking the model what to do about it would spend a model call
172/// on a connection that is already closing and whose answer could not be written anywhere.
173pub static STOMP_CLIENT_RECEIPT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
174    EventType::new(
175        "stomp_receipt_received",
176        "The broker acknowledged a frame that carried a receipt header",
177        json!({"type": "wait_for_more"}),
178    )
179    .with_parameters(vec![Parameter {
180        name: "receipt_id".to_string(),
181        type_hint: "string".to_string(),
182        description: "Value of the receipt-id header, matching the receipt asked for".to_string(),
183        required: true,
184    }])
185    .with_actions(vec![
186        wait_for_more_action(),
187        send_stomp_send_action(),
188        disconnect_action(),
189    ])
190});
191
192/// Raised for an `ERROR` frame. The broker closes the connection after sending one, so this is
193/// the last thing the model hears on this session.
194pub static STOMP_CLIENT_ERROR_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
195    EventType::new(
196        "stomp_error_received",
197        "The broker sent an ERROR frame; per the STOMP specification it closes the connection \
198         immediately afterwards, so this session is over",
199        json!({"type": "wait_for_more"}),
200    )
201    .with_parameters(vec![
202        Parameter {
203            name: "message".to_string(),
204            type_hint: "string".to_string(),
205            description: "The ERROR frame's 'message' header - the broker's short reason"
206                .to_string(),
207            required: false,
208        },
209        Parameter {
210            name: "body".to_string(),
211            type_hint: "string".to_string(),
212            description: "The ERROR frame's body, encoded as body_encoding says".to_string(),
213            required: false,
214        },
215        Parameter {
216            name: "body_encoding".to_string(),
217            type_hint: "string".to_string(),
218            description: "\"utf8\" when the body was printable text, \"hex\" when it was binary"
219                .to_string(),
220            required: true,
221        },
222    ])
223    // Nothing can be written to a connection the broker is closing, so the only useful answers
224    // are the common actions (set_memory, show_message, append_to_log) plus an explicit
225    // acknowledgement that there is nothing to send.
226    .with_actions(vec![wait_for_more_action()])
227});
228
229/// STOMP 1.2 client protocol action handler.
230pub struct StompClientProtocol;
231
232impl StompClientProtocol {
233    pub fn new() -> Self {
234        Self
235    }
236}
237
238impl Default for StompClientProtocol {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244impl Protocol for StompClientProtocol {
245    /// The whole vocabulary lives here.
246    ///
247    /// `call_llm_for_client` advertises the union of async, sync and the firing event's own
248    /// actions (`client_llm_action_set`), and a client has a single LLM entry point, so a
249    /// sync/async split cannot express a narrowing the way a server's can. Declaring the list
250    /// once and attaching per-event subsets to the event types is the shape that says
251    /// something; duplicating it into `get_sync_actions()` would say nothing.
252    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
253        vec![
254            send_stomp_subscribe_action(),
255            send_stomp_send_action(),
256            send_stomp_unsubscribe_action(),
257            send_stomp_ack_action(),
258            send_stomp_nack_action(),
259            wait_for_more_action(),
260            disconnect_action(),
261        ]
262    }
263
264    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
265        Vec::new()
266    }
267
268    fn protocol_name(&self) -> &'static str {
269        "STOMP"
270    }
271
272    fn get_event_types(&self) -> Vec<EventType> {
273        vec![
274            STOMP_CLIENT_CONNECTED_EVENT.clone(),
275            STOMP_CLIENT_MESSAGE_RECEIVED_EVENT.clone(),
276            STOMP_CLIENT_RECEIPT_RECEIVED_EVENT.clone(),
277            STOMP_CLIENT_ERROR_RECEIVED_EVENT.clone(),
278        ]
279    }
280
281    fn stack_name(&self) -> &'static str {
282        "ETH>IP>TCP>STOMP"
283    }
284
285    fn keywords(&self) -> Vec<&'static str> {
286        vec![
287            "stomp",
288            "stomp client",
289            "message broker client",
290            "subscribe to a queue",
291        ]
292    }
293
294    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
295        vec![
296            ParameterDefinition {
297                name: "host".to_string(),
298                type_hint: "string".to_string(),
299                description: "Virtual host for the CONNECT frame's 'host' header. Defaults to \
300                              the host part of remote_addr, which is what the specification \
301                              says a client should send when it has no better answer."
302                    .to_string(),
303                required: false,
304                example: json!("/"),
305                default: None,
306            },
307            ParameterDefinition {
308                name: "login".to_string(),
309                type_hint: "string".to_string(),
310                description: "Value for the CONNECT frame's 'login' header. Omitted entirely \
311                              when not supplied."
312                    .to_string(),
313                required: false,
314                example: json!("guest"),
315                default: None,
316            },
317            ParameterDefinition {
318                name: "passcode".to_string(),
319                type_hint: "string".to_string(),
320                description: "Value for the CONNECT frame's 'passcode' header. Sent in \
321                              cleartext - this client has no TLS."
322                    .to_string(),
323                required: false,
324                example: json!("guest"),
325                default: None,
326            },
327            ParameterDefinition {
328                name: "use_stomp_command".to_string(),
329                type_hint: "boolean".to_string(),
330                description: "Send the 1.2-preferred 'STOMP' command instead of 'CONNECT'. \
331                              Identical otherwise; some brokers log the two differently and \
332                              1.0-only brokers reject STOMP, which is the point of the choice."
333                    .to_string(),
334                required: false,
335                example: json!(false),
336                default: None,
337            },
338            ParameterDefinition {
339                name: "handshake_timeout_secs".to_string(),
340                type_hint: "number".to_string(),
341                description: "How long to wait for the broker's CONNECTED before giving up \
342                              (default 20). A broker that never answers must not leave the \
343                              client wedged in Connecting."
344                    .to_string(),
345                required: false,
346                example: json!(20),
347                default: None,
348            },
349        ]
350    }
351
352    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
353        use crate::protocol::metadata::{
354            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
355        };
356
357        ProtocolMetadataV2::builder()
358            .state(DevelopmentState::Experimental)
359            .privilege_requirement(PrivilegeRequirement::None)
360            .implementation(
361                "Tokio TCP driving the STOMP 1.2 codec in src/server/stomp/frame.rs, reused \
362                 rather than duplicated. The handshake, the content-length/NUL body rule, \
363                 header escaping and the DISCONNECT receipt are deterministic Rust; the model \
364                 chooses destinations, bodies and acknowledgements.",
365            )
366            .llm_control(
367                "SUBSCRIBE / SEND / UNSUBSCRIBE / ACK / NACK / DISCONNECT, decided per event. \
368                 The model never sees or writes frame bytes.",
369            )
370            .e2e_testing(
371                "tests/client/stomp/e2e_test.rs drives this client against NetGet's own STOMP \
372                 server over a real TCP socket, plus a hand-written broker that answers the \
373                 handshake wrongly. Not #[ignore]d and it cannot skip - there is no external \
374                 binary to detect as missing.",
375            )
376            .notes(
377                "EXPERIMENTAL, and precisely so: the only peer this client has ever been run \
378                 against is NetGet's own STOMP server. That is same-project evidence - it \
379                 shows the two halves of this repository agree with each other, not that \
380                 either matches RFC-equivalent broker behaviour, and a shared misreading of \
381                 the specification would be invisible to it. The async-stomp crate cannot \
382                 close the gap because it is itself a client. Beta needs one exchange against \
383                 a real broker (ActiveMQ, or RabbitMQ with the STOMP plugin); \
384                 src/client/stomp/CLAUDE.md spells out the exact test. NOT IMPLEMENTED, and \
385                 none of it is hidden: heart-beating (negotiated 0,0, which is a legal \
386                 configuration rather than a silent omission), transactions \
387                 (BEGIN/COMMIT/ABORT), STOMP 1.0/1.1 (a CONNECTED naming any other version is \
388                 refused rather than guessed at) and TLS, so login/passcode travel in \
389                 cleartext.",
390            )
391            .build()
392    }
393
394    fn description(&self) -> &'static str {
395        "STOMP 1.2 client for subscribing to and publishing on a message broker"
396    }
397
398    fn example_prompt(&self) -> &'static str {
399        "Connect to the STOMP broker at 127.0.0.1:61613, subscribe to /queue/test, and reply \
400         on /queue/replies to whatever arrives"
401    }
402
403    fn group_name(&self) -> &'static str {
404        "Application"
405    }
406
407    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
408        use crate::llm::actions::StartupExamples;
409
410        StartupExamples::new(
411            // LLM mode: the model drives the whole session.
412            json!({
413                "type": "open_client",
414                "remote_addr": "127.0.0.1:61613",
415                "base_stack": "stomp",
416                "instruction": "Subscribe to /queue/test and reply on /queue/replies to every \
417                                message that arrives"
418            }),
419            // Script mode: deterministic handling of each delivery.
420            json!({
421                "type": "open_client",
422                "remote_addr": "127.0.0.1:61613",
423                "base_stack": "stomp",
424                "event_handlers": [{
425                    "event_pattern": "stomp_message_received",
426                    "handler": {
427                        "type": "script",
428                        "language": "python",
429                        "code": "respond([{'type': 'send_stomp_send', 'destination': '/queue/replies', 'body': event['body'], 'encoding': event['body_encoding']}])"
430                    }
431                }]
432            }),
433            // Static mode: subscribe on connect, then say nothing.
434            json!({
435                "type": "open_client",
436                "remote_addr": "127.0.0.1:61613",
437                "base_stack": "stomp",
438                "event_handlers": [
439                    {
440                        "event_pattern": "stomp_connected",
441                        "handler": {
442                            "type": "static",
443                            "actions": [{
444                                "type": "send_stomp_subscribe",
445                                "destination": "/queue/test",
446                                "id": "sub-0",
447                                "ack_mode": "auto"
448                            }]
449                        }
450                    },
451                    {
452                        "event_pattern": "stomp_message_received",
453                        "handler": {
454                            "type": "static",
455                            "actions": [{"type": "wait_for_more"}]
456                        }
457                    }
458                ]
459            }),
460        )
461    }
462}
463
464impl Client for StompClientProtocol {
465    fn connect(
466        &self,
467        ctx: crate::protocol::ConnectContext,
468    ) -> std::pin::Pin<
469        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
470    > {
471        Box::pin(async move {
472            crate::client::stomp::StompClient::connect_with_llm_actions(
473                ctx.remote_addr,
474                ctx.llm_client,
475                ctx.state,
476                ctx.status_tx,
477                ctx.client_id,
478                ctx.startup_params,
479            )
480            .await
481        })
482    }
483
484    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
485        let action_type = action
486            .get("type")
487            .and_then(|v| v.as_str())
488            .context("Missing 'type' field in action")?;
489
490        match action_type {
491            "send_stomp_subscribe" => {
492                let destination = required_str(&action, "destination")?;
493                let id = required_str(&action, "id")?;
494                let ack_mode = action
495                    .get("ack_mode")
496                    .and_then(|v| v.as_str())
497                    .unwrap_or("auto")
498                    .to_string();
499                if !matches!(ack_mode.as_str(), "auto" | "client" | "client-individual") {
500                    return Err(anyhow::anyhow!(
501                        "'ack_mode' must be \"auto\", \"client\" or \"client-individual\"; got \
502                         {ack_mode:?}"
503                    ));
504                }
505                let mut headers = vec![
506                    ("destination".to_string(), destination),
507                    ("id".to_string(), id),
508                    ("ack".to_string(), ack_mode),
509                ];
510                push_receipt(&mut headers, &action);
511                Ok(ClientActionResult::SendData(
512                    StompFrame::new("SUBSCRIBE", headers, Vec::new()).encode(),
513                ))
514            }
515
516            "send_stomp_send" => {
517                let destination = required_str(&action, "destination")?;
518                let body = decode_body(&action)?;
519                let mut headers = vec![("destination".to_string(), destination)];
520                if let Some(content_type) = action.get("content_type").and_then(|v| v.as_str()) {
521                    headers.push(("content-type".to_string(), content_type.to_string()));
522                }
523                headers.extend(extra_headers(&action)?);
524                push_receipt(&mut headers, &action);
525                Ok(ClientActionResult::SendData(
526                    StompFrame::new("SEND", headers, body).encode(),
527                ))
528            }
529
530            "send_stomp_unsubscribe" => {
531                let id = required_str(&action, "id")?;
532                let mut headers = vec![("id".to_string(), id)];
533                push_receipt(&mut headers, &action);
534                Ok(ClientActionResult::SendData(
535                    StompFrame::new("UNSUBSCRIBE", headers, Vec::new()).encode(),
536                ))
537            }
538
539            "send_stomp_ack" | "send_stomp_nack" => {
540                let id = required_str(&action, "id")?;
541                let command = if action_type == "send_stomp_ack" {
542                    "ACK"
543                } else {
544                    "NACK"
545                };
546                let mut headers = vec![("id".to_string(), id)];
547                push_receipt(&mut headers, &action);
548                Ok(ClientActionResult::SendData(
549                    StompFrame::new(command, headers, Vec::new()).encode(),
550                ))
551            }
552
553            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
554
555            // The graceful shutdown the specification defines, not a socket close: the frame
556            // carries a fixed receipt and the read loop closes when the matching RECEIPT comes
557            // back (or when the grace period lapses). Returning
558            // `ClientActionResult::Disconnect` here would hang up before the broker had
559            // acknowledged anything we published.
560            "disconnect" => Ok(ClientActionResult::SendData(
561                StompFrame::new(
562                    "DISCONNECT",
563                    vec![("receipt".to_string(), DISCONNECT_RECEIPT_ID.to_string())],
564                    Vec::new(),
565                )
566                .encode(),
567            )),
568
569            other => Err(anyhow::anyhow!("Unknown STOMP client action: {other}")),
570        }
571    }
572}
573
574// === helpers ===
575
576fn required_str(action: &serde_json::Value, key: &str) -> Result<String> {
577    action
578        .get(key)
579        .and_then(|v| v.as_str())
580        .map(|s| s.to_string())
581        .with_context(|| format!("Missing or non-string '{key}' parameter"))
582}
583
584/// Attach an optional `receipt` header, which is how a client asks the broker to confirm a
585/// frame it would otherwise never hear about again.
586fn push_receipt(headers: &mut Vec<(String, String)>, action: &serde_json::Value) {
587    if let Some(receipt) = action.get("receipt").and_then(|v| v.as_str()) {
588        if !receipt.is_empty() {
589            headers.push(("receipt".to_string(), receipt.to_string()));
590        }
591    }
592}
593
594/// Decode `body` according to the **explicit** `encoding` field.
595///
596/// Never sniffed: `"48656c6c6f"` is simultaneously valid text and valid hex, and only the
597/// sender knows which it means (the `send_tcp_data` bug, `d70bb5b5`). This is the same
598/// contract the inbound `stomp_message_received` event uses, so passing a received
599/// `body`/`body_encoding` pair straight into `send_stomp_send` republishes the exact bytes.
600fn decode_body(action: &serde_json::Value) -> Result<Vec<u8>> {
601    let body = action.get("body").and_then(|v| v.as_str()).unwrap_or("");
602    match action
603        .get("encoding")
604        .and_then(|v| v.as_str())
605        .unwrap_or("utf8")
606    {
607        "utf8" => Ok(body.as_bytes().to_vec()),
608        "hex" => {
609            let cleaned: String = body
610                .trim()
611                .trim_start_matches("0x")
612                .chars()
613                .filter(|c| !c.is_whitespace() && *c != ':')
614                .collect();
615            hex::decode(&cleaned).map_err(|e| {
616                anyhow::anyhow!("'body' is not valid hex although encoding is \"hex\": {e}")
617            })
618        }
619        other => Err(anyhow::anyhow!(
620            "Invalid 'encoding' {other:?}; valid values are \"utf8\" (default) and \"hex\""
621        )),
622    }
623}
624
625/// Model-supplied extra headers for `SEND`.
626///
627/// `content-length` is dropped: the codec computes it from the body actually sent, and a wrong
628/// one either truncates the frame or makes the broker wait for bytes that never arrive.
629/// `destination`, `content-type` and `receipt` are dropped too — each has its own named
630/// parameter, and a duplicate header is resolved by first-occurrence-wins, so allowing both
631/// routes would make which one applies depend on map ordering. Nested values are refused
632/// rather than stringified: there is no representation of a JSON object in a STOMP header that
633/// a broker could read back.
634fn extra_headers(action: &serde_json::Value) -> Result<Vec<(String, String)>> {
635    let Some(map) = action.get("headers").and_then(|v| v.as_object()) else {
636        return Ok(Vec::new());
637    };
638    let mut out = Vec::new();
639    for (k, v) in map {
640        if matches!(
641            k.as_str(),
642            "content-length" | "destination" | "content-type" | "receipt"
643        ) {
644            continue;
645        }
646        let value = match v {
647            serde_json::Value::String(s) => s.clone(),
648            serde_json::Value::Number(n) => n.to_string(),
649            serde_json::Value::Bool(b) => b.to_string(),
650            serde_json::Value::Null => continue,
651            _ => {
652                return Err(anyhow::anyhow!(
653                    "header {k:?} must be a string, number or boolean; a STOMP header cannot \
654                     carry a nested object or array"
655                ))
656            }
657        };
658        out.push((k.clone(), value));
659    }
660    Ok(out)
661}
662
663// === action definitions ===
664
665fn send_stomp_subscribe_action() -> ActionDefinition {
666    ActionDefinition {
667        name: "send_stomp_subscribe".to_string(),
668        description: "Subscribe to a destination. The 'id' is chosen by this client, not the \
669                      broker, and every MESSAGE delivered for this subscription quotes it back \
670                      in stomp_message_received's 'subscription' field - so pick one you can \
671                      recognise and reuse it for send_stomp_unsubscribe."
672            .to_string(),
673        parameters: vec![
674            Parameter {
675                name: "destination".to_string(),
676                type_hint: "string".to_string(),
677                description: "Destination to subscribe to, e.g. /queue/test or /topic/news"
678                    .to_string(),
679                required: true,
680            },
681            Parameter {
682                name: "id".to_string(),
683                type_hint: "string".to_string(),
684                description: "Subscription id, unique within this connection".to_string(),
685                required: true,
686            },
687            Parameter {
688                name: "ack_mode".to_string(),
689                type_hint: "string".to_string(),
690                description: "\"auto\" (default - the broker considers a message delivered as \
691                              soon as it is sent), \"client\" (cumulative: an ACK also \
692                              acknowledges everything before it) or \"client-individual\". \
693                              Anything but \"auto\" means you must answer each delivery with \
694                              send_stomp_ack or send_stomp_nack."
695                    .to_string(),
696                required: false,
697            },
698            Parameter {
699                name: "receipt".to_string(),
700                type_hint: "string".to_string(),
701                description: "Ask the broker to confirm this frame with a RECEIPT carrying this \
702                              id, delivered as a stomp_receipt_received event"
703                    .to_string(),
704                required: false,
705            },
706        ],
707        example: json!({
708            "type": "send_stomp_subscribe",
709            "destination": "/queue/test",
710            "id": "sub-0",
711            "ack_mode": "auto"
712        }),
713        log_template: None,
714    }
715}
716
717fn send_stomp_send_action() -> ActionDefinition {
718    ActionDefinition {
719        name: "send_stomp_send".to_string(),
720        description: "Publish a message to a destination. The body's encoding is explicit and \
721                      is never guessed: pass a stomp_message_received event's 'body' and \
722                      'body_encoding' straight through to republish the exact bytes."
723            .to_string(),
724        parameters: vec![
725            Parameter {
726                name: "destination".to_string(),
727                type_hint: "string".to_string(),
728                description: "Destination to publish to, e.g. /queue/replies".to_string(),
729                required: true,
730            },
731            Parameter {
732                name: "body".to_string(),
733                type_hint: "string".to_string(),
734                description: "Message body, interpreted according to 'encoding'".to_string(),
735                required: false,
736            },
737            Parameter {
738                name: "encoding".to_string(),
739                type_hint: "string".to_string(),
740                description: "\"utf8\" (default) or \"hex\" for binary bodies. There is no \
741                              sniffing - \"48656c6c6f\" is valid as both."
742                    .to_string(),
743                required: false,
744            },
745            Parameter {
746                name: "content_type".to_string(),
747                type_hint: "string".to_string(),
748                description: "Value for the content-type header, e.g. text/plain".to_string(),
749                required: false,
750            },
751            Parameter {
752                name: "headers".to_string(),
753                type_hint: "object".to_string(),
754                description: "Extra headers as a flat string map. content-length is computed \
755                              and cannot be set here; destination, content-type and receipt \
756                              have their own parameters."
757                    .to_string(),
758                required: false,
759            },
760            Parameter {
761                name: "receipt".to_string(),
762                type_hint: "string".to_string(),
763                description: "Ask the broker to confirm this publish with a RECEIPT carrying \
764                              this id"
765                    .to_string(),
766                required: false,
767            },
768        ],
769        example: json!({
770            "type": "send_stomp_send",
771            "destination": "/queue/replies",
772            "body": "hello from netget",
773            "encoding": "utf8",
774            "content_type": "text/plain"
775        }),
776        log_template: None,
777    }
778}
779
780fn send_stomp_unsubscribe_action() -> ActionDefinition {
781    ActionDefinition {
782        name: "send_stomp_unsubscribe".to_string(),
783        description: "Cancel a subscription. Takes the same id that was passed to \
784                      send_stomp_subscribe; the broker stops delivering for it."
785            .to_string(),
786        parameters: vec![
787            Parameter {
788                name: "id".to_string(),
789                type_hint: "string".to_string(),
790                description: "Subscription id to cancel".to_string(),
791                required: true,
792            },
793            Parameter {
794                name: "receipt".to_string(),
795                type_hint: "string".to_string(),
796                description: "Ask the broker to confirm with a RECEIPT carrying this id"
797                    .to_string(),
798                required: false,
799            },
800        ],
801        example: json!({"type": "send_stomp_unsubscribe", "id": "sub-0"}),
802        log_template: None,
803    }
804}
805
806fn send_stomp_ack_action() -> ActionDefinition {
807    ActionDefinition {
808        name: "send_stomp_ack".to_string(),
809        description: "Acknowledge a delivered message. Only meaningful when the subscription \
810                      was made with ack_mode \"client\" or \"client-individual\". The 'id' is \
811                      the delivered frame's own \"ack\" header, which arrives inside the \
812                      stomp_message_received event's 'headers' map - it is not the message_id."
813            .to_string(),
814        parameters: vec![
815            Parameter {
816                name: "id".to_string(),
817                type_hint: "string".to_string(),
818                description: "The delivered MESSAGE frame's \"ack\" header value".to_string(),
819                required: true,
820            },
821            Parameter {
822                name: "receipt".to_string(),
823                type_hint: "string".to_string(),
824                description: "Ask the broker to confirm with a RECEIPT carrying this id"
825                    .to_string(),
826                required: false,
827            },
828        ],
829        example: json!({"type": "send_stomp_ack", "id": "ack-1"}),
830        log_template: None,
831    }
832}
833
834fn send_stomp_nack_action() -> ActionDefinition {
835    ActionDefinition {
836        name: "send_stomp_nack".to_string(),
837        description: "Refuse a delivered message, telling the broker it was not processed. \
838                      Same 'id' as send_stomp_ack - the delivered frame's \"ack\" header. What \
839                      the broker then does with the message (redeliver, dead-letter, drop) is \
840                      its own policy."
841            .to_string(),
842        parameters: vec![
843            Parameter {
844                name: "id".to_string(),
845                type_hint: "string".to_string(),
846                description: "The delivered MESSAGE frame's \"ack\" header value".to_string(),
847                required: true,
848            },
849            Parameter {
850                name: "receipt".to_string(),
851                type_hint: "string".to_string(),
852                description: "Ask the broker to confirm with a RECEIPT carrying this id"
853                    .to_string(),
854                required: false,
855            },
856        ],
857        example: json!({"type": "send_stomp_nack", "id": "ack-1"}),
858        log_template: None,
859    }
860}
861
862fn wait_for_more_action() -> ActionDefinition {
863    ActionDefinition {
864        name: "wait_for_more".to_string(),
865        description: "Send nothing and keep the session open, waiting for the next frame. This \
866                      is a real answer, distinct from failing to answer."
867            .to_string(),
868        parameters: vec![],
869        example: json!({"type": "wait_for_more"}),
870        log_template: None,
871    }
872}
873
874fn disconnect_action() -> ActionDefinition {
875    ActionDefinition {
876        name: "disconnect".to_string(),
877        description: "End the session the way the specification defines it: a DISCONNECT frame \
878                      carrying a receipt, then the connection closes once the broker's RECEIPT \
879                      comes back. That acknowledgement is what tells a publisher the broker \
880                      durably took everything it sent, so this is not the same as hanging up."
881            .to_string(),
882        parameters: vec![],
883        example: json!({"type": "disconnect"}),
884        log_template: None,
885    }
886}