1use 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, SizeSpelling,
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#[derive(Debug, Clone, PartialEq)]
42pub enum Call<'a> {
43 Api { method: Method, path: String, body: Option<Value> },
46 Upload { url: &'a str, file: &'a Path },
49}
50
51impl Call<'_> {
52 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
61pub 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
68impl<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
82pub 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_as(&self, uuid: &str, gb: u64, spelling: SizeSpelling) -> Result<Reply, String> {
182 let body = match spelling {
183 SizeSpelling::String => body::storage_size(gb),
184 SizeSpelling::Number => body::storage_size_number(gb),
185 };
186 self.send(Method::Put, format!("/storage/{uuid}"), body)
187 }
188 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> {
189 self.send(Method::Put, format!("/storage/{uuid}"), body::storage_size(gb))
190 }
191 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> {
192 self.send(Method::Post, format!("/storage/{uuid}/resize"), json!({}))
193 }
194 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> {
195 self.0.exchange(api(Method::Delete, format!("/storage/{uuid}{}", delete_storage_query(backups)), None))
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::cell::RefCell;
203
204 #[derive(Default)]
205 struct Recorder(RefCell<Vec<String>>);
206 impl Exchange for Recorder {
207 fn describe(&self) -> String {
208 "recorder".into()
209 }
210 fn is_the_account(&self) -> bool {
211 false
212 }
213 fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
214 self.0.borrow_mut().push(call.line());
215 Ok(Reply { status: 200, body: Value::Null, text: String::new() })
216 }
217 }
218
219 #[test]
223 fn a_storage_size_keeps_its_callers_spelling() {
224 #[derive(Default)]
225 struct Body(RefCell<Option<Value>>);
226 impl Exchange for Body {
227 fn describe(&self) -> String {
228 "body".into()
229 }
230 fn is_the_account(&self) -> bool {
231 false
232 }
233 fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
234 if let Call::Api { body, .. } = call {
235 *self.0.borrow_mut() = body;
236 }
237 Ok(Reply { status: 200, body: Value::Null, text: String::new() })
238 }
239 }
240 let o = Over(Body::default());
241 o.modify_storage_size_as("u", 64, SizeSpelling::Number).unwrap();
242 assert_eq!(o.0 .0.borrow().clone().unwrap(), json!({"storage": {"size": 64}}));
243 o.modify_storage_size_as("u", 64, SizeSpelling::String).unwrap();
244 assert_eq!(o.0 .0.borrow().clone().unwrap(), json!({"storage": {"size": "64"}}));
245 o.modify_storage_size("u", 64).unwrap();
246 assert_eq!(o.0 .0.borrow().clone().unwrap(), json!({"storage": {"size": "64"}}));
247 }
248
249 #[test]
250 fn every_typed_call_is_one_line_of_the_real_api() {
251 let o = Over(Recorder::default());
252 o.server("u").unwrap();
253 o.delete_server("u", WithStorages::AndKeepBackups).unwrap();
254 o.storages_labelled(&[("monetize_ref", "r")]).unwrap();
255 o.upload_direct("https://fi-hel1.img.upcloud.com/uploader/session/SECRET", Path::new("/x")).unwrap();
256 assert_eq!(
257 *o.0 .0.borrow(),
258 vec![
259 "GET /server/u".to_string(),
260 "DELETE /server/u?storages=1&backups=keep".to_string(),
261 "GET /storage?label=monetize_ref%3Dr".to_string(),
262 "PUT https://fi-hel1.img.upcloud.com/uploader/session/…".to_string(),
263 ]
264 );
265 }
266}