use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::ClientError;
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct NotificationRequest {
pub event_type: String,
pub identifier: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct NotifyResponse {
pub status: String,
pub request_id: String,
pub processed_at: String,
}
impl NotificationRequest {
#[must_use]
pub fn new(event_type: impl Into<String>) -> Self {
Self {
event_type: event_type.into(),
identifier: BTreeMap::new(),
payload: None,
}
}
#[must_use]
pub fn with_identifier(mut self, identifier: BTreeMap<String, String>) -> Self {
self.identifier = identifier;
self
}
#[must_use]
pub fn with_payload(mut self, payload: Value) -> Self {
self.payload = Some(payload);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Notification {
pub event_type: String,
pub sequence: u64,
pub identifier: BTreeMap<String, String>,
pub payload: Value,
#[serde(skip)]
pub cloudevent: Option<Value>,
}
pub fn parse_cloudevent_id(id: &str) -> crate::Result<(String, u64)> {
let (event_type, sequence_str) = id
.rsplit_once('@')
.ok_or_else(|| ClientError::MalformedEvent(format!("missing '@' separator: {id:?}")))?;
if event_type.is_empty() {
return Err(ClientError::MalformedEvent(format!(
"empty event_type: {id:?}"
)));
}
let sequence: u64 = sequence_str
.parse()
.map_err(|_| ClientError::MalformedEvent(format!("sequence not a u64: {id:?}")))?;
Ok((event_type.to_string(), sequence))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "test code: panic-on-unwrap is the expected diagnostic"
)]
mod tests {
use super::{ClientError, parse_cloudevent_id};
#[test]
fn parses_valid_id() {
let (et, seq) = parse_cloudevent_id("mars@42").unwrap();
assert_eq!(et, "mars");
assert_eq!(seq, 42);
}
#[test]
fn rsplit_handles_event_type_with_at_inside() {
let (et, seq) = parse_cloudevent_id("weird@type@99").unwrap();
assert_eq!(et, "weird@type");
assert_eq!(seq, 99);
}
#[test]
fn rejects_missing_separator() {
let err = parse_cloudevent_id("mars").unwrap_err();
assert!(matches!(err, ClientError::MalformedEvent(_)));
}
#[test]
fn rejects_empty_event_type() {
let err = parse_cloudevent_id("@42").unwrap_err();
assert!(matches!(err, ClientError::MalformedEvent(_)));
}
#[test]
fn rejects_non_numeric_sequence() {
let err = parse_cloudevent_id("mars@abc").unwrap_err();
assert!(matches!(err, ClientError::MalformedEvent(_)));
}
#[test]
fn rejects_negative_sequence() {
let err = parse_cloudevent_id("mars@-1").unwrap_err();
assert!(matches!(err, ClientError::MalformedEvent(_)));
}
#[test]
fn rejects_empty_string() {
let err = parse_cloudevent_id("").unwrap_err();
assert!(matches!(err, ClientError::MalformedEvent(_)));
}
#[test]
fn accepts_max_u64_sequence() {
let id = format!("mars@{}", u64::MAX);
let (et, seq) = parse_cloudevent_id(&id).unwrap();
assert_eq!(et, "mars");
assert_eq!(seq, u64::MAX);
}
#[test]
fn rejects_sequence_one_past_u64_max() {
let err = parse_cloudevent_id("mars@18446744073709551616").unwrap_err();
assert!(matches!(err, ClientError::MalformedEvent(_)), "got {err:?}");
}
mod notification_request {
use std::collections::BTreeMap;
use crate::NotificationRequest;
#[test]
fn new_creates_request_with_event_type_only() {
let req = NotificationRequest::new("mars");
assert_eq!(req.event_type, "mars");
assert!(req.identifier.is_empty());
assert!(req.payload.is_none());
}
#[test]
fn builder_methods_set_optional_fields() {
let mut id = BTreeMap::new();
id.insert("country".to_string(), "uk".to_string());
let req = NotificationRequest::new("mars")
.with_identifier(id.clone())
.with_payload(serde_json::json!({ "location": "south" }));
assert_eq!(req.event_type, "mars");
assert_eq!(req.identifier, id);
assert_eq!(
req.payload,
Some(serde_json::json!({ "location": "south" }))
);
}
#[test]
fn serializes_to_expected_wire_shape() {
let mut id = BTreeMap::new();
id.insert("country".to_string(), "uk".to_string());
let req = NotificationRequest::new("mars")
.with_identifier(id)
.with_payload(serde_json::json!({ "location": "south" }));
let json = serde_json::to_value(&req).unwrap();
assert_eq!(
json,
serde_json::json!({
"event_type": "mars",
"identifier": { "country": "uk" },
"payload": { "location": "south" },
})
);
}
#[test]
fn omits_payload_field_when_none() {
let req = NotificationRequest::new("mars");
let json = serde_json::to_value(&req).unwrap();
assert!(
json.get("payload").is_none(),
"payload field must be omitted when None: {json}"
);
}
}
mod notify_response_serialize {
use crate::NotifyResponse;
#[test]
fn serializes_to_expected_wire_shape() {
let response: NotifyResponse = serde_json::from_value(serde_json::json!({
"status": "success",
"request_id": "req-abc",
"processed_at": "2026-05-17T12:34:56Z",
}))
.unwrap();
let json = serde_json::to_value(&response).unwrap();
assert_eq!(
json,
serde_json::json!({
"status": "success",
"request_id": "req-abc",
"processed_at": "2026-05-17T12:34:56Z",
})
);
}
}
mod notification_serialize {
use std::collections::BTreeMap;
use crate::Notification;
#[test]
fn serializes_to_expected_wire_shape_with_all_fields() {
let mut identifier = BTreeMap::new();
identifier.insert("country".to_string(), "uk".to_string());
let notification = Notification {
event_type: "mars".to_string(),
sequence: 42,
identifier,
payload: serde_json::json!({ "location": "south" }),
cloudevent: None,
};
let json = serde_json::to_value(¬ification).unwrap();
assert_eq!(
json,
serde_json::json!({
"event_type": "mars",
"sequence": 42,
"identifier": { "country": "uk" },
"payload": { "location": "south" },
})
);
}
#[test]
fn serializes_null_payload_as_json_null() {
let notification = Notification {
event_type: "mars".to_string(),
sequence: 7,
identifier: BTreeMap::new(),
payload: serde_json::Value::Null,
cloudevent: None,
};
let json = serde_json::to_value(¬ification).unwrap();
assert_eq!(json.get("payload"), Some(&serde_json::Value::Null));
assert!(
json.get("request_id").is_none(),
"Notification no longer has a request_id field; the session-level X-Request-ID is exposed via the `client.watch.subscribed` tracing event from the supervisor: {json}"
);
}
#[test]
fn no_request_id_field_in_serialized_form() {
let notification = Notification {
event_type: "mars".to_string(),
sequence: 7,
identifier: BTreeMap::new(),
payload: serde_json::Value::Null,
cloudevent: None,
};
let json = serde_json::to_value(¬ification).unwrap();
assert!(
json.get("request_id").is_none(),
"Notification.request_id was removed (the SSE protocol does not carry a per-notification request_id; the session-level X-Request-ID is emitted via `client.watch.subscribed` tracing): {json}"
);
}
}
}