envelope.rs (23341B)
1 #![allow(dead_code)] 2 3 use serde::Serialize; 4 use serde_json::{Value, json}; 5 6 pub const OUTPUT_SCHEMA_VERSION: &str = "radroots.cli.output.v1"; 7 8 #[derive(Debug, Clone, PartialEq, Eq)] 9 pub struct EnvelopeContext { 10 pub request_id: String, 11 pub correlation_id: Option<String>, 12 pub idempotency_key: Option<String>, 13 pub output_format: OutputFormat, 14 pub dry_run: bool, 15 pub actor: Option<EnvelopeActor>, 16 } 17 18 impl EnvelopeContext { 19 pub fn new(request_id: impl Into<String>, dry_run: bool) -> Self { 20 Self { 21 request_id: request_id.into(), 22 correlation_id: None, 23 idempotency_key: None, 24 output_format: OutputFormat::Human, 25 dry_run, 26 actor: None, 27 } 28 } 29 } 30 31 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 32 pub struct EnvelopeActor { 33 pub account_id: String, 34 pub role: String, 35 } 36 37 #[derive(Debug, Clone, PartialEq, Serialize)] 38 pub struct OutputEnvelope { 39 pub schema_version: &'static str, 40 pub operation_id: String, 41 pub kind: String, 42 pub status: OutputStatus, 43 pub output_format: OutputFormat, 44 pub request_id: String, 45 pub correlation_id: Option<String>, 46 pub idempotency_key: Option<String>, 47 pub dry_run: bool, 48 pub actor: Option<EnvelopeActor>, 49 pub resource: Option<OutputResource>, 50 pub result: Value, 51 pub reason_code: Option<String>, 52 pub warnings: Vec<OutputWarning>, 53 pub errors: Vec<OutputError>, 54 pub next_actions: Vec<NextAction>, 55 } 56 57 impl OutputEnvelope { 58 pub fn success( 59 operation_id: impl Into<String>, 60 result: Value, 61 context: EnvelopeContext, 62 ) -> Self { 63 let operation_id = operation_id.into(); 64 let resource = output_resource_from_value(&result); 65 let reason_code = output_reason_code_from_value(&result); 66 Self { 67 schema_version: OUTPUT_SCHEMA_VERSION, 68 kind: operation_id.clone(), 69 operation_id, 70 status: OutputStatus::Ok, 71 output_format: context.output_format, 72 request_id: context.request_id, 73 correlation_id: context.correlation_id, 74 idempotency_key: context.idempotency_key, 75 dry_run: context.dry_run, 76 actor: context.actor, 77 resource, 78 result, 79 reason_code, 80 warnings: Vec::new(), 81 errors: Vec::new(), 82 next_actions: Vec::new(), 83 } 84 } 85 86 pub fn failure( 87 operation_id: impl Into<String>, 88 error: OutputError, 89 context: EnvelopeContext, 90 ) -> Self { 91 let operation_id = operation_id.into(); 92 let next_actions = next_actions_from_error_detail(&error); 93 let resource = error.detail.as_ref().and_then(output_resource_from_value); 94 let reason_code = Some(error.reason_code.clone()); 95 Self { 96 schema_version: OUTPUT_SCHEMA_VERSION, 97 kind: operation_id.clone(), 98 operation_id, 99 status: OutputStatus::Error, 100 output_format: context.output_format, 101 request_id: context.request_id, 102 correlation_id: context.correlation_id, 103 idempotency_key: context.idempotency_key, 104 dry_run: context.dry_run, 105 actor: context.actor, 106 resource, 107 result: Value::Null, 108 reason_code, 109 warnings: Vec::new(), 110 errors: vec![error], 111 next_actions, 112 } 113 } 114 115 pub fn to_ndjson_frames(&self) -> Vec<NdjsonFrame> { 116 let started = NdjsonFrame::new( 117 self.operation_id.clone(), 118 self.request_id.clone(), 119 0, 120 NdjsonFrameType::Started, 121 json!({ 122 "state": "started", 123 "status": self.status, 124 "output_format": self.output_format, 125 "dry_run": self.dry_run, 126 "correlation_id": &self.correlation_id, 127 "idempotency_key": &self.idempotency_key, 128 "actor": &self.actor, 129 "resource": &self.resource, 130 }), 131 ); 132 let mut terminal = NdjsonFrame::new( 133 self.operation_id.clone(), 134 self.request_id.clone(), 135 1, 136 if self.errors.is_empty() { 137 NdjsonFrameType::Completed 138 } else { 139 NdjsonFrameType::Error 140 }, 141 json!({ 142 "status": self.status, 143 "reason_code": &self.reason_code, 144 "output_format": self.output_format, 145 "resource": &self.resource, 146 "result": &self.result, 147 "next_actions": &self.next_actions, 148 "dry_run": self.dry_run, 149 "correlation_id": &self.correlation_id, 150 "idempotency_key": &self.idempotency_key, 151 "actor": &self.actor, 152 }), 153 ); 154 terminal.warnings = self.warnings.clone(); 155 terminal.errors = self.errors.clone(); 156 vec![started, terminal] 157 } 158 } 159 160 fn output_reason_code_from_value(value: &Value) -> Option<String> { 161 value 162 .get("reason_code") 163 .and_then(Value::as_str) 164 .filter(|reason_code| !reason_code.trim().is_empty()) 165 .map(str::to_owned) 166 } 167 168 fn output_resource_from_value(value: &Value) -> Option<OutputResource> { 169 let object = value.as_object()?; 170 if let Some(resource) = object.get("resource").and_then(declared_output_resource) { 171 return Some(resource); 172 } 173 output_resource_from_fields(object).or_else(|| { 174 let nested_fields = [ 175 "account", 176 "resolved_account", 177 "default_account", 178 "bound_account", 179 "farm", 180 "listing", 181 "basket", 182 "quote", 183 "order", 184 ]; 185 nested_fields 186 .into_iter() 187 .filter_map(|field| { 188 object 189 .get(field) 190 .and_then(|value| nested_output_resource(field, value)) 191 }) 192 .next() 193 }) 194 } 195 196 fn nested_output_resource(field: &str, value: &Value) -> Option<OutputResource> { 197 let mut resource = output_resource_from_value(value)?; 198 if resource.kind == "resource" { 199 resource.kind = match field { 200 "resolved_account" | "default_account" | "bound_account" => "account", 201 other => other, 202 } 203 .to_owned(); 204 } 205 Some(resource) 206 } 207 208 fn declared_output_resource(value: &Value) -> Option<OutputResource> { 209 let object = value.as_object()?; 210 let kind = object 211 .get("kind") 212 .and_then(Value::as_str) 213 .filter(|kind| !kind.trim().is_empty())?; 214 let id = object 215 .get("id") 216 .and_then(Value::as_str) 217 .filter(|id| !id.trim().is_empty())?; 218 Some(OutputResource { 219 kind: kind.to_owned(), 220 id: id.to_owned(), 221 }) 222 } 223 224 fn output_resource_from_fields(object: &serde_json::Map<String, Value>) -> Option<OutputResource> { 225 [ 226 ("account_id", "account"), 227 ("id", "resource"), 228 ("farm_id", "farm"), 229 ("seller_account_id", "account"), 230 ("buyer_account_id", "account"), 231 ("listing_id", "listing"), 232 ("listing_address", "listing"), 233 ("listing_addr", "listing"), 234 ("basket_id", "basket"), 235 ("order_id", "order"), 236 ] 237 .into_iter() 238 .find_map(|(field, kind)| { 239 object 240 .get(field) 241 .and_then(Value::as_str) 242 .filter(|id| !id.trim().is_empty()) 243 .map(|id| OutputResource { 244 kind: kind.to_owned(), 245 id: id.to_owned(), 246 }) 247 }) 248 } 249 250 pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> { 251 next_actions_from_actions_value(result.get("actions")) 252 } 253 254 fn next_actions_from_error_detail(error: &OutputError) -> Vec<NextAction> { 255 next_actions_from_actions_value( 256 error 257 .detail 258 .as_ref() 259 .and_then(|detail| detail.get("actions")), 260 ) 261 } 262 263 fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAction> { 264 actions_value 265 .and_then(Value::as_array) 266 .into_iter() 267 .flatten() 268 .filter_map(Value::as_str) 269 .filter_map(next_action_from_action_string) 270 .fold(Vec::<NextAction>::new(), |mut actions, action| { 271 if !actions.contains(&action) { 272 actions.push(action); 273 } 274 actions 275 }) 276 } 277 278 fn next_action_from_action_string(action: &str) -> Option<NextAction> { 279 let action = action.trim(); 280 if action 281 == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" 282 { 283 return Some(NextAction { 284 kind: NextActionKind::OperatorConfig, 285 label: "configure radrootsd proxy token source".to_owned(), 286 command: None, 287 description: Some(action.to_owned()), 288 env_var: Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()), 289 config_key: None, 290 }); 291 } 292 if action == "configure signer.remote_nip46 signer_session_ref" { 293 return Some(NextAction { 294 kind: NextActionKind::OperatorConfig, 295 label: "configure signer session binding".to_owned(), 296 command: None, 297 description: Some(action.to_owned()), 298 env_var: None, 299 config_key: Some("signer.remote_nip46.signer_session_ref".to_owned()), 300 }); 301 } 302 let command = action.trim().strip_prefix("run ").unwrap_or(action).trim(); 303 if !command.starts_with("radroots ") { 304 return None; 305 } 306 Some(NextAction { 307 kind: NextActionKind::CliCommand, 308 label: next_action_label(command), 309 command: Some(command.to_owned()), 310 description: None, 311 env_var: None, 312 config_key: None, 313 }) 314 } 315 316 fn next_action_label(command: &str) -> String { 317 let parts = command.split_whitespace().collect::<Vec<_>>(); 318 let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots")); 319 let mut labels = Vec::new(); 320 while index < parts.len() { 321 let part = parts[index]; 322 if part.starts_with("--") { 323 index += 1; 324 if matches!( 325 part, 326 "--format" 327 | "--account-id" 328 | "--relay" 329 | "--publish-transport" 330 | "--idempotency-key" 331 | "--correlation-id" 332 | "--approval-token" 333 ) && index < parts.len() 334 { 335 index += 1; 336 } 337 continue; 338 } 339 labels.push(part); 340 index += 1; 341 } 342 if labels.is_empty() { 343 "radroots".to_owned() 344 } else { 345 labels.join(" ") 346 } 347 } 348 349 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 350 pub struct OutputWarning { 351 pub code: String, 352 pub message: String, 353 } 354 355 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 356 #[serde(rename_all = "snake_case")] 357 pub enum OutputStatus { 358 Ok, 359 Error, 360 } 361 362 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 363 #[serde(rename_all = "snake_case")] 364 pub enum OutputFormat { 365 Human, 366 Json, 367 Ndjson, 368 } 369 370 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 371 pub struct OutputResource { 372 pub kind: String, 373 pub id: String, 374 } 375 376 #[derive(Debug, Clone, PartialEq, Serialize)] 377 pub struct OutputError { 378 pub code: String, 379 pub reason_code: String, 380 pub message: String, 381 pub exit_code: u8, 382 pub detail: Option<Value>, 383 } 384 385 impl OutputError { 386 pub fn new( 387 code: impl Into<String>, 388 message: impl Into<String>, 389 exit_code: CliExitCode, 390 ) -> Self { 391 let code = code.into(); 392 Self { 393 reason_code: code.clone(), 394 code, 395 message: message.into(), 396 exit_code: exit_code.code(), 397 detail: None, 398 } 399 } 400 } 401 402 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 403 pub enum CliExitCode { 404 Success, 405 InternalError, 406 InvalidInput, 407 RuntimeUnavailable, 408 NotFound, 409 AuthorizationFailed, 410 ApprovalRequiredOrDenied, 411 SignerUnavailable, 412 SyncOrNetworkFailure, 413 Conflict, 414 ValidationFailed, 415 UnsafeOperationRefused, 416 } 417 418 impl CliExitCode { 419 pub fn code(self) -> u8 { 420 match self { 421 Self::Success => 0, 422 Self::InternalError => 1, 423 Self::InvalidInput => 2, 424 Self::RuntimeUnavailable => 3, 425 Self::NotFound => 4, 426 Self::AuthorizationFailed => 5, 427 Self::ApprovalRequiredOrDenied => 6, 428 Self::SignerUnavailable => 7, 429 Self::SyncOrNetworkFailure => 8, 430 Self::Conflict => 9, 431 Self::ValidationFailed => 10, 432 Self::UnsafeOperationRefused => 11, 433 } 434 } 435 } 436 437 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 438 pub struct NextAction { 439 pub kind: NextActionKind, 440 pub label: String, 441 #[serde(skip_serializing_if = "Option::is_none")] 442 pub command: Option<String>, 443 #[serde(skip_serializing_if = "Option::is_none")] 444 pub description: Option<String>, 445 #[serde(skip_serializing_if = "Option::is_none")] 446 pub env_var: Option<String>, 447 #[serde(skip_serializing_if = "Option::is_none")] 448 pub config_key: Option<String>, 449 } 450 451 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 452 #[serde(rename_all = "snake_case")] 453 pub enum NextActionKind { 454 CliCommand, 455 OperatorConfig, 456 } 457 458 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 459 #[serde(rename_all = "snake_case")] 460 pub enum NdjsonFrameType { 461 Started, 462 Event, 463 Progress, 464 Warning, 465 Error, 466 Completed, 467 Heartbeat, 468 } 469 470 #[derive(Debug, Clone, PartialEq, Serialize)] 471 pub struct NdjsonFrame { 472 pub schema_version: &'static str, 473 pub operation_id: String, 474 pub kind: String, 475 pub request_id: String, 476 pub sequence: u64, 477 pub frame_type: NdjsonFrameType, 478 pub payload: Value, 479 pub warnings: Vec<OutputWarning>, 480 pub errors: Vec<OutputError>, 481 } 482 483 impl NdjsonFrame { 484 pub fn new( 485 operation_id: impl Into<String>, 486 request_id: impl Into<String>, 487 sequence: u64, 488 frame_type: NdjsonFrameType, 489 payload: Value, 490 ) -> Self { 491 let operation_id = operation_id.into(); 492 Self { 493 schema_version: OUTPUT_SCHEMA_VERSION, 494 kind: operation_id.clone(), 495 operation_id, 496 request_id: request_id.into(), 497 sequence, 498 frame_type, 499 payload, 500 warnings: Vec::new(), 501 errors: Vec::new(), 502 } 503 } 504 } 505 506 #[cfg(test)] 507 mod tests { 508 use serde_json::{Value, json}; 509 510 use super::{ 511 CliExitCode, EnvelopeContext, NdjsonFrame, NdjsonFrameType, NextActionKind, 512 OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError, 513 }; 514 515 #[test] 516 fn success_envelope_serializes_required_fields() { 517 let mut context = EnvelopeContext::new("req_test", true); 518 context.correlation_id = Some("corr_test".to_owned()); 519 context.idempotency_key = Some("idem_test".to_owned()); 520 let envelope = OutputEnvelope::success( 521 "listing.publish", 522 json!({ "listing_id": "listing_test" }), 523 context, 524 ); 525 let value = serde_json::to_value(envelope).expect("serialize envelope"); 526 527 assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); 528 assert_eq!(value["operation_id"], "listing.publish"); 529 assert_eq!(value["kind"], "listing.publish"); 530 assert_eq!(value["status"], "ok"); 531 assert_eq!(value["output_format"], "human"); 532 assert_eq!(value["request_id"], "req_test"); 533 assert_eq!(value["correlation_id"], "corr_test"); 534 assert_eq!(value["idempotency_key"], "idem_test"); 535 assert_eq!(value["dry_run"], true); 536 assert_eq!(value["resource"]["kind"], "listing"); 537 assert_eq!(value["resource"]["id"], "listing_test"); 538 assert_eq!(value["result"]["listing_id"], "listing_test"); 539 assert_eq!(value["reason_code"], Value::Null); 540 assert_eq!(value["warnings"].as_array().unwrap().len(), 0); 541 assert_eq!(value["errors"].as_array().unwrap().len(), 0); 542 assert_eq!(value["next_actions"].as_array().unwrap().len(), 0); 543 } 544 545 #[test] 546 fn failure_envelope_carries_structured_error_and_exit_code() { 547 let error = OutputError::new( 548 "approval_required", 549 "operation requires approval token", 550 CliExitCode::ApprovalRequiredOrDenied, 551 ); 552 let envelope = OutputEnvelope::failure( 553 "order.submit", 554 error, 555 EnvelopeContext::new("req_order", false), 556 ); 557 let value = serde_json::to_value(envelope).expect("serialize envelope"); 558 559 assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); 560 assert_eq!(value["operation_id"], "order.submit"); 561 assert_eq!(value["status"], "error"); 562 assert_eq!(value["reason_code"], "approval_required"); 563 assert_eq!(value["result"], Value::Null); 564 assert_eq!(value["errors"][0]["code"], "approval_required"); 565 assert_eq!(value["errors"][0]["reason_code"], "approval_required"); 566 assert_eq!(value["errors"][0]["exit_code"], 6); 567 } 568 569 #[test] 570 fn failure_envelope_derives_next_actions_from_error_detail() { 571 let mut error = OutputError::new( 572 "not_found", 573 "order draft was not found", 574 CliExitCode::NotFound, 575 ); 576 error.detail = Some(json!({ 577 "actions": [ 578 "radroots order list", 579 "run radroots basket create" 580 ] 581 })); 582 let envelope = OutputEnvelope::failure( 583 "order.submit", 584 error, 585 EnvelopeContext::new("req_order", true), 586 ); 587 588 assert_eq!(envelope.next_actions.len(), 2); 589 assert_eq!(envelope.next_actions[0].kind, NextActionKind::CliCommand); 590 assert_eq!(envelope.next_actions[0].label, "order list"); 591 assert_eq!( 592 envelope.next_actions[0].command.as_deref(), 593 Some("radroots order list") 594 ); 595 assert_eq!(envelope.next_actions[1].kind, NextActionKind::CliCommand); 596 assert_eq!(envelope.next_actions[1].label, "basket create"); 597 assert_eq!( 598 envelope.next_actions[1].command.as_deref(), 599 Some("radroots basket create") 600 ); 601 } 602 603 #[test] 604 fn failure_envelope_derives_operator_config_next_actions() { 605 let mut error = OutputError::new( 606 "operation_unavailable", 607 "publish transport needs operator configuration", 608 CliExitCode::RuntimeUnavailable, 609 ); 610 error.detail = Some(json!({ 611 "actions": [ 612 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID", 613 "configure signer.remote_nip46 signer_session_ref", 614 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" 615 ] 616 })); 617 let envelope = OutputEnvelope::failure( 618 "config.get", 619 error, 620 EnvelopeContext::new("req_config", false), 621 ); 622 let value = serde_json::to_value(&envelope).expect("serialize envelope"); 623 624 assert_eq!(envelope.next_actions.len(), 2); 625 assert_eq!( 626 envelope.next_actions[0].kind, 627 NextActionKind::OperatorConfig 628 ); 629 assert_eq!( 630 envelope.next_actions[0].label, 631 "configure radrootsd proxy token source" 632 ); 633 assert_eq!(envelope.next_actions[0].command, None); 634 assert_eq!( 635 envelope.next_actions[0].env_var.as_deref(), 636 Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE") 637 ); 638 assert_eq!( 639 envelope.next_actions[1].kind, 640 NextActionKind::OperatorConfig 641 ); 642 assert_eq!( 643 envelope.next_actions[1].label, 644 "configure signer session binding" 645 ); 646 assert_eq!(envelope.next_actions[1].command, None); 647 assert_eq!( 648 envelope.next_actions[1].config_key.as_deref(), 649 Some("signer.remote_nip46.signer_session_ref") 650 ); 651 assert_eq!(value["next_actions"][0]["kind"], "operator_config"); 652 assert_eq!(value["next_actions"][0]["command"], Value::Null); 653 assert_eq!(value["next_actions"][1]["kind"], "operator_config"); 654 assert_eq!(value["next_actions"][1]["command"], Value::Null); 655 } 656 657 #[test] 658 fn ndjson_frames_serialize_one_json_object_per_line() { 659 let frames = [ 660 NdjsonFrame::new( 661 "sync.watch", 662 "req_watch", 663 0, 664 NdjsonFrameType::Started, 665 json!({ "state": "started" }), 666 ), 667 NdjsonFrame::new( 668 "sync.watch", 669 "req_watch", 670 1, 671 NdjsonFrameType::Event, 672 json!({ "state": "submitted" }), 673 ), 674 NdjsonFrame::new( 675 "sync.watch", 676 "req_watch", 677 2, 678 NdjsonFrameType::Completed, 679 json!({ "state": "complete" }), 680 ), 681 ]; 682 let rendered = frames 683 .iter() 684 .map(|frame| serde_json::to_string(frame).expect("serialize frame")) 685 .collect::<Vec<_>>() 686 .join("\n"); 687 688 for line in rendered.lines() { 689 let value: Value = serde_json::from_str(line).expect("line is json"); 690 assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); 691 assert_eq!(value["operation_id"], "sync.watch"); 692 assert!(value["frame_type"].is_string()); 693 } 694 } 695 696 #[test] 697 fn ndjson_terminal_frame_carries_status_reason_and_resource() { 698 let mut error = OutputError::new( 699 "not_implemented", 700 "operation is not implemented", 701 CliExitCode::RuntimeUnavailable, 702 ); 703 error.detail = Some(json!({ 704 "order_id": "ord_test", 705 })); 706 let envelope = OutputEnvelope::failure( 707 "test.operation", 708 error, 709 EnvelopeContext::new("req_test", false), 710 ); 711 let frames = envelope.to_ndjson_frames(); 712 713 assert_eq!(frames[0].payload["status"], "error"); 714 assert_eq!(frames[0].payload["output_format"], "human"); 715 assert_eq!(frames[1].payload["status"], "error"); 716 assert_eq!(frames[1].payload["reason_code"], "not_implemented"); 717 assert_eq!(frames[1].payload["resource"]["kind"], "order"); 718 assert_eq!(frames[1].payload["resource"]["id"], "ord_test"); 719 assert_eq!(frames[1].errors[0].reason_code, "not_implemented"); 720 } 721 722 #[test] 723 fn exit_code_contract_matches_handoff_range() { 724 assert_eq!(CliExitCode::Success.code(), 0); 725 assert_eq!(CliExitCode::InvalidInput.code(), 2); 726 assert_eq!(CliExitCode::ApprovalRequiredOrDenied.code(), 6); 727 assert_eq!(CliExitCode::UnsafeOperationRefused.code(), 11); 728 } 729 }