listing.rs (24670B)
1 use std::path::PathBuf; 2 3 use serde::Serialize; 4 use serde_json::Value; 5 6 use crate::cli::global::{ 7 ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs, 8 ListingRebindArgs, RecordLookupArgs, 9 }; 10 use crate::ops::{ 11 ListingAppExportRequest, ListingAppExportResult, ListingAppListRequest, ListingAppListResult, 12 ListingArchiveRequest, ListingArchiveResult, ListingCreateRequest, ListingCreateResult, 13 ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult, 14 ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult, 15 ListingUpdateRequest, ListingUpdateResult, ListingValidateRequest, ListingValidateResult, 16 OperationAdapterError, OperationNetworkMode, OperationRequest, OperationRequestData, 17 OperationRequestPayload, OperationResult, OperationResultData, OperationService, 18 }; 19 use crate::runtime::RuntimeError; 20 use crate::runtime::config::RuntimeConfig; 21 use crate::view::runtime::{CommandDisposition, ListingAppRecordExportView, ListingMutationView}; 22 23 pub struct ListingOperationService<'a> { 24 config: &'a RuntimeConfig, 25 } 26 27 impl<'a> ListingOperationService<'a> { 28 pub fn new(config: &'a RuntimeConfig) -> Self { 29 Self { config } 30 } 31 } 32 33 impl OperationService<ListingCreateRequest> for ListingOperationService<'_> { 34 type Result = ListingCreateResult; 35 36 fn execute( 37 &self, 38 request: OperationRequest<ListingCreateRequest>, 39 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 40 let args = ListingCreateArgs { 41 output: optional_path(&request, "output"), 42 key: string_input(&request, "key"), 43 title: string_input(&request, "title"), 44 category: string_input(&request, "category"), 45 summary: string_input(&request, "summary"), 46 bin_id: string_input(&request, "bin_id"), 47 quantity_amount: string_input(&request, "quantity_amount"), 48 quantity_unit: string_input(&request, "quantity_unit"), 49 price_amount: string_input(&request, "price_amount"), 50 price_currency: string_input(&request, "price_currency"), 51 price_per_amount: string_input(&request, "price_per_amount"), 52 price_per_unit: string_input(&request, "price_per_unit"), 53 available: string_input(&request, "available"), 54 label: string_input(&request, "label"), 55 discount_id: string_input(&request, "discount_id"), 56 discount_label: string_input(&request, "discount_label"), 57 discount_kind: string_input(&request, "discount_kind"), 58 discount_value: string_input(&request, "discount_value"), 59 discount_amount: string_input(&request, "discount_amount"), 60 discount_currency: string_input(&request, "discount_currency"), 61 }; 62 if request.context.dry_run { 63 let view = map_runtime( 64 request.operation_id(), 65 crate::runtime::listing::scaffold_preflight(self.config, &args), 66 )?; 67 return serialized_operation_result::<ListingCreateResult, _>(&view); 68 } 69 70 let view = map_runtime( 71 request.operation_id(), 72 crate::runtime::listing::scaffold(self.config, &args), 73 )?; 74 serialized_operation_result::<ListingCreateResult, _>(&view) 75 } 76 } 77 78 impl OperationService<ListingGetRequest> for ListingOperationService<'_> { 79 type Result = ListingGetResult; 80 81 fn execute( 82 &self, 83 request: OperationRequest<ListingGetRequest>, 84 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 85 let args = RecordLookupArgs { 86 key: required_string(&request, "key")?, 87 }; 88 let view = map_runtime( 89 request.operation_id(), 90 crate::runtime::listing::get(self.config, &args), 91 )?; 92 serialized_operation_result::<ListingGetResult, _>(&view) 93 } 94 } 95 96 impl OperationService<ListingListRequest> for ListingOperationService<'_> { 97 type Result = ListingListResult; 98 99 fn execute( 100 &self, 101 request: OperationRequest<ListingListRequest>, 102 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 103 let view = map_runtime( 104 request.operation_id(), 105 crate::runtime::listing::list(self.config), 106 )?; 107 serialized_operation_result::<ListingListResult, _>(&view) 108 } 109 } 110 111 impl OperationService<ListingAppListRequest> for ListingOperationService<'_> { 112 type Result = ListingAppListResult; 113 114 fn execute( 115 &self, 116 request: OperationRequest<ListingAppListRequest>, 117 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 118 let view = map_runtime( 119 request.operation_id(), 120 crate::runtime::listing::app_record_list(self.config), 121 )?; 122 serialized_operation_result::<ListingAppListResult, _>(&view) 123 } 124 } 125 126 impl OperationService<ListingAppExportRequest> for ListingOperationService<'_> { 127 type Result = ListingAppExportResult; 128 129 fn execute( 130 &self, 131 request: OperationRequest<ListingAppExportRequest>, 132 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 133 let args = ListingAppRecordExportArgs { 134 record_id: required_string(&request, "record_id")?, 135 output: optional_path(&request, "output"), 136 }; 137 let mut config = self.config.clone(); 138 if request.context.dry_run { 139 config.output.dry_run = true; 140 } 141 let view = map_runtime( 142 request.operation_id(), 143 crate::runtime::listing::app_record_export(&config, &args), 144 )?; 145 listing_app_record_export_result::<ListingAppExportResult>(request.operation_id(), &view) 146 } 147 } 148 149 impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { 150 type Result = ListingUpdateResult; 151 152 fn execute( 153 &self, 154 request: OperationRequest<ListingUpdateRequest>, 155 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 156 if !request.context.dry_run { 157 require_approval(&request)?; 158 } 159 let args = mutation_args(&request)?; 160 let config = mutation_config(self.config, &request); 161 let view = crate::runtime::listing::update(&config, &args).map_err(|error| { 162 OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) 163 })?; 164 mutation_result::<ListingUpdateResult>(request.operation_id(), &view) 165 } 166 } 167 168 impl OperationService<ListingValidateRequest> for ListingOperationService<'_> { 169 type Result = ListingValidateResult; 170 171 fn execute( 172 &self, 173 request: OperationRequest<ListingValidateRequest>, 174 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 175 let args = ListingFileArgs { 176 file: required_path(&request, "file")?, 177 }; 178 let view = map_runtime( 179 request.operation_id(), 180 crate::runtime::listing::validate(self.config, &args), 181 )?; 182 serialized_operation_result::<ListingValidateResult, _>(&view) 183 } 184 } 185 186 impl OperationService<ListingRebindRequest> for ListingOperationService<'_> { 187 type Result = ListingRebindResult; 188 189 fn execute( 190 &self, 191 request: OperationRequest<ListingRebindRequest>, 192 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 193 let args = ListingRebindArgs { 194 file: required_path(&request, "file")?, 195 selector: required_string(&request, "selector")?, 196 farm_d_tag: string_input(&request, "farm_d_tag"), 197 }; 198 if request.context.dry_run { 199 let view = map_runtime( 200 request.operation_id(), 201 crate::runtime::listing::rebind_preflight(self.config, &args), 202 )?; 203 return serialized_operation_result::<ListingRebindResult, _>(&view); 204 } 205 require_approval(&request)?; 206 let view = map_runtime( 207 request.operation_id(), 208 crate::runtime::listing::rebind(self.config, &args), 209 )?; 210 serialized_operation_result::<ListingRebindResult, _>(&view) 211 } 212 } 213 214 impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { 215 type Result = ListingPublishResult; 216 217 fn execute( 218 &self, 219 request: OperationRequest<ListingPublishRequest>, 220 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 221 if !request.context.dry_run { 222 require_approval(&request)?; 223 } 224 let args = mutation_args(&request)?; 225 let config = mutation_config(self.config, &request); 226 let view = crate::runtime::listing::publish_via_sdk(&config, &args).map_err(|error| { 227 OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) 228 })?; 229 mutation_result::<ListingPublishResult>(request.operation_id(), &view) 230 } 231 } 232 233 impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> { 234 type Result = ListingArchiveResult; 235 236 fn execute( 237 &self, 238 request: OperationRequest<ListingArchiveRequest>, 239 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 240 if !request.context.dry_run { 241 require_approval(&request)?; 242 } 243 let args = mutation_args(&request)?; 244 let config = mutation_config(self.config, &request); 245 let view = crate::runtime::listing::archive(&config, &args).map_err(|error| { 246 OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) 247 })?; 248 mutation_result::<ListingArchiveResult>(request.operation_id(), &view) 249 } 250 } 251 252 fn mutation_config<P>(config: &RuntimeConfig, request: &OperationRequest<P>) -> RuntimeConfig 253 where 254 P: OperationRequestPayload, 255 { 256 let mut config = config.clone(); 257 if request.context.dry_run { 258 config.output.dry_run = true; 259 } 260 config 261 } 262 263 fn mutation_args<P>( 264 request: &OperationRequest<P>, 265 ) -> Result<ListingMutationArgs, OperationAdapterError> 266 where 267 P: OperationRequestPayload + OperationRequestData, 268 { 269 Ok(ListingMutationArgs { 270 file: required_path(request, "file")?, 271 idempotency_key: request 272 .context 273 .idempotency_key 274 .clone() 275 .or_else(|| string_input(request, "idempotency_key")), 276 print_event: bool_input(request, "print_event").unwrap_or(false), 277 offline: matches!(request.context.network_mode, OperationNetworkMode::Offline), 278 }) 279 } 280 281 fn require_approval<P>(request: &OperationRequest<P>) -> Result<(), OperationAdapterError> 282 where 283 P: OperationRequestPayload + OperationRequestData, 284 { 285 if request.context.requires_approval_token() { 286 return Err(OperationAdapterError::approval_required( 287 request.operation_id(), 288 )); 289 } 290 Ok(()) 291 } 292 293 fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> 294 where 295 R: OperationResultData, 296 T: Serialize, 297 { 298 OperationResult::new(R::from_serializable(value)?) 299 } 300 301 fn mutation_result<R>( 302 operation_id: &str, 303 view: &ListingMutationView, 304 ) -> Result<OperationResult<R>, OperationAdapterError> 305 where 306 R: OperationResultData, 307 { 308 match view.disposition() { 309 CommandDisposition::Success => serialized_operation_result::<R, _>(view), 310 CommandDisposition::ExternalUnavailable if listing_relay_unavailable(view) => { 311 Err(OperationAdapterError::network_unavailable_with_detail( 312 operation_id, 313 view.reason.clone().unwrap_or_else(|| { 314 format!( 315 "listing {} finished with state `{}`", 316 view.operation, view.state 317 ) 318 }), 319 serde_json::to_value(view).unwrap_or(Value::Null), 320 )) 321 } 322 disposition => Err(OperationAdapterError::from_command_disposition( 323 operation_id, 324 disposition, 325 view.reason.clone().unwrap_or_else(|| { 326 format!( 327 "listing {} finished with state `{}`", 328 view.operation, view.state 329 ) 330 }), 331 )), 332 } 333 } 334 335 fn listing_relay_unavailable(view: &ListingMutationView) -> bool { 336 matches!( 337 view.source.as_str(), 338 "direct Nostr relay publish · local key" | "SDK listing publish · configured signer" 339 ) && (view.reason.as_deref().is_some_and(|reason| { 340 reason.contains("configured relay") 341 || reason.contains("direct relay connection failed") 342 || reason.contains("SDK relay publish") 343 }) || !view.target_relays.is_empty() 344 || !view.connected_relays.is_empty() 345 || !view.failed_relays.is_empty()) 346 } 347 348 fn listing_app_record_export_result<R>( 349 operation_id: &str, 350 view: &ListingAppRecordExportView, 351 ) -> Result<OperationResult<R>, OperationAdapterError> 352 where 353 R: OperationResultData, 354 { 355 match view.disposition() { 356 CommandDisposition::Success => serialized_operation_result::<R, _>(view), 357 CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail( 358 operation_id, 359 view.reason.clone().unwrap_or_else(|| { 360 format!( 361 "app-authored local record `{}` was not found", 362 view.record_id 363 ) 364 }), 365 serde_json::to_value(view).unwrap_or(Value::Null), 366 )), 367 CommandDisposition::ValidationFailed => { 368 Err(OperationAdapterError::validation_failed_with_detail( 369 operation_id, 370 view.reason.clone().unwrap_or_else(|| { 371 format!( 372 "app-authored local record `{}` cannot be exported", 373 view.record_id 374 ) 375 }), 376 serde_json::to_value(view).unwrap_or(Value::Null), 377 )) 378 } 379 disposition => Err(OperationAdapterError::from_command_disposition( 380 operation_id, 381 disposition, 382 view.reason.clone().unwrap_or_else(|| { 383 format!( 384 "app-authored local record export finished with state `{}`", 385 view.state 386 ) 387 }), 388 )), 389 } 390 } 391 392 fn map_runtime<T>( 393 operation_id: &str, 394 result: Result<T, RuntimeError>, 395 ) -> Result<T, OperationAdapterError> { 396 result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) 397 } 398 399 fn required_string<P>( 400 request: &OperationRequest<P>, 401 key: &str, 402 ) -> Result<String, OperationAdapterError> 403 where 404 P: OperationRequestPayload + OperationRequestData, 405 { 406 string_input(request, key).ok_or_else(|| { 407 invalid_input( 408 request.operation_id(), 409 format!("missing required `{key}` input"), 410 ) 411 }) 412 } 413 414 fn required_path<P>( 415 request: &OperationRequest<P>, 416 key: &str, 417 ) -> Result<PathBuf, OperationAdapterError> 418 where 419 P: OperationRequestPayload + OperationRequestData, 420 { 421 optional_path(request, key).ok_or_else(|| { 422 invalid_input( 423 request.operation_id(), 424 format!("missing required `{key}` input"), 425 ) 426 }) 427 } 428 429 fn optional_path<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf> 430 where 431 P: OperationRequestPayload + OperationRequestData, 432 { 433 string_input(request, key).map(PathBuf::from) 434 } 435 436 fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> 437 where 438 P: OperationRequestPayload + OperationRequestData, 439 { 440 request 441 .payload 442 .input() 443 .get(key) 444 .and_then(Value::as_str) 445 .map(str::to_owned) 446 } 447 448 fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> 449 where 450 P: OperationRequestPayload + OperationRequestData, 451 { 452 request.payload.input().get(key).and_then(Value::as_bool) 453 } 454 455 fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { 456 OperationAdapterError::InvalidInput { 457 operation_id: operation_id.to_owned(), 458 message, 459 } 460 } 461 462 #[cfg(test)] 463 mod tests { 464 use std::path::{Path, PathBuf}; 465 466 use radroots_runtime_paths::RadrootsMigrationReport; 467 use radroots_secret_vault::RadrootsSecretBackend; 468 use serde_json::{Map, Value}; 469 use tempfile::tempdir; 470 471 use super::ListingOperationService; 472 use crate::ops::{ 473 ListingArchiveRequest, ListingCreateRequest, ListingListRequest, ListingPublishRequest, 474 OperationAdapter, OperationContext, OperationData, OperationRequest, 475 }; 476 use crate::runtime::config::{ 477 AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, 478 LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, 479 PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, 480 RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, 481 SignerConfig, Verbosity, 482 }; 483 484 #[test] 485 fn listing_service_requires_seller_actor_for_create_dry_run() { 486 let dir = tempdir().expect("tempdir"); 487 let config = sample_config(dir.path()); 488 let service = OperationAdapter::new(ListingOperationService::new(&config)); 489 let mut context = OperationContext::default(); 490 context.dry_run = true; 491 let request = OperationRequest::new( 492 context.clone(), 493 ListingCreateRequest::from_data(data(&[("key", "eggs"), ("title", "Eggs")])), 494 ) 495 .expect("listing create request"); 496 let error = service 497 .execute(request) 498 .expect_err("listing create seller actor"); 499 let output_error = error.to_output_error(); 500 501 assert_eq!(output_error.code, "account_unresolved"); 502 assert!(output_error.detail.expect("detail")["seller_actor_source"] == "resolved_account"); 503 } 504 505 #[test] 506 fn listing_service_exposes_listing_list_operation() { 507 let dir = tempdir().expect("tempdir"); 508 let config = sample_config(dir.path()); 509 let service = OperationAdapter::new(ListingOperationService::new(&config)); 510 let request = 511 OperationRequest::new(OperationContext::default(), ListingListRequest::default()) 512 .expect("listing list request"); 513 let envelope = service 514 .execute(request) 515 .expect("listing list result") 516 .to_envelope(OperationContext::default().envelope_context("req_listing_list")) 517 .expect("listing list envelope"); 518 519 assert_eq!(envelope.operation_id, "listing.list"); 520 assert_eq!(envelope.result["state"], "empty"); 521 assert_eq!(envelope.result["count"], 0); 522 } 523 524 #[test] 525 fn listing_publish_and_archive_require_approval_unless_dry_run() { 526 let dir = tempdir().expect("tempdir"); 527 let config = sample_config(dir.path()); 528 let service = OperationAdapter::new(ListingOperationService::new(&config)); 529 let publish = OperationRequest::new( 530 OperationContext::default(), 531 ListingPublishRequest::from_data(data(&[("file", "listing.toml")])), 532 ) 533 .expect("listing publish request"); 534 let publish_error = service.execute(publish).expect_err("approval required"); 535 assert!(format!("{publish_error}").contains("approval_token")); 536 assert_eq!(publish_error.to_output_error().code, "approval_required"); 537 assert_eq!(publish_error.to_output_error().exit_code, 6); 538 539 let mut context = OperationContext::default(); 540 context.dry_run = true; 541 let archive = OperationRequest::new( 542 context.clone(), 543 ListingArchiveRequest::from_data(data(&[("file", "listing.toml")])), 544 ) 545 .expect("listing archive request"); 546 let archive_error = service.execute(archive).expect_err("archive preflight"); 547 assert!(!format!("{archive_error}").contains("approval_token")); 548 } 549 550 fn sample_config(root: &Path) -> RuntimeConfig { 551 let data = root.join("data"); 552 let logs = root.join("logs"); 553 let secrets = root.join("secrets"); 554 RuntimeConfig { 555 output: OutputConfig { 556 format: OutputFormat::Human, 557 verbosity: Verbosity::Normal, 558 color: true, 559 dry_run: false, 560 }, 561 interaction: InteractionConfig { 562 input_enabled: true, 563 assume_yes: false, 564 stdin_tty: false, 565 stdout_tty: false, 566 prompts_allowed: false, 567 confirmations_allowed: false, 568 }, 569 paths: PathsConfig { 570 profile: "interactive_user".into(), 571 profile_source: "test".into(), 572 allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], 573 root_source: "test".into(), 574 repo_local_root: None, 575 repo_local_root_source: None, 576 subordinate_path_override_source: "runtime_config".into(), 577 app_namespace: "apps/cli".into(), 578 shared_accounts_namespace: "shared/accounts".into(), 579 shared_identities_namespace: "shared/identities".into(), 580 app_config_path: root.join("config/apps/cli/config.toml"), 581 workspace_config_path: None, 582 app_data_root: data.join("apps/cli"), 583 app_logs_root: logs.join("apps/cli"), 584 shared_accounts_data_root: data.join("shared/accounts"), 585 shared_accounts_secrets_root: secrets.join("shared/accounts"), 586 default_identity_path: secrets.join("shared/identities/default.json"), 587 }, 588 migration: MigrationConfig { 589 report: RadrootsMigrationReport::empty(), 590 }, 591 logging: LoggingConfig { 592 filter: "info".into(), 593 directory: None, 594 stdout: false, 595 }, 596 account: AccountConfig { 597 selector: None, 598 store_path: data.join("shared/accounts/store.json"), 599 secrets_dir: secrets.join("shared/accounts"), 600 secret_backend: RadrootsSecretBackend::EncryptedFile, 601 secret_fallback: None, 602 }, 603 account_secret_contract: AccountSecretContractConfig { 604 default_backend: "host_vault".into(), 605 default_fallback: Some("encrypted_file".into()), 606 allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], 607 host_vault_policy: Some("desktop".into()), 608 uses_protected_store: true, 609 }, 610 identity: IdentityConfig { 611 path: secrets.join("shared/identities/default.json"), 612 }, 613 signer: SignerConfig { 614 backend: SignerBackend::Local, 615 }, 616 publish: PublishConfig { 617 transport: PublishTransport::DirectNostrRelay, 618 source: PublishTransportSource::Defaults, 619 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 620 }, 621 relay: RelayConfig { 622 urls: Vec::new(), 623 publish_policy: RelayPublishPolicy::Any, 624 source: RelayConfigSource::Defaults, 625 }, 626 local: LocalConfig { 627 root: data.join("apps/cli/replica"), 628 replica_db_path: data.join("apps/cli/replica/replica.sqlite"), 629 backups_dir: data.join("apps/cli/replica/backups"), 630 exports_dir: data.join("apps/cli/replica/exports"), 631 }, 632 myc: MycConfig { 633 executable: PathBuf::from("myc"), 634 status_timeout_ms: 2_000, 635 }, 636 hyf: HyfConfig { 637 enabled: false, 638 executable: PathBuf::from("hyfd"), 639 }, 640 rpc: RpcConfig { 641 url: "http://127.0.0.1:7070".into(), 642 }, 643 rhi: crate::runtime::config::RhiConfig { 644 trusted_worker_pubkeys: Vec::new(), 645 }, 646 capability_bindings: Vec::new(), 647 } 648 } 649 650 fn data(entries: &[(&str, &str)]) -> OperationData { 651 entries 652 .iter() 653 .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) 654 .collect::<Map<String, Value>>() 655 } 656 }