Skip to main content

upcloud_api/
over.rs

1//! **One spelling of UpCloud's paths, under every implementation.**
2//!
3//! Every [`crate::UpCloudApi`] method becomes ONE [`Call`] here — method, path
4//! under `/1.3`, JSON body — and an [`Exchange`] answers it. The wire is an
5//! `Exchange` (it prefixes the [`crate::Endpoint`]'s base and sends it);
6//! `mock-upcloud`'s in-process `FakeUpCloud` is an `Exchange` (it hands the
7//! same call to the same router the HTTP face uses); a test's scripted world
8//! is an `Exchange`. So a fake answering a call answers exactly the request
9//! the account would have been sent — not a parallel spelling of it that can
10//! drift. An `Exchange` never sees a base URL, so it cannot choose a cloud.
11
12use std::path::Path;
13
14use serde_json::{json, Value};
15
16use crate::{
17    body, delete_server_query, delete_storage_query, label_query, redact_upload_url, Backups, BootOrder, Console, DeviceKind, Label, NewStorage,
18    Reply, Stop, UpCloudApi, WithStorages,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Method {
23    Get,
24    Post,
25    Put,
26    Delete,
27}
28
29impl Method {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Method::Get => "GET",
33            Method::Post => "POST",
34            Method::Put => "PUT",
35            Method::Delete => "DELETE",
36        }
37    }
38}
39
40/// One request, fully described, with no host in it.
41#[derive(Debug, Clone, PartialEq)]
42pub enum Call<'a> {
43    /// `path` is under `/1.3` and carries its query (`/server/{uuid}?…`).
44    /// `body` is `None` for a GET/DELETE; a bodyless POST sends `{}`.
45    Api { method: Method, path: String, body: Option<Value> },
46    /// The medium's `PUT` to the URL an import answered. The URL is a
47    /// credential; see [`Call::line`].
48    Upload { url: &'a str, file: &'a Path },
49}
50
51impl Call<'_> {
52    /// `GET /server/abc` — or, for an upload, `PUT <redacted url>`. Safe to print.
53    pub fn line(&self) -> String {
54        match self {
55            Call::Api { method, path, .. } => format!("{} {path}", method.as_str()),
56            Call::Upload { url, .. } => format!("PUT {}", redact_upload_url(url)),
57        }
58    }
59}
60
61/// **What answers a [`Call`].** Implemented by the wire and by every fake.
62pub trait Exchange {
63    fn describe(&self) -> String;
64    fn is_the_account(&self) -> bool;
65    fn exchange(&self, call: Call<'_>) -> Result<Reply, String>;
66}
67
68/// A borrowed exchange is an exchange, so a test can keep its fake and read
69/// what it recorded after the procedure has moved on.
70impl<E: Exchange + ?Sized> Exchange for &E {
71    fn describe(&self) -> String {
72        (**self).describe()
73    }
74    fn is_the_account(&self) -> bool {
75        (**self).is_the_account()
76    }
77    fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
78        (**self).exchange(call)
79    }
80}
81
82/// An [`Exchange`] as an [`UpCloudApi`]: the ONE place a typed call becomes a
83/// method, a path and a body.
84pub struct Over<E>(pub E);
85
86fn api(method: Method, path: String, body: Option<Value>) -> Call<'static> {
87    Call::Api { method, path, body }
88}
89
90impl<E: Exchange> Over<E> {
91    fn get(&self, path: String) -> Result<Reply, String> {
92        self.0.exchange(api(Method::Get, path, None))
93    }
94    fn send(&self, method: Method, path: String, body: Value) -> Result<Reply, String> {
95        self.0.exchange(api(method, path, Some(body)))
96    }
97}
98
99impl<E: Exchange> UpCloudApi for Over<E> {
100    fn describe(&self) -> String {
101        self.0.describe()
102    }
103    fn is_the_account(&self) -> bool {
104        self.0.is_the_account()
105    }
106    fn account(&self) -> Result<Reply, String> {
107        self.get("/account".into())
108    }
109    fn price(&self) -> Result<Reply, String> {
110        self.get("/price".into())
111    }
112    fn servers(&self) -> Result<Reply, String> {
113        self.get("/server".into())
114    }
115    fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
116        self.get(format!("/server{}", label_query(labels)))
117    }
118    fn server(&self, uuid: &str) -> Result<Reply, String> {
119        self.get(format!("/server/{uuid}"))
120    }
121    fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> {
122        self.get(format!("/server/{uuid}/firewall_rule"))
123    }
124    fn storages_private(&self) -> Result<Reply, String> {
125        self.get("/storage/private".into())
126    }
127    fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
128        self.get(format!("/storage{}", label_query(labels)))
129    }
130    fn storage(&self, uuid: &str) -> Result<Reply, String> {
131        self.get(format!("/storage/{uuid}"))
132    }
133    fn zones(&self) -> Result<Reply, String> {
134        self.get("/zone".into())
135    }
136    fn plans(&self) -> Result<Reply, String> {
137        self.get("/plan".into())
138    }
139    fn create_server(&self, document: &Value) -> Result<Reply, String> {
140        self.send(Method::Post, "/server".into(), document.clone())
141    }
142    fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> {
143        self.send(Method::Post, format!("/server/{uuid}/stop"), body::stop(stop))
144    }
145    fn start_server(&self, uuid: &str) -> Result<Reply, String> {
146        self.send(Method::Post, format!("/server/{uuid}/start"), json!({}))
147    }
148    fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> {
149        self.send(Method::Put, format!("/server/{uuid}"), body::server_plan(plan))
150    }
151    fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> {
152        self.send(Method::Put, format!("/server/{uuid}"), body::boot_order(order))
153    }
154    fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> {
155        self.send(Method::Put, format!("/server/{uuid}"), body::console(&console))
156    }
157    fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> {
158        self.send(Method::Post, format!("/server/{server}/storage/attach"), body::attach(kind, storage, at))
159    }
160    fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> {
161        self.send(Method::Post, format!("/server/{server}/storage/detach"), body::detach(address))
162    }
163    fn eject_cdrom(&self, server: &str) -> Result<Reply, String> {
164        self.send(Method::Post, format!("/server/{server}/cdrom/eject"), json!({}))
165    }
166    fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> {
167        self.0.exchange(api(Method::Delete, format!("/server/{uuid}{}", delete_server_query(with)), None))
168    }
169    fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> {
170        self.send(Method::Post, "/storage".into(), body::create_storage(new))
171    }
172    fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> {
173        self.send(Method::Post, format!("/storage/{uuid}/clone"), body::clone_storage(title, zone, tier))
174    }
175    fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> {
176        self.send(Method::Post, format!("/storage/{uuid}/import"), body::direct_upload())
177    }
178    fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> {
179        self.0.exchange(Call::Upload { url, file })
180    }
181    fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> {
182        self.send(Method::Put, format!("/storage/{uuid}"), body::storage_size(gb))
183    }
184    fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> {
185        self.send(Method::Post, format!("/storage/{uuid}/resize"), json!({}))
186    }
187    fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> {
188        self.0.exchange(api(Method::Delete, format!("/storage/{uuid}{}", delete_storage_query(backups)), None))
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::cell::RefCell;
196
197    #[derive(Default)]
198    struct Recorder(RefCell<Vec<String>>);
199    impl Exchange for Recorder {
200        fn describe(&self) -> String {
201            "recorder".into()
202        }
203        fn is_the_account(&self) -> bool {
204            false
205        }
206        fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
207            self.0.borrow_mut().push(call.line());
208            Ok(Reply { status: 200, body: Value::Null, text: String::new() })
209        }
210    }
211
212    #[test]
213    fn every_typed_call_is_one_line_of_the_real_api() {
214        let o = Over(Recorder::default());
215        o.server("u").unwrap();
216        o.delete_server("u", WithStorages::AndKeepBackups).unwrap();
217        o.storages_labelled(&[("monetize_ref", "r")]).unwrap();
218        o.upload_direct("https://fi-hel1.img.upcloud.com/uploader/session/SECRET", Path::new("/x")).unwrap();
219        assert_eq!(
220            *o.0 .0.borrow(),
221            vec![
222                "GET /server/u".to_string(),
223                "DELETE /server/u?storages=1&backups=keep".to_string(),
224                "GET /storage?label=monetize_ref%3Dr".to_string(),
225                "PUT https://fi-hel1.img.upcloud.com/uploader/session/…".to_string(),
226            ]
227        );
228    }
229}