1use std::path::Path;
49
50use serde_json::Value;
51
52pub mod guard;
53#[cfg(feature = "wire")]
54pub mod mock_door;
55#[cfg(feature = "wire")]
56pub mod net;
57pub mod over;
58#[cfg(feature = "wire")]
59mod wire;
60
61pub use over::{Call, Exchange, Method, Over};
62#[cfg(feature = "wire")]
63pub use wire::{connect, Credential, Options};
64
65const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
69
70pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
73
74pub const ACCOUNT_ROOT_FOR_DISPLAY: &str = "https://api.upcloud.com";
78
79pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
82
83pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
86
87pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
89
90#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Endpoint {
96 Account,
98 Mock(MockBase),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct MockBase(String);
107
108impl MockBase {
109 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114impl Endpoint {
115 pub fn account() -> Result<Endpoint, String> {
120 Endpoint::account_given(|k| std::env::var(k).ok())
121 }
122
123 pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
125 let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
126 if set.is_empty() {
127 return Ok(Endpoint::Account);
128 }
129 Err(format!(
130 "REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
131 a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
132 process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
133 <loopback base>`), or unset {s} to use the account.",
134 s = set.join(" and ")
135 ))
136 }
137
138 pub fn mock(base: &str) -> Result<Endpoint, String> {
142 let mut b = base.trim().trim_end_matches('/').to_string();
143 if !is_loopback(&b) {
144 return Err(format!(
145 "REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
146 http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
147 machine can be one."
148 ));
149 }
150 if !b.ends_with("/1.3") {
151 b.push_str("/1.3");
152 }
153 Ok(Endpoint::Mock(MockBase(b)))
154 }
155
156
157 pub fn for_base(base: &str) -> Result<Endpoint, String> {
164 let b = base.trim().trim_end_matches('/');
165 let root = b.strip_suffix("/1.3").unwrap_or(b);
166 if root == ACCOUNT_ROOT_FOR_DISPLAY {
167 return Ok(Endpoint::Account);
168 }
169 Endpoint::mock(root).map_err(|e| format!("{base:?} is neither the account nor a loopback mock — {e}"))
170 }
171 pub fn mock_from_env() -> Result<Endpoint, String> {
174 let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
175 format!(
176 "REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
177 fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
178 {MOCK_BASE_ENV}=http://127.0.0.1:8099."
179 )
180 })?;
181 Endpoint::mock(&raw)
182 }
183
184 pub fn is_account(&self) -> bool {
185 matches!(self, Endpoint::Account)
186 }
187
188 pub fn base_for_display(&self) -> &str {
191 match self {
192 Endpoint::Account => ACCOUNT_BASE,
193 Endpoint::Mock(b) => b.as_str(),
194 }
195 }
196
197 pub fn banner(&self) -> String {
200 match self {
201 Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
202 Endpoint::Mock(b) => format!(
203 "provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
204 nothing measured here says anything about the account.",
205 b.as_str()
206 ),
207 }
208 }
209
210 pub fn child_args(&self) -> Vec<String> {
216 match self {
217 Endpoint::Account => Vec::new(),
218 Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
219 }
220 }
221}
222
223pub const MOCK_API_FLAG: &str = "--mock-api";
225
226impl Endpoint {
227 pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
231 match mock_api {
232 Some(b) => Endpoint::mock(b),
233 None => Endpoint::account(),
234 }
235 }
236}
237
238pub fn is_loopback(url: &str) -> bool {
241 let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
242 host == "127.0.0.1" || host == "localhost"
243}
244
245#[derive(Debug, Clone)]
252pub struct Reply {
253 pub status: u16,
254 pub body: Value,
256 pub text: String,
260}
261
262impl Reply {
263 pub fn ok(&self) -> bool {
264 (200..300).contains(&self.status)
265 }
266
267 pub fn error_code(&self) -> &str {
269 self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
270 }
271
272 pub fn error_message(&self) -> &str {
274 self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
275 }
276
277 pub fn describe_failure(&self, what: &str) -> String {
280 let code = self.error_code();
281 if code.is_empty() {
282 format!("{what} answered {} — {}", self.status, self.error_message())
283 } else {
284 format!("{what} answered {} {code} — {}", self.status, self.error_message())
285 }
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum Stop {
295 Soft { timeout_s: u32 },
296 Hard,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum WithStorages {
303 AndTheirBackups,
306 AndKeepBackups,
309 LeaveThem,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum Backups {
316 Unsaid,
319 Keep,
321 Delete,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum DeviceKind {
328 Cdrom,
330 Disk,
332}
333
334impl DeviceKind {
335 pub fn as_str(self) -> &'static str {
336 match self {
337 DeviceKind::Cdrom => "cdrom",
338 DeviceKind::Disk => "disk",
339 }
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum BootOrder {
347 Cdrom,
348 Disk,
349}
350
351impl BootOrder {
352 pub fn as_str(self) -> &'static str {
353 match self {
354 BootOrder::Cdrom => "cdrom",
355 BootOrder::Disk => "disk",
356 }
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum Console<'a> {
363 Off,
364 Vnc { password: &'a str },
365}
366
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum SizeSpelling {
372 String,
374 Number,
376}
377pub type Label<'a> = (&'a str, &'a str);
380
381#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct NewStorage<'a> {
384 pub title: &'a str,
385 pub zone: &'a str,
386 pub size_gib: u64,
387 pub tier: &'a str,
389 pub labels: &'a [Label<'a>],
391}
392
393pub trait UpCloudApi {
398 fn describe(&self) -> String;
401
402 fn is_the_account(&self) -> bool;
405
406 fn account(&self) -> Result<Reply, String>;
410 fn price(&self) -> Result<Reply, String>;
412 fn servers(&self) -> Result<Reply, String>;
415 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
418 fn server(&self, uuid: &str) -> Result<Reply, String>;
420 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
422 fn storages_private(&self) -> Result<Reply, String>;
425 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
428 fn storage(&self, uuid: &str) -> Result<Reply, String>;
431 fn zones(&self) -> Result<Reply, String>;
433 fn plans(&self) -> Result<Reply, String>;
435
436 fn create_server(&self, document: &Value) -> Result<Reply, String>;
441 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
443 fn start_server(&self, uuid: &str) -> Result<Reply, String>;
445 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
448 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
450 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
453 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
457 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
461 fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
464 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
466
467 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
471 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
473 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
477 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
483 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
487 fn modify_storage_size_as(&self, uuid: &str, gb: u64, spelling: SizeSpelling) -> Result<Reply, String> {
499 match spelling {
500 SizeSpelling::String => self.modify_storage_size(uuid, gb),
501 SizeSpelling::Number => Err(format!("{}: this implementation cannot send a storage size as a JSON number", self.describe())),
502 }
503 }
504 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
507 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
509}
510
511macro_rules! forward {
514 ($($ty:tt)*) => {
515 impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
516 fn describe(&self) -> String { (**self).describe() }
517 fn is_the_account(&self) -> bool { (**self).is_the_account() }
518 fn account(&self) -> Result<Reply, String> { (**self).account() }
519 fn price(&self) -> Result<Reply, String> { (**self).price() }
520 fn servers(&self) -> Result<Reply, String> { (**self).servers() }
521 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
522 fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
523 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
524 fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
525 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
526 fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
527 fn zones(&self) -> Result<Reply, String> { (**self).zones() }
528 fn plans(&self) -> Result<Reply, String> { (**self).plans() }
529 fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
530 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
531 fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
532 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
533 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
534 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
535 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
536 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
537 fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
538 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
539 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
540 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
541 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
542 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
543 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
544 fn modify_storage_size_as(&self, uuid: &str, gb: u64, spelling: SizeSpelling) -> Result<Reply, String> { (**self).modify_storage_size_as(uuid, gb, spelling) }
545 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
546 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
547 }
548 };
549}
550forward!(&T);
551forward!(Box<T>);
552
553pub fn delete_server_query(with: WithStorages) -> &'static str {
556 match with {
557 WithStorages::AndTheirBackups => "?storages=1&backups=delete",
558 WithStorages::AndKeepBackups => "?storages=1&backups=keep",
559 WithStorages::LeaveThem => "",
560 }
561}
562
563pub fn delete_storage_query(backups: Backups) -> &'static str {
565 match backups {
566 Backups::Unsaid => "",
567 Backups::Keep => "?backups=keep",
568 Backups::Delete => "?backups=delete",
569 }
570}
571
572pub fn label_query(labels: &[Label<'_>]) -> String {
575 fn enc(s: &str, out: &mut String) {
576 for b in s.bytes() {
577 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
578 out.push(b as char);
579 } else {
580 out.push_str(&format!("%{b:02X}"));
581 }
582 }
583 }
584 let mut q = String::new();
585 for (k, v) in labels {
586 q.push(if q.is_empty() { '?' } else { '&' });
587 q.push_str("label=");
588 enc(&format!("{k}={v}"), &mut q);
589 }
590 q
591}
592
593pub mod body {
598 use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
599 use serde_json::{json, Value};
600
601 pub fn stop(stop: Stop) -> Value {
604 match stop {
605 Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
606 Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
607 }
608 }
609 pub fn storage_size_number(gb: u64) -> Value {
611 json!({"storage": {"size": gb}})
612 }
613 pub fn storage_size(gb: u64) -> Value {
614 json!({"storage": {"size": gb.to_string()}})
615 }
616 pub fn server_plan(plan: &str) -> Value {
617 json!({"server": {"plan": plan}})
618 }
619 pub fn boot_order(order: BootOrder) -> Value {
620 json!({"server": {"boot_order": order.as_str()}})
621 }
622 pub fn console(c: &Console<'_>) -> Value {
623 match c {
624 Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
625 Console::Vnc { password } => json!({"server": {
626 "remote_access_enabled": "yes",
627 "remote_access_type": "vnc",
628 "remote_access_password": password,
629 }}),
630 }
631 }
632 pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
633 match at {
634 None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
635 Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
636 }
637 }
638 pub fn detach(address: &str) -> Value {
640 json!({"storage_device": {"address": address}})
641 }
642 pub fn create_storage(n: &NewStorage<'_>) -> Value {
643 let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
644 if !n.labels.is_empty() {
645 v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
646 }
647 v
648 }
649 pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
651 json!({"storage": {"tier": tier, "title": title, "zone": zone}})
652 }
653 pub fn direct_upload() -> Value {
654 json!({"storage_import": {"source": "direct_upload"}})
655 }
656}
657
658pub fn redact_upload_url(url: &str) -> String {
662 match url.find("/session/") {
663 Some(i) => format!("{}/session/…", &url[..i]),
664 None => match url.rfind('/') {
665 Some(i) => format!("{}/…", &url[..i]),
666 None => "…".to_string(),
667 },
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 #[test]
676 fn a_mock_endpoint_is_loopback_or_nothing() {
677 assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
678 assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
679 for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
680 let e = Endpoint::mock(bad).unwrap_err();
681 assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
682 }
683 }
684
685 #[test]
686 fn a_configured_base_is_the_account_a_mock_or_refused() {
687 for a in ["https://api.upcloud.com", "https://api.upcloud.com/", "https://api.upcloud.com/1.3", "https://api.upcloud.com/1.3/"] {
688 assert_eq!(Endpoint::for_base(a).unwrap(), Endpoint::Account, "{a}");
689 }
690 assert_eq!(Endpoint::for_base("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099").unwrap());
691 assert!(Endpoint::for_base("https://api.upcloud.com.evil.example").is_err());
692 assert!(Endpoint::for_base("http://10.13.0.247:8099").is_err());
693 assert_eq!(ACCOUNT_BASE_FOR_DISPLAY, format!("{ACCOUNT_ROOT_FOR_DISPLAY}/1.3"));
694 }
695
696 #[test]
697 fn both_spellings_of_a_mock_base_reach_the_same_door() {
698 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
699 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
700 }
701
702 #[test]
706 fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
707 for k in MOCK_ENVS {
708 let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
709 assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
710 }
711 assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
712 assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
714 }
715
716 #[test]
717 fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
718 let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
719 let args = m.child_args();
720 assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
721 assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
722 assert!(Endpoint::Account.child_args().is_empty());
723 assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
724 }
725
726 #[test]
727 fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
728 let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
729 assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
730 assert!(Endpoint::Account.banner().contains("a real bill"));
731 }
732
733 #[test]
734 fn the_stop_timeout_goes_out_as_a_string() {
735 let b = body::stop(Stop::Soft { timeout_s: 60 });
736 assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
737 assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
738 }
739
740 #[test]
741 fn a_label_filter_is_one_encoded_pair_per_label() {
742 assert_eq!(label_query(&[]), "");
743 assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
744 assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
745 }
746
747 #[test]
748 fn a_delete_says_what_happens_to_backups_in_one_spelling() {
749 assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
750 assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
751 assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
752 assert_eq!(delete_storage_query(Backups::Unsaid), "");
753 assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
754 }
755
756 #[test]
757 fn an_attach_names_an_address_only_when_asked() {
758 assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
759 assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
760 let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
761 assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
762 }
763
764 #[test]
765 fn an_upload_session_is_never_printed_whole() {
766 let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
767 assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
768 }
769
770 #[test]
771 fn a_failure_names_the_api_error_code() {
772 let r = Reply {
773 status: 409,
774 body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
775 text: String::new(),
776 };
777 let m = r.describe_failure("POST /server/{uuid}/storage/attach");
778 assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
779 let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
780 assert!(page.describe_failure("GET /x").contains("bad gateway"));
781 }
782}