milestone: add runtime template state and dlq/retry broker topology

This commit is contained in:
2026-04-06 18:41:17 -07:00
parent 836a968806
commit 516a740505
12 changed files with 838 additions and 273 deletions

View File

@@ -1,69 +1,86 @@
//! # brokers/payload.rs — AMQP Message Payload
//! # brokers/payload.rs — AMQP Envelope Contracts
//!
//! Defines the JSON body structure carried in all BEDS broker messages.
//! The AMQP envelope handles routing (type header, reply_to, correlation_id);
//! this struct is what lives in the message body.
//!
//! ## Wire Format
//!
//! ```json
//! {
//! "template": "usr",
//! "data": { "first_name": "joe", "status": "active" }
//! }
//! ```
//!
//! `template` names the data object (maps to a NamasteCore implementor).
//! `data` carries key/value pairs in user-facing field names — the template
//! maps these to actual schema names. Callers never specify primary keys on
//! writes; the template generates a GUID and returns it in the reply.
//!
//! ## Calling Agents
//! - `brokers::r_broker` — parsed from message body on fetch events
//! - `brokers::w_broker` — parsed from message body on write/update/delete events
//!
//! **Author:** mks
//! **Version:** 1.0
//!
//! ## History
//! * `2026-04-05` - mks - original coding
//! Strict 1.0 request/response contracts for broker message bodies.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The JSON body of every BEDS broker message.
///
/// # Examples
///
/// ```
/// use rustybeds::brokers::payload::BrokerPayload;
///
/// let json = r#"{"template":"usr","data":{"first_name":"joe"}}"#;
/// let payload: BrokerPayload = serde_json::from_str(json).unwrap();
/// assert_eq!(payload.template, "usr");
/// assert!(payload.data.contains_key("first_name"));
/// ```
///
/// Both read and write brokers parse this struct from the raw AMQP delivery
/// bytes. The operation type is carried in the AMQP `type` message property —
/// this struct carries the object identity and data payload.
///
/// # History
///
/// * `2026-04-05` - mks - original coding
pub const ENVELOPE_VERSION: &str = "1.0";
#[derive(Debug, Deserialize, Serialize)]
pub struct BrokerPayload {
/// Template identifier — names the NamasteCore implementor to dispatch to.
/// Matches the TLA convention from the template file (e.g. `"usr"`, `"pst"`).
pub struct BrokerRequestEnvelope {
pub version: String,
pub op: String,
pub template: String,
/// Key/value data pairs in user-facing field names.
/// For writes: the record to store (pkey excluded — generated by template).
/// For reads: query discriminants (field → value to match).
/// For deletes: discriminants identifying the record(s) to remove.
#[serde(default)]
pub data: HashMap<String, Value>,
pub correlation_id: String,
#[serde(default)]
pub payload: HashMap<String, Value>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BrokerResponseEnvelope {
pub version: String,
pub op: String,
pub correlation_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub payload: Value,
}
pub fn parse_request(bytes: &[u8]) -> Result<BrokerRequestEnvelope, String> {
let env: BrokerRequestEnvelope =
serde_json::from_slice(bytes).map_err(|e| format!("invalid envelope JSON: {}", e))?;
if env.version != ENVELOPE_VERSION {
return Err(format!(
"unsupported envelope version '{}', expected '{}'",
env.version, ENVELOPE_VERSION
));
}
if env.op.trim().is_empty() {
return Err("missing required field 'op'".to_string());
}
if env.template.trim().is_empty() {
return Err("missing required field 'template'".to_string());
}
Ok(env)
}
pub fn success_response(op: &str, correlation_id: &str, payload: Value) -> Vec<u8> {
let response = BrokerResponseEnvelope {
version: ENVELOPE_VERSION.to_string(),
op: op.to_string(),
correlation_id: correlation_id.to_string(),
status: "ok".to_string(),
error_code: None,
message: None,
payload,
};
serde_json::to_vec(&response).unwrap_or_else(|_| b"{}".to_vec())
}
pub fn error_response(op: &str, correlation_id: &str, code: &str, message: &str) -> Vec<u8> {
let response = BrokerResponseEnvelope {
version: ENVELOPE_VERSION.to_string(),
op: op.to_string(),
correlation_id: correlation_id.to_string(),
status: "error".to_string(),
error_code: Some(code.to_string()),
message: Some(message.to_string()),
payload: Value::Object(serde_json::Map::new()),
};
serde_json::to_vec(&response).unwrap_or_else(|_| b"{}".to_vec())
}
#[cfg(test)]
@@ -71,38 +88,38 @@ mod tests {
use super::*;
#[test]
fn deserializes_full_payload() {
let json = r#"{"template":"usr","data":{"first_name":"joe","status":"active"}}"#;
let payload: BrokerPayload = serde_json::from_str(json).unwrap();
assert_eq!(payload.template, "usr");
assert_eq!(payload.data.len(), 2);
assert_eq!(payload.data["first_name"], "joe");
assert_eq!(payload.data["status"], "active");
fn parses_valid_request_envelope() {
let json = r#"{"version":"1.0","op":"fetch","template":"Logger","correlation_id":"c1","payload":{"limit":10}}"#;
let env = parse_request(json.as_bytes()).expect("parse should succeed");
assert_eq!(env.version, "1.0");
assert_eq!(env.op, "fetch");
assert_eq!(env.template, "Logger");
assert_eq!(env.correlation_id, "c1");
assert_eq!(env.payload["limit"], 10);
}
#[test]
fn deserializes_without_data_field() {
// data is optional — fetch by template name alone is valid
let json = r#"{"template":"usr"}"#;
let payload: BrokerPayload = serde_json::from_str(json).unwrap();
assert_eq!(payload.template, "usr");
assert!(payload.data.is_empty());
fn rejects_legacy_unversioned_shape() {
let json = r#"{"template":"Logger","data":{"limit":10}}"#;
let err = parse_request(json.as_bytes()).expect_err("parse should fail");
assert!(err.contains("invalid envelope JSON") || err.contains("missing required"));
}
#[test]
fn serializes_round_trip() {
let json = r#"{"template":"pst","data":{"title":"hello"}}"#;
let payload: BrokerPayload = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&payload).unwrap();
let round_trip: BrokerPayload = serde_json::from_str(&serialized).unwrap();
assert_eq!(round_trip.template, payload.template);
assert_eq!(round_trip.data["title"], payload.data["title"]);
fn rejects_wrong_version() {
let json = r#"{"version":"0.9","op":"fetch","template":"Logger","payload":{}}"#;
let err = parse_request(json.as_bytes()).expect_err("parse should fail");
assert!(err.contains("unsupported envelope version"));
}
#[test]
fn rejects_missing_template() {
let json = r#"{"data":{"first_name":"joe"}}"#;
let result: Result<BrokerPayload, _> = serde_json::from_str(json);
assert!(result.is_err());
fn builds_error_response_envelope() {
let bytes = error_response("write", "c123", "INVALID_PAYLOAD", "bad");
let env: BrokerResponseEnvelope = serde_json::from_slice(&bytes).expect("response should parse");
assert_eq!(env.version, ENVELOPE_VERSION);
assert_eq!(env.op, "write");
assert_eq!(env.correlation_id, "c123");
assert_eq!(env.status, "error");
assert_eq!(env.error_code.as_deref(), Some("INVALID_PAYLOAD"));
}
}