milestone: add runtime template state and dlq/retry broker topology
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user