1use crate::llm::actions::{
10 protocol_trait::{ActionResult, Protocol, Server},
11 ActionDefinition, Parameter,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::server::stomp::frame::{error_frame, receipt_frame, StompFrame};
16use crate::state::app_state::AppState;
17use anyhow::{Context, Result};
18use serde_json::json;
19use std::sync::LazyLock;
20
21pub const STOMP_VERSION: &str = "1.2";
25
26pub const STOMP_HEARTBEAT: &str = "0,0";
33
34pub struct StompProtocol;
35
36impl StompProtocol {
37 pub fn new() -> Self {
38 Self
39 }
40}
41
42impl Default for StompProtocol {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl Protocol for StompProtocol {
49 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
50 Vec::new()
53 }
54
55 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
56 vec![
57 send_stomp_connected_action(),
58 send_stomp_message_action(),
59 send_stomp_receipt_action(),
60 send_stomp_error_action(),
61 close_connection_action(),
62 ]
63 }
64
65 fn protocol_name(&self) -> &'static str {
66 "STOMP"
67 }
68
69 fn get_event_types(&self) -> Vec<EventType> {
70 get_stomp_event_types()
71 }
72
73 fn stack_name(&self) -> &'static str {
74 "ETH>IP>TCP>STOMP"
75 }
76
77 fn keywords(&self) -> Vec<&'static str> {
78 vec!["stomp"]
79 }
80
81 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
82 use crate::protocol::metadata::{
83 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
84 };
85
86 ProtocolMetadataV2::builder()
87 .state(DevelopmentState::Beta)
88 .privilege_requirement(PrivilegeRequirement::None)
89 .well_known_port(61613)
90 .implementation(
91 "Hand-rolled STOMP 1.2 codec over tokio TCP (src/server/stomp/frame.rs). No \
92 third-party STOMP crate on the server side: header escaping, the \
93 content-length/NUL body rule and the receipt handshake are ~300 lines and a \
94 dependency would not have removed the part that matters.",
95 )
96 .llm_control(
97 "Whether to admit a CONNECT, the contents of MESSAGE frames, and whether to \
98 refuse with ERROR. Framing, header escaping, receipts and heart-beat \
99 negotiation are deterministic Rust.",
100 )
101 .e2e_testing(
102 "Driven by the async-stomp 0.6.3 client (tests/server/stomp/e2e_test.rs), which \
103 is not #[ignore]d and cannot skip - it is a compiled-in crate dependency, so \
104 there is nothing to detect as missing. Connector::connect() completes the \
105 handshake and async-stomp's own parser decodes every later frame. \
106 raw_socket_test.rs covers what a client library cannot express (a NUL body, a \
107 frame before the handshake, a refused accept-version, broken framing) and \
108 codec_test.rs pins the codec against spec byte literals.",
109 )
110 .notes(
111 "Beta on the evidence of a real third-party client: async-stomp 0.6.3 completes \
112 CONNECT -> CONNECTED -> SUBSCRIBE -> MESSAGE -> SEND -> MESSAGE -> DISCONNECT \
113 -> RECEIPT -> close against this server, decoding each frame with its own \
114 parser, and separately decodes a refusal as an ERROR frame. A negative control \
115 was run: dropping the CONNECTED 'version' header makes async-stomp's handshake \
116 fail and the test fail, so it is not passing vacuously. NOT IMPLEMENTED, and \
117 none of it is hidden from the peer: heart-beating (always negotiated 0,0, \
118 which is a legal configuration rather than a silent omission), transactions \
119 (BEGIN/COMMIT/ABORT are acknowledged and otherwise ignored - holding messages \
120 until COMMIT would be storage, which protocols may not implement; the \
121 transaction header still reaches the model on the SEND event), subscription \
122 bookkeeping, message redelivery, STOMP 1.0/1.1 (refused with an ERROR naming \
123 version:1.2) and TLS. UNPROVEN: interop with brokers' own clients \
124 (ActiveMQ/RabbitMQ STOMP), and concurrent sessions.",
125 )
126 .max_inbound_bytes(crate::server::stomp::frame::MAX_FRAME_BYTES)
127 .build()
128 }
129
130 fn description(&self) -> &'static str {
131 "STOMP 1.2 message broker server"
132 }
133
134 fn example_prompt(&self) -> &'static str {
135 "STOMP broker on port 61613 - accept any login and deliver a greeting message to \
136 whoever subscribes to /queue/test"
137 }
138
139 fn group_name(&self) -> &'static str {
140 "Application"
141 }
142
143 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
144 use crate::llm::actions::StartupExamples;
145 StartupExamples::new(
146 json!({
147 "type": "open_server",
148 "port": 61613,
149 "base_stack": "stomp",
150 "instruction": "STOMP broker: accept every CONNECT, and when a client subscribes to a destination send it one MESSAGE describing that destination"
151 }),
152 json!({
153 "type": "open_server",
154 "port": 61613,
155 "base_stack": "stomp",
156 "event_handlers": [{
157 "event_pattern": "stomp_connect",
158 "handler": {
159 "type": "script",
160 "language": "python",
161 "code": "# Admit anyone, naming the session after the login they offered\nlogin = event.get('login') or 'anonymous'\nrespond([{'type': 'send_stomp_connected', 'session': 'session-' + login, 'server': 'netget/stomp'}])"
162 }
163 }]
164 }),
165 json!({
166 "type": "open_server",
167 "port": 61613,
168 "base_stack": "stomp",
169 "event_handlers": [{
170 "event_pattern": "stomp_connect",
171 "handler": {
172 "type": "static",
173 "actions": [{
174 "type": "send_stomp_connected",
175 "version": "1.2",
176 "session": "session-1",
177 "server": "netget/stomp"
178 }]
179 }
180 }]
181 }),
182 )
183 }
184}
185
186impl Server for StompProtocol {
187 fn spawn(
188 &self,
189 ctx: crate::protocol::SpawnContext,
190 ) -> std::pin::Pin<
191 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
192 > {
193 Box::pin(async move {
194 use crate::server::stomp::StompServer;
195 StompServer::spawn_with_llm_actions(
196 ctx.legacy_listen_addr(),
197 ctx.llm_client,
198 ctx.state,
199 ctx.status_tx,
200 ctx.server_id,
201 )
202 .await
203 })
204 }
205
206 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
207 let action_type = action
208 .get("type")
209 .and_then(|v| v.as_str())
210 .context("Missing 'type' field in action")?;
211
212 match action_type {
213 "send_stomp_connected" => execute_send_connected(&action),
214 "send_stomp_message" => execute_send_message(&action),
215 "send_stomp_receipt" => execute_send_receipt(&action),
216 "send_stomp_error" => execute_send_error(&action),
217 "close_connection" => Ok(ActionResult::CloseConnection),
218 other => Err(anyhow::anyhow!("Unknown STOMP action: {}", other)),
219 }
220 }
221}
222
223fn execute_send_connected(action: &serde_json::Value) -> Result<ActionResult> {
226 let version = action
227 .get("version")
228 .and_then(|v| v.as_str())
229 .unwrap_or(STOMP_VERSION);
230 if version != STOMP_VERSION {
231 return Err(anyhow::anyhow!(
232 "This server implements STOMP {STOMP_VERSION} only; 'version' must be \
233 \"{STOMP_VERSION}\" (got {version:?})"
234 ));
235 }
236
237 let mut headers = vec![("version".to_string(), version.to_string())];
238 if let Some(session) = action.get("session").and_then(|v| v.as_str()) {
239 reject_unescapable("session", session)?;
240 headers.push(("session".to_string(), session.to_string()));
241 }
242 if let Some(server) = action.get("server").and_then(|v| v.as_str()) {
243 reject_unescapable("server", server)?;
244 headers.push(("server".to_string(), server.to_string()));
245 }
246 headers.push(("heart-beat".to_string(), STOMP_HEARTBEAT.to_string()));
248
249 Ok(ActionResult::Output(
250 StompFrame::new("CONNECTED", headers, Vec::new()).encode(),
251 ))
252}
253
254fn reject_unescapable(field: &str, value: &str) -> Result<()> {
266 if !crate::server::stomp::frame::is_safe_unescaped_header(value) {
267 return Err(anyhow::anyhow!(
268 "'{field}' may not contain a newline, carriage return, colon or NUL: CONNECTED \
269 headers are exempt from STOMP 1.2 escaping, so such a value would forge a header \
270 rather than appear in this one"
271 ));
272 }
273 Ok(())
274}
275
276fn execute_send_message(action: &serde_json::Value) -> Result<ActionResult> {
277 let destination = required_str(action, "destination")?;
278 let subscription = required_str(action, "subscription")?;
279 let message_id = required_str(action, "message_id")?;
280 let body = decode_body(action)?;
281
282 let mut headers = vec![
283 ("destination".to_string(), destination),
284 ("subscription".to_string(), subscription),
285 ("message-id".to_string(), message_id),
286 ];
287 if let Some(content_type) = action.get("content_type").and_then(|v| v.as_str()) {
288 headers.push(("content-type".to_string(), content_type.to_string()));
289 }
290 headers.extend(extra_headers(action)?);
291
292 Ok(ActionResult::Output(
293 StompFrame::new("MESSAGE", headers, body).encode(),
294 ))
295}
296
297fn execute_send_receipt(action: &serde_json::Value) -> Result<ActionResult> {
298 let receipt_id = required_str(action, "receipt_id")?;
299 Ok(ActionResult::Output(receipt_frame(&receipt_id)))
300}
301
302fn execute_send_error(action: &serde_json::Value) -> Result<ActionResult> {
303 let message = required_str(action, "message")?;
304 let body = action
305 .get("body")
306 .and_then(|v| v.as_str())
307 .unwrap_or("")
308 .to_string();
309 Ok(ActionResult::Output(error_frame(&message, &body)))
310}
311
312fn required_str(action: &serde_json::Value, key: &str) -> Result<String> {
313 action
314 .get(key)
315 .and_then(|v| v.as_str())
316 .map(|s| s.to_string())
317 .with_context(|| format!("Missing or non-string '{key}' parameter"))
318}
319
320fn decode_body(action: &serde_json::Value) -> Result<Vec<u8>> {
327 let body = action.get("body").and_then(|v| v.as_str()).unwrap_or("");
328 match action
329 .get("encoding")
330 .and_then(|v| v.as_str())
331 .unwrap_or("utf8")
332 {
333 "utf8" => Ok(body.as_bytes().to_vec()),
334 "hex" => {
335 let cleaned: String = body
336 .trim()
337 .trim_start_matches("0x")
338 .chars()
339 .filter(|c| !c.is_whitespace() && *c != ':')
340 .collect();
341 hex::decode(&cleaned).map_err(|e| {
342 anyhow::anyhow!("'body' is not valid hex although encoding is \"hex\": {e}")
343 })
344 }
345 other => Err(anyhow::anyhow!(
346 "Unknown 'encoding' {other:?}; valid values are \"utf8\" (default) and \"hex\""
347 )),
348 }
349}
350
351fn extra_headers(action: &serde_json::Value) -> Result<Vec<(String, String)>> {
358 let Some(map) = action.get("headers").and_then(|v| v.as_object()) else {
359 return Ok(Vec::new());
360 };
361 let mut out = Vec::new();
362 for (k, v) in map {
363 if k == "content-length" {
364 continue;
365 }
366 let value = match v {
367 serde_json::Value::String(s) => s.clone(),
368 serde_json::Value::Number(n) => n.to_string(),
369 serde_json::Value::Bool(b) => b.to_string(),
370 serde_json::Value::Null => continue,
371 _ => {
372 return Err(anyhow::anyhow!(
373 "header {k:?} must be a string, number or boolean; a STOMP header cannot \
374 carry a nested object or array"
375 ))
376 }
377 };
378 out.push((k.clone(), value));
379 }
380 Ok(out)
381}
382
383fn send_stomp_connected_action() -> ActionDefinition {
386 ActionDefinition {
387 name: "send_stomp_connected".to_string(),
388 description: format!(
389 "Accept the client's CONNECT and open the session. This or send_stomp_error is the \
390 only valid answer to stomp_connect - STOMP has no third option, and a client that \
391 gets neither blocks. Heart-beating is always negotiated off ({STOMP_HEARTBEAT}) \
392 because this server implements no heart-beat timer, so there is no parameter for it."
393 ),
394 parameters: vec![
395 Parameter {
396 name: "version".to_string(),
397 type_hint: "string".to_string(),
398 description: format!(
399 "Negotiated protocol version. Only \"{STOMP_VERSION}\" is implemented; \
400 omit to use it."
401 ),
402 required: false,
403 },
404 Parameter {
405 name: "session".to_string(),
406 type_hint: "string".to_string(),
407 description: "Session identifier shown to the client".to_string(),
408 required: false,
409 },
410 Parameter {
411 name: "server".to_string(),
412 type_hint: "string".to_string(),
413 description: "Server name/version string, e.g. \"netget/stomp\"".to_string(),
414 required: false,
415 },
416 ],
417 example: json!({
418 "type": "send_stomp_connected",
419 "version": "1.2",
420 "session": "session-1",
421 "server": "netget/stomp"
422 }),
423 log_template: Some(
424 LogTemplate::new()
425 .with_info("-> STOMP CONNECTED (session {session})")
426 .with_debug("STOMP send_stomp_connected: session={session} server={server}"),
427 ),
428 }
429}
430
431fn send_stomp_message_action() -> ActionDefinition {
432 ActionDefinition {
433 name: "send_stomp_message".to_string(),
434 description: "Deliver a MESSAGE frame to the client. `subscription` must be the id the \
435 client used in its SUBSCRIBE, or the client will discard the frame; \
436 `message_id` is yours to choose and must be unique within the session."
437 .to_string(),
438 parameters: vec![
439 Parameter {
440 name: "destination".to_string(),
441 type_hint: "string".to_string(),
442 description: "Destination the message belongs to, e.g. \"/queue/test\"".to_string(),
443 required: true,
444 },
445 Parameter {
446 name: "subscription".to_string(),
447 type_hint: "string".to_string(),
448 description: "The subscription id from the client's SUBSCRIBE frame".to_string(),
449 required: true,
450 },
451 Parameter {
452 name: "message_id".to_string(),
453 type_hint: "string".to_string(),
454 description: "Unique identifier for this message".to_string(),
455 required: true,
456 },
457 Parameter {
458 name: "body".to_string(),
459 type_hint: "string".to_string(),
460 description: "Message body, interpreted according to `encoding`".to_string(),
461 required: false,
462 },
463 Parameter {
464 name: "encoding".to_string(),
465 type_hint: "string".to_string(),
466 description: "How to read `body`: \"utf8\" (default) sends the characters as \
467 they are; \"hex\" decodes hex digits into bytes. There is no \
468 sniffing - say which you mean."
469 .to_string(),
470 required: false,
471 },
472 Parameter {
473 name: "content_type".to_string(),
474 type_hint: "string".to_string(),
475 description: "MIME type of the body, e.g. \"text/plain\"".to_string(),
476 required: false,
477 },
478 Parameter {
479 name: "headers".to_string(),
480 type_hint: "object".to_string(),
481 description: "Additional headers as a flat string-to-string map. \
482 `content-length` is ignored - it is computed from the body."
483 .to_string(),
484 required: false,
485 },
486 ],
487 example: json!({
488 "type": "send_stomp_message",
489 "destination": "/queue/test",
490 "subscription": "sub-0",
491 "message_id": "msg-1",
492 "content_type": "text/plain",
493 "body": "hello from netget",
494 "encoding": "utf8"
495 }),
496 log_template: Some(
497 LogTemplate::new()
498 .with_info("-> STOMP MESSAGE {destination} (sub {subscription})")
499 .with_debug(
500 "STOMP send_stomp_message: destination={destination} \
501 subscription={subscription} message_id={message_id}",
502 ),
503 ),
504 }
505}
506
507fn send_stomp_receipt_action() -> ActionDefinition {
508 ActionDefinition {
509 name: "send_stomp_receipt".to_string(),
510 description: "Send a RECEIPT frame. You rarely need this: any client frame carrying a \
511 `receipt` header is answered with a RECEIPT automatically once the frame \
512 has been processed. Use it only to acknowledge something out of band."
513 .to_string(),
514 parameters: vec![Parameter {
515 name: "receipt_id".to_string(),
516 type_hint: "string".to_string(),
517 description: "Value of the client's `receipt` header".to_string(),
518 required: true,
519 }],
520 example: json!({"type": "send_stomp_receipt", "receipt_id": "receipt-1"}),
521 log_template: Some(
522 LogTemplate::new()
523 .with_info("-> STOMP RECEIPT {receipt_id}")
524 .with_debug("STOMP send_stomp_receipt: receipt_id={receipt_id}"),
525 ),
526 }
527}
528
529fn send_stomp_error_action() -> ActionDefinition {
530 ActionDefinition {
531 name: "send_stomp_error".to_string(),
532 description: "Refuse with an ERROR frame. The STOMP spec requires the connection to be \
533 closed afterwards, and this server does so automatically - so this is a \
534 final answer, not something to follow with more frames."
535 .to_string(),
536 parameters: vec![
537 Parameter {
538 name: "message".to_string(),
539 type_hint: "string".to_string(),
540 description: "Short reason, becomes the `message` header".to_string(),
541 required: true,
542 },
543 Parameter {
544 name: "body".to_string(),
545 type_hint: "string".to_string(),
546 description: "Longer explanation, becomes the frame body".to_string(),
547 required: false,
548 },
549 ],
550 example: json!({
551 "type": "send_stomp_error",
552 "message": "authentication failed",
553 "body": "The login/passcode pair was not recognised."
554 }),
555 log_template: Some(
556 LogTemplate::new()
557 .with_info("-> STOMP ERROR: {message}")
558 .with_debug("STOMP send_stomp_error: {message}"),
559 ),
560 }
561}
562
563fn close_connection_action() -> ActionDefinition {
564 ActionDefinition {
565 name: "close_connection".to_string(),
566 description: "Close the STOMP connection without saying anything further".to_string(),
567 parameters: vec![],
568 example: json!({"type": "close_connection"}),
569 log_template: Some(
570 LogTemplate::new()
571 .with_info("STOMP connection closed")
572 .with_debug("STOMP close_connection"),
573 ),
574 }
575}
576
577fn session_actions() -> Vec<ActionDefinition> {
581 vec![
582 send_stomp_message_action(),
583 send_stomp_receipt_action(),
584 send_stomp_error_action(),
585 close_connection_action(),
586 ]
587}
588
589pub static STOMP_CONNECT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
591 EventType::new(
592 "stomp_connect",
593 "Client opened a STOMP session with a CONNECT or STOMP frame. Answer with \
594 send_stomp_connected to admit it or send_stomp_error to refuse; the client blocks \
595 until one of the two arrives.",
596 json!({
597 "type": "send_stomp_connected",
598 "version": "1.2",
599 "session": "session-1",
600 "server": "netget/stomp"
601 }),
602 )
603 .with_parameters(vec![
604 Parameter {
605 name: "accept_version".to_string(),
606 type_hint: "string".to_string(),
607 description: "Comma-separated versions the client accepts".to_string(),
608 required: false,
609 },
610 Parameter {
611 name: "host".to_string(),
612 type_hint: "string".to_string(),
613 description: "Virtual host the client asked for".to_string(),
614 required: false,
615 },
616 Parameter {
617 name: "login".to_string(),
618 type_hint: "string".to_string(),
619 description: "Login name offered by the client, if any".to_string(),
620 required: false,
621 },
622 Parameter {
623 name: "passcode".to_string(),
624 type_hint: "string".to_string(),
625 description: "Passcode offered by the client, if any".to_string(),
626 required: false,
627 },
628 Parameter {
629 name: "heart_beat".to_string(),
630 type_hint: "string".to_string(),
631 description: "The client's requested heart-beat pair. Informational only: this \
632 server always negotiates 0,0."
633 .to_string(),
634 required: false,
635 },
636 ])
637 .with_log_template(
638 LogTemplate::new()
639 .with_info("STOMP CONNECT host={host} login={login}")
640 .with_debug("STOMP connect: accept_version={accept_version} host={host} login={login}")
641 .with_trace("STOMP connect: {json_pretty(.)}"),
642 )
643 .with_actions(vec![
644 send_stomp_connected_action(),
645 send_stomp_error_action(),
646 close_connection_action(),
647 ])
648 .with_alternative_example(json!({
649 "type": "send_stomp_error",
650 "message": "authentication failed",
651 "body": "The login/passcode pair was not recognised."
652 }))
653});
654
655pub static STOMP_SEND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
657 EventType::new(
658 "stomp_send",
659 "Client published a message to a destination. A broker normally says nothing in reply, \
660 so answering with no action is legitimate; deliver it onward with send_stomp_message \
661 if a subscriber should see it.",
662 json!({
663 "type": "send_stomp_message",
664 "destination": "/queue/test",
665 "subscription": "sub-0",
666 "message_id": "msg-1",
667 "body": "hello from netget",
668 "encoding": "utf8"
669 }),
670 )
671 .with_parameters(vec![
672 Parameter {
673 name: "destination".to_string(),
674 type_hint: "string".to_string(),
675 description: "Destination the client published to".to_string(),
676 required: true,
677 },
678 Parameter {
679 name: "body".to_string(),
680 type_hint: "string".to_string(),
681 description: "The message body, read according to body_encoding".to_string(),
682 required: false,
683 },
684 Parameter {
685 name: "body_encoding".to_string(),
686 type_hint: "string".to_string(),
687 description: "\"utf8\" when the body was printable text, \"hex\" when it was not. \
688 Pass it through to send_stomp_message's `encoding` to echo the exact \
689 bytes back."
690 .to_string(),
691 required: true,
692 },
693 Parameter {
694 name: "headers".to_string(),
695 type_hint: "object".to_string(),
696 description: "Every other header on the frame, including content-type and any \
697 transaction id"
698 .to_string(),
699 required: false,
700 },
701 ])
702 .with_log_template(
703 LogTemplate::new()
704 .with_info("STOMP SEND {destination}")
705 .with_debug("STOMP send: destination={destination} encoding={body_encoding}")
706 .with_trace("STOMP send: {json_pretty(.)}"),
707 )
708 .with_actions(session_actions())
709});
710
711pub static STOMP_SUBSCRIBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
713 EventType::new(
714 "stomp_subscribe",
715 "Client subscribed to a destination. Send it messages with send_stomp_message, quoting \
716 this subscription id.",
717 json!({
718 "type": "send_stomp_message",
719 "destination": "/queue/test",
720 "subscription": "sub-0",
721 "message_id": "msg-1",
722 "body": "welcome",
723 "encoding": "utf8"
724 }),
725 )
726 .with_parameters(vec![
727 Parameter {
728 name: "destination".to_string(),
729 type_hint: "string".to_string(),
730 description: "Destination subscribed to".to_string(),
731 required: true,
732 },
733 Parameter {
734 name: "id".to_string(),
735 type_hint: "string".to_string(),
736 description: "Subscription id chosen by the client; quote it in send_stomp_message"
737 .to_string(),
738 required: true,
739 },
740 Parameter {
741 name: "ack_mode".to_string(),
742 type_hint: "string".to_string(),
743 description: "\"auto\" (default), \"client\" or \"client-individual\"".to_string(),
744 required: false,
745 },
746 ])
747 .with_log_template(
748 LogTemplate::new()
749 .with_info("STOMP SUBSCRIBE {destination} (id {id})")
750 .with_debug("STOMP subscribe: destination={destination} id={id} ack={ack_mode}")
751 .with_trace("STOMP subscribe: {json_pretty(.)}"),
752 )
753 .with_actions(session_actions())
754});
755
756pub static STOMP_UNSUBSCRIBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
758 EventType::new(
759 "stomp_unsubscribe",
760 "Client cancelled a subscription. Answering with no action is the normal case - any \
761 receipt the frame asked for is sent for you. Use send_stomp_message only if a \
762 message still has to go out on another subscription.",
763 json!({
764 "type": "send_stomp_message",
765 "destination": "/queue/test",
766 "subscription": "sub-1",
767 "message_id": "msg-9",
768 "body": "sent on a subscription that is still open",
769 "encoding": "utf8"
770 }),
771 )
772 .with_parameters(vec![Parameter {
773 name: "id".to_string(),
774 type_hint: "string".to_string(),
775 description: "Subscription id being cancelled".to_string(),
776 required: true,
777 }])
778 .with_log_template(
779 LogTemplate::new()
780 .with_info("STOMP UNSUBSCRIBE id={id}")
781 .with_debug("STOMP unsubscribe: id={id}")
782 .with_trace("STOMP unsubscribe: {json_pretty(.)}"),
783 )
784 .with_actions(session_actions())
785});
786
787pub static STOMP_ACK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
789 EventType::new(
790 "stomp_ack",
791 "Client acknowledged a message it had been sent. Answering with no action is \
792 legitimate; the usual reason to answer is to send the next message now that this one \
793 is settled. Any receipt the frame asked for is sent for you.",
794 json!({
795 "type": "send_stomp_message",
796 "destination": "/queue/test",
797 "subscription": "sub-0",
798 "message_id": "msg-2",
799 "body": "the next message",
800 "encoding": "utf8"
801 }),
802 )
803 .with_parameters(vec![Parameter {
804 name: "id".to_string(),
805 type_hint: "string".to_string(),
806 description: "The ack id, which is the message-id of the MESSAGE being acknowledged"
807 .to_string(),
808 required: true,
809 }])
810 .with_log_template(
811 LogTemplate::new()
812 .with_info("STOMP ACK id={id}")
813 .with_debug("STOMP ack: id={id}")
814 .with_trace("STOMP ack: {json_pretty(.)}"),
815 )
816 .with_actions(session_actions())
817});
818
819pub static STOMP_NACK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
821 EventType::new(
822 "stomp_nack",
823 "Client rejected a message it had been sent. This server does not redeliver on its own \
824 - resend it with send_stomp_message if that is what you want, or answer with no \
825 action to drop it. Any receipt the frame asked for is sent for you.",
826 json!({
827 "type": "send_stomp_message",
828 "destination": "/queue/test",
829 "subscription": "sub-0",
830 "message_id": "msg-1-retry",
831 "body": "redelivered after NACK",
832 "encoding": "utf8"
833 }),
834 )
835 .with_parameters(vec![Parameter {
836 name: "id".to_string(),
837 type_hint: "string".to_string(),
838 description: "The ack id, which is the message-id of the MESSAGE being rejected"
839 .to_string(),
840 required: true,
841 }])
842 .with_log_template(
843 LogTemplate::new()
844 .with_info("STOMP NACK id={id}")
845 .with_debug("STOMP nack: id={id}")
846 .with_trace("STOMP nack: {json_pretty(.)}"),
847 )
848 .with_actions(session_actions())
849});
850
851pub static STOMP_DISCONNECT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
853 EventType::new(
854 "stomp_disconnect",
855 "Client is shutting the session down. The connection closes as soon as this is handled \
856 (and after its receipt, if it asked for one), so this is the last chance to say \
857 anything.",
858 json!({"type": "close_connection"}),
859 )
860 .with_parameters(vec![Parameter {
861 name: "receipt".to_string(),
862 type_hint: "string".to_string(),
863 description: "The receipt id the client asked for, if any. It is answered \
864 automatically; this field is here so a handler can see it."
865 .to_string(),
866 required: false,
867 }])
868 .with_log_template(
869 LogTemplate::new()
870 .with_info("STOMP DISCONNECT")
871 .with_debug("STOMP disconnect: receipt={receipt}")
872 .with_trace("STOMP disconnect: {json_pretty(.)}"),
873 )
874 .with_actions(vec![
875 send_stomp_message_action(),
876 send_stomp_receipt_action(),
877 close_connection_action(),
878 ])
879});
880
881pub fn get_stomp_event_types() -> Vec<EventType> {
882 vec![
883 STOMP_CONNECT_EVENT.clone(),
884 STOMP_SEND_EVENT.clone(),
885 STOMP_SUBSCRIBE_EVENT.clone(),
886 STOMP_UNSUBSCRIBE_EVENT.clone(),
887 STOMP_ACK_EVENT.clone(),
888 STOMP_NACK_EVENT.clone(),
889 STOMP_DISCONNECT_EVENT.clone(),
890 ]
891}