use std::path::Path;
use serde_json::{json, Value};
use crate::{
body, delete_server_query, delete_storage_query, label_query, redact_upload_url, Backups, BootOrder, Console, DeviceKind, Label, NewStorage, SizeSpelling,
Reply, Stop, UpCloudApi, WithStorages,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
Put,
Delete,
}
impl Method {
pub fn as_str(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Put => "PUT",
Method::Delete => "DELETE",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Call<'a> {
Api { method: Method, path: String, body: Option<Value> },
Upload { url: &'a str, file: &'a Path },
}
impl Call<'_> {
pub fn line(&self) -> String {
match self {
Call::Api { method, path, .. } => format!("{} {path}", method.as_str()),
Call::Upload { url, .. } => format!("PUT {}", redact_upload_url(url)),
}
}
}
pub trait Exchange {
fn describe(&self) -> String;
fn is_the_account(&self) -> bool;
fn exchange(&self, call: Call<'_>) -> Result<Reply, String>;
}
impl<E: Exchange + ?Sized> Exchange for &E {
fn describe(&self) -> String {
(**self).describe()
}
fn is_the_account(&self) -> bool {
(**self).is_the_account()
}
fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
(**self).exchange(call)
}
}
pub struct Over<E>(pub E);
fn api(method: Method, path: String, body: Option<Value>) -> Call<'static> {
Call::Api { method, path, body }
}
impl<E: Exchange> Over<E> {
fn get(&self, path: String) -> Result<Reply, String> {
self.0.exchange(api(Method::Get, path, None))
}
fn send(&self, method: Method, path: String, body: Value) -> Result<Reply, String> {
self.0.exchange(api(method, path, Some(body)))
}
}
impl<E: Exchange> UpCloudApi for Over<E> {
fn describe(&self) -> String {
self.0.describe()
}
fn is_the_account(&self) -> bool {
self.0.is_the_account()
}
fn account(&self) -> Result<Reply, String> {
self.get("/account".into())
}
fn price(&self) -> Result<Reply, String> {
self.get("/price".into())
}
fn servers(&self) -> Result<Reply, String> {
self.get("/server".into())
}
fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
self.get(format!("/server{}", label_query(labels)))
}
fn server(&self, uuid: &str) -> Result<Reply, String> {
self.get(format!("/server/{uuid}"))
}
fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> {
self.get(format!("/server/{uuid}/firewall_rule"))
}
fn storages_private(&self) -> Result<Reply, String> {
self.get("/storage/private".into())
}
fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
self.get(format!("/storage{}", label_query(labels)))
}
fn storage(&self, uuid: &str) -> Result<Reply, String> {
self.get(format!("/storage/{uuid}"))
}
fn zones(&self) -> Result<Reply, String> {
self.get("/zone".into())
}
fn plans(&self) -> Result<Reply, String> {
self.get("/plan".into())
}
fn create_server(&self, document: &Value) -> Result<Reply, String> {
self.send(Method::Post, "/server".into(), document.clone())
}
fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> {
self.send(Method::Post, format!("/server/{uuid}/stop"), body::stop(stop))
}
fn start_server(&self, uuid: &str) -> Result<Reply, String> {
self.send(Method::Post, format!("/server/{uuid}/start"), json!({}))
}
fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> {
self.send(Method::Put, format!("/server/{uuid}"), body::server_plan(plan))
}
fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> {
self.send(Method::Put, format!("/server/{uuid}"), body::boot_order(order))
}
fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> {
self.send(Method::Put, format!("/server/{uuid}"), body::console(&console))
}
fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> {
self.send(Method::Post, format!("/server/{server}/storage/attach"), body::attach(kind, storage, at))
}
fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> {
self.send(Method::Post, format!("/server/{server}/storage/detach"), body::detach(address))
}
fn eject_cdrom(&self, server: &str) -> Result<Reply, String> {
self.send(Method::Post, format!("/server/{server}/cdrom/eject"), json!({}))
}
fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> {
self.0.exchange(api(Method::Delete, format!("/server/{uuid}{}", delete_server_query(with)), None))
}
fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> {
self.send(Method::Post, "/storage".into(), body::create_storage(new))
}
fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> {
self.send(Method::Post, format!("/storage/{uuid}/clone"), body::clone_storage(title, zone, tier))
}
fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> {
self.send(Method::Post, format!("/storage/{uuid}/import"), body::direct_upload())
}
fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> {
self.0.exchange(Call::Upload { url, file })
}
fn modify_storage_size_as(&self, uuid: &str, gb: u64, spelling: SizeSpelling) -> Result<Reply, String> {
let body = match spelling {
SizeSpelling::String => body::storage_size(gb),
SizeSpelling::Number => body::storage_size_number(gb),
};
self.send(Method::Put, format!("/storage/{uuid}"), body)
}
fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> {
self.send(Method::Put, format!("/storage/{uuid}"), body::storage_size(gb))
}
fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> {
self.send(Method::Post, format!("/storage/{uuid}/resize"), json!({}))
}
fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> {
self.0.exchange(api(Method::Delete, format!("/storage/{uuid}{}", delete_storage_query(backups)), None))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[derive(Default)]
struct Recorder(RefCell<Vec<String>>);
impl Exchange for Recorder {
fn describe(&self) -> String {
"recorder".into()
}
fn is_the_account(&self) -> bool {
false
}
fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
self.0.borrow_mut().push(call.line());
Ok(Reply { status: 200, body: Value::Null, text: String::new() })
}
}
#[test]
fn a_storage_size_keeps_its_callers_spelling() {
#[derive(Default)]
struct Body(RefCell<Option<Value>>);
impl Exchange for Body {
fn describe(&self) -> String {
"body".into()
}
fn is_the_account(&self) -> bool {
false
}
fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
if let Call::Api { body, .. } = call {
*self.0.borrow_mut() = body;
}
Ok(Reply { status: 200, body: Value::Null, text: String::new() })
}
}
let o = Over(Body::default());
o.modify_storage_size_as("u", 64, SizeSpelling::Number).unwrap();
assert_eq!(o.0 .0.borrow().clone().unwrap(), json!({"storage": {"size": 64}}));
o.modify_storage_size_as("u", 64, SizeSpelling::String).unwrap();
assert_eq!(o.0 .0.borrow().clone().unwrap(), json!({"storage": {"size": "64"}}));
o.modify_storage_size("u", 64).unwrap();
assert_eq!(o.0 .0.borrow().clone().unwrap(), json!({"storage": {"size": "64"}}));
}
#[test]
fn every_typed_call_is_one_line_of_the_real_api() {
let o = Over(Recorder::default());
o.server("u").unwrap();
o.delete_server("u", WithStorages::AndKeepBackups).unwrap();
o.storages_labelled(&[("monetize_ref", "r")]).unwrap();
o.upload_direct("https://fi-hel1.img.upcloud.com/uploader/session/SECRET", Path::new("/x")).unwrap();
assert_eq!(
*o.0 .0.borrow(),
vec![
"GET /server/u".to_string(),
"DELETE /server/u?storages=1&backups=keep".to_string(),
"GET /storage?label=monetize_ref%3Dr".to_string(),
"PUT https://fi-hel1.img.upcloud.com/uploader/session/…".to_string(),
]
);
}
}