upcloud-api 0.1.3

The UpCloud API 1.3 surface the nordisk estates use, as ONE trait (`UpCloudApi`) with ONE wire implementation. Which cloud a run talks to (the account, or a mock-upcloud on loopback) is an `Endpoint` decided once at the edge, and a mock endpoint cannot be pointed off this machine. The fake that answers the trait in-process lives beside mock-upcloud's state machine.
Documentation
//! **One spelling of UpCloud's paths, under every implementation.**
//!
//! Every [`crate::UpCloudApi`] method becomes ONE [`Call`] here — method, path
//! under `/1.3`, JSON body — and an [`Exchange`] answers it. The wire is an
//! `Exchange` (it prefixes the [`crate::Endpoint`]'s base and sends it);
//! `mock-upcloud`'s in-process `FakeUpCloud` is an `Exchange` (it hands the
//! same call to the same router the HTTP face uses); a test's scripted world
//! is an `Exchange`. So a fake answering a call answers exactly the request
//! the account would have been sent — not a parallel spelling of it that can
//! drift. An `Exchange` never sees a base URL, so it cannot choose a cloud.

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,
    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",
        }
    }
}

/// One request, fully described, with no host in it.
#[derive(Debug, Clone, PartialEq)]
pub enum Call<'a> {
    /// `path` is under `/1.3` and carries its query (`/server/{uuid}?…`).
    /// `body` is `None` for a GET/DELETE; a bodyless POST sends `{}`.
    Api { method: Method, path: String, body: Option<Value> },
    /// The medium's `PUT` to the URL an import answered. The URL is a
    /// credential; see [`Call::line`].
    Upload { url: &'a str, file: &'a Path },
}

impl Call<'_> {
    /// `GET /server/abc` — or, for an upload, `PUT <redacted url>`. Safe to print.
    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)),
        }
    }
}

/// **What answers a [`Call`].** Implemented by the wire and by every fake.
pub trait Exchange {
    fn describe(&self) -> String;
    fn is_the_account(&self) -> bool;
    fn exchange(&self, call: Call<'_>) -> Result<Reply, String>;
}

/// A borrowed exchange is an exchange, so a test can keep its fake and read
/// what it recorded after the procedure has moved on.
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)
    }
}

/// An [`Exchange`] as an [`UpCloudApi`]: the ONE place a typed call becomes a
/// method, a path and a body.
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(&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 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(),
            ]
        );
    }
}