use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use ts_rs::TS;
use super::types::JsonRpcError;
pub const OPS_RESULT_METHOD: &str = "ops.result";
pub const OPS_CANCEL_METHOD: &str = "ops.cancel";
pub const OPS_SETTLED_METHOD: &str = "ops.settled";
pub const MIN_CLIENT_TTL_SECS: u64 = 10 * 60;
pub const MAX_CLIENT_TTL_SECS: u64 = 30 * 60;
pub const DEFAULT_CLIENT_TTL_SECS: u64 = MAX_CLIENT_TTL_SECS;
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS, JsonSchema,
)]
#[ts(export, export_to = "ops.ts")]
pub struct DeferredOpRef(String);
impl DeferredOpRef {
pub fn new_random() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_wire(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl std::fmt::Display for DeferredOpRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<DeferredOpRef> for String {
fn from(op: DeferredOpRef) -> Self {
op.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "ops.ts")]
pub enum DeferredOpStatus {
Pending,
Completed,
Failed,
}
impl DeferredOpStatus {
pub fn is_terminal(self) -> bool {
!matches!(self, Self::Pending)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, JsonSchema)]
#[ts(export, export_to = "ops.ts")]
pub struct DeferredOpCreated {
pub op_id: DeferredOpRef,
pub expires_in: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS, JsonSchema)]
#[ts(export, export_to = "ops.ts")]
pub struct DeferredOpOutcome {
pub op_id: DeferredOpRef,
pub status: DeferredOpStatus,
pub method: String,
pub expires_in: u64,
pub cancel_requested: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub result: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub error: Option<JsonRpcError>,
}
impl DeferredOpOutcome {
pub fn is_settled(&self) -> bool {
self.status.is_terminal()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, JsonSchema)]
#[ts(export, export_to = "ops.ts")]
pub struct OpsResultParams {
pub op_id: DeferredOpRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, JsonSchema)]
#[ts(export, export_to = "ops.ts")]
pub struct OpsCancelParams {
pub op_id: DeferredOpRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, JsonSchema)]
#[ts(export, export_to = "ops.ts")]
pub struct OpsCancelResult {
pub op_id: DeferredOpRef,
pub status: DeferredOpStatus,
pub cancel_requested: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, JsonSchema)]
#[ts(export, export_to = "ops.ts")]
pub struct DeferredOpSettledParams {
pub op_id: DeferredOpRef,
pub status: DeferredOpStatus,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn created_answer_uses_the_documented_wire_shape() {
let created = DeferredOpCreated {
op_id: DeferredOpRef::from_wire("op-1"),
expires_in: DEFAULT_CLIENT_TTL_SECS,
};
let wire = serde_json::to_value(&created).unwrap();
assert_eq!(wire, json!({"op_id": "op-1", "expires_in": 1800}));
let back: DeferredOpCreated = serde_json::from_value(wire).unwrap();
assert_eq!(back, created);
}
#[test]
fn op_ref_is_transparent_on_the_wire_and_unguessable() {
let op = DeferredOpRef::new_random();
assert_eq!(serde_json::to_value(&op).unwrap(), json!(op.as_str()));
let back: DeferredOpRef = serde_json::from_value(json!(op.as_str())).unwrap();
assert_eq!(back, op);
let ids: std::collections::HashSet<String> = (0..32)
.map(|_| DeferredOpRef::new_random().to_string())
.collect();
assert_eq!(ids.len(), 32);
for id in &ids {
assert_eq!(id.len(), 36, "uuid v4 hyphenated form: {id}");
assert_eq!(
id.chars().nth(14),
Some('4'),
"uuid v4 sets the version nibble: {id}"
);
}
}
#[test]
fn status_spellings_are_snake_case_and_terminality_is_exact() {
assert_eq!(
serde_json::to_value(DeferredOpStatus::Pending).unwrap(),
json!("pending")
);
assert_eq!(
serde_json::to_value(DeferredOpStatus::Completed).unwrap(),
json!("completed")
);
assert_eq!(
serde_json::to_value(DeferredOpStatus::Failed).unwrap(),
json!("failed")
);
assert!(!DeferredOpStatus::Pending.is_terminal());
assert!(DeferredOpStatus::Completed.is_terminal());
assert!(DeferredOpStatus::Failed.is_terminal());
for s in [
DeferredOpStatus::Pending,
DeferredOpStatus::Completed,
DeferredOpStatus::Failed,
] {
assert_eq!(serde_json::to_value(s).unwrap(), json!(s.as_str()));
}
}
#[test]
fn outcome_omits_the_absent_half_of_the_settlement() {
let pending = DeferredOpOutcome {
op_id: DeferredOpRef::from_wire("op-2"),
status: DeferredOpStatus::Pending,
method: "rescue.diagnose".into(),
expires_in: 1799,
cancel_requested: false,
result: None,
error: None,
};
let wire = serde_json::to_value(&pending).unwrap();
assert!(wire.get("result").is_none());
assert!(wire.get("error").is_none());
assert!(!pending.is_settled());
let completed = DeferredOpOutcome {
status: DeferredOpStatus::Completed,
result: Some(json!({"diagnosis": "ok"})),
..pending.clone()
};
let wire = serde_json::to_value(&completed).unwrap();
assert_eq!(wire["status"], "completed");
assert_eq!(wire["result"]["diagnosis"], "ok");
assert!(wire.get("error").is_none());
assert!(completed.is_settled());
let failed = DeferredOpOutcome {
status: DeferredOpStatus::Failed,
result: None,
error: Some(JsonRpcError::new(
super::super::error_codes::OPS_CANCELLED,
"operation cancelled",
)),
..pending
};
let wire = serde_json::to_value(&failed).unwrap();
assert_eq!(wire["status"], "failed");
assert_eq!(
wire["error"]["code"],
super::super::error_codes::OPS_CANCELLED
);
assert!(wire.get("result").is_none());
assert!(failed.is_settled());
}
#[test]
fn ops_params_round_trip_and_reject_a_missing_id() {
let params: OpsResultParams = serde_json::from_value(json!({"op_id": "op-3"})).unwrap();
assert_eq!(params.op_id.as_str(), "op-3");
assert!(serde_json::from_value::<OpsResultParams>(json!({})).is_err());
let cancel = OpsCancelResult {
op_id: DeferredOpRef::from_wire("op-3"),
status: DeferredOpStatus::Pending,
cancel_requested: true,
};
assert_eq!(
serde_json::to_value(&cancel).unwrap(),
json!({"op_id": "op-3", "status": "pending", "cancel_requested": true})
);
}
#[test]
fn settled_notification_carries_status_but_never_the_payload() {
let settled = DeferredOpSettledParams {
op_id: DeferredOpRef::from_wire("op-4"),
status: DeferredOpStatus::Completed,
};
let wire = serde_json::to_value(&settled).unwrap();
assert_eq!(
wire,
json!({"op_id": "op-4", "status": "completed"}),
"the notification must stay payload-free: collection is authoritative"
);
}
#[test]
fn method_names_and_ttl_band_are_the_published_contract() {
assert_eq!(OPS_RESULT_METHOD, "ops.result");
assert_eq!(OPS_CANCEL_METHOD, "ops.cancel");
assert_eq!(OPS_SETTLED_METHOD, "ops.settled");
assert_eq!(MIN_CLIENT_TTL_SECS, 600);
assert_eq!(MAX_CLIENT_TTL_SECS, 1800);
assert_eq!(DEFAULT_CLIENT_TTL_SECS, MAX_CLIENT_TTL_SECS);
}
}