Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/upcloud-api/src/over.rs

//! **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, FirewallRule, Label, NewStorage, Page,
    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",
        }
    }
}

/// 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 servers_labelled_page(&self, labels: &[Label<'_>], page: Page) -> Result<Reply, String> {
        self.get(format!("/server{}", page.on(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 storages_labelled_page(&self, labels: &[Label<'_>], page: Page) -> Result<Reply, String> {
        self.get(format!("/storage{}", page.on(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 set_server_metadata(&self, uuid: &str, on: bool) -> Result<Reply, String> {
        self.send(Method::Put, format!("/server/{uuid}"), body::server_metadata(on))
    }
    fn set_storage_labels(&self, uuid: &str, labels: &[Label<'_>]) -> Result<Reply, String> {
        self.send(Method::Put, format!("/storage/{uuid}"), body::storage_labels(labels))
    }
    fn set_server_labels(&self, uuid: &str, labels: &[Label<'_>]) -> Result<Reply, String> {
        self.send(Method::Put, format!("/server/{uuid}"), body::server_labels(labels))
    }
    fn set_firewall_rules(&self, uuid: &str, rules: &[FirewallRule]) -> Result<Reply, String> {
        self.send(Method::Put, format!("/server/{uuid}/firewall_rule"), body::firewall_rules(rules))
    }
    fn storage_import(&self, uuid: &str) -> Result<Reply, String> {
        self.get(format!("/storage/{uuid}/import"))
    }
    fn import_http(&self, uuid: &str, url: &str) -> Result<Reply, String> {
        self.send(Method::Post, format!("/storage/{uuid}/import"), body::http_import(url))
    }
    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))
    }
    fn restore_backup(&self, backup: &str) -> Result<Reply, String> {
        self.send(Method::Post, format!("/storage/{backup}/restore"), json!({}))
    }
    fn storages_backup_page(&self, page: Page) -> Result<Reply, String> {
        self.get(format!("/storage/backup{}", page.on(String::new())))
    }
}

#[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() })
        }
    }

    /// ★ **S8: a restore is ONE `POST /storage/{backup}/restore`** — the backup's uuid in the
    /// path, nothing else asked; a `&T` and a `Box<T>` send the same.
    #[test]
    fn a_restore_is_one_post_naming_the_backup() {
        let rec = Recorder::default();
        Over(&rec).restore_backup("01-b").expect("sent");
        let boxed: Box<dyn UpCloudApi + '_> = Box::new(Over(&rec));
        boxed.restore_backup("01-c").expect("sent");
        assert_eq!(*rec.0.borrow(), vec!["POST /storage/01-b/restore".to_string(), "POST /storage/01-c/restore".to_string()]);
        let rec = Recorder::default();
        Over(&rec).storages_backup_page(Page::first().next()).expect("sent");
        let line = rec.0.borrow()[0].clone();
        assert!(line.starts_with("GET /storage/backup?limit=") && line.contains("&offset="), "{line}");
    }

    /// ★ **An http import asks UpCloud to fetch the object itself** (the release media in
    /// Object Storage, 2026-10-09): the body names `http_import` and the URL, and the call's
    /// line — what a log shows — carries the path only, never the (presigned) URL.
    #[test]
    fn an_http_import_names_its_source_and_its_line_hides_the_url() {
        #[derive(Default)]
        struct Body(RefCell<Option<(String, 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> {
                let line = call.line();
                if let Call::Api { body, .. } = call {
                    *self.0.borrow_mut() = Some((line, body));
                }
                Ok(Reply { status: 201, body: Value::Null, text: String::new() })
            }
        }
        let rec = Body::default();
        let url = "https://bucket.example/release/front/ab.iso?X-Amz-Signature=SECRET";
        Over(&rec).import_http("01-s", url).expect("sent");
        let (line, body) = rec.0.borrow().clone().expect("one call");
        assert_eq!(line, "POST /storage/01-s/import");
        let body = body.expect("a body");
        assert_eq!(body["storage_import"]["source"], "http_import");
        assert_eq!(body["storage_import"]["source_location"], url);
        assert_eq!(crate::redact_object_url(url), "https://bucket.example/release/front/ab.iso?…");
        assert!(!crate::redact_object_url(url).contains("SECRET"));
    }

    /// ★ **After Done the seed's metadata is turned off, and a release clone is labelled**:
    /// each call is ONE `PUT` with exactly its field, so it changes nothing else.
    #[test]
    fn metadata_off_and_storage_labels_are_one_put_each() {
        #[derive(Default)]
        struct Body(RefCell<Vec<(String, 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> {
                let line = call.line();
                if let Call::Api { body, .. } = call {
                    self.0.borrow_mut().push((line, body));
                }
                Ok(Reply { status: 202, body: Value::Null, text: String::new() })
            }
        }
        let rec = Body::default();
        Over(&rec).set_server_metadata("00-s", false).expect("sent");
        Over(&rec).set_storage_labels("01-c", &[("estate", "acme"), ("role", "release-clone")]).expect("sent");
        Over(&rec).set_server_labels("00-k", &[("monetize_ref", "estate/gunnarrs"), ("monetize_res", "adopt:front:server")]).expect("sent");
        let calls = rec.0.borrow().clone();
        assert_eq!(calls[0].0, "PUT /server/00-s");
        assert_eq!(calls[0].1, Some(json!({"server": {"metadata": "no"}})));
        assert_eq!(calls[1].0, "PUT /storage/01-c");
        assert_eq!(
            calls[1].1,
            Some(json!({"storage": {"labels": [{"key": "estate", "value": "acme"}, {"key": "role", "value": "release-clone"}]}}))
        );
        assert_eq!(calls[2].0, "PUT /server/00-k", "MAP-SEG 4: a server's labels go on its own PUT");
        assert_eq!(calls[2].1, Some(json!({"server": {"labels": {"label": [{"key": "monetize_ref", "value": "estate/gunnarrs"}, {"key": "monetize_res", "value": "adopt:front:server"}]}}})));
    }

    /// ★ **A sold estate's firewall is written whole, in order** (stinky S7, 2026-10-10): ONE
    /// `PUT /server/{uuid}/firewall_rule` with the bulk spelling; an empty field is not sent,
    /// so a catch-all drop is `{direction, action}` only.
    #[test]
    fn a_firewall_is_one_put_of_the_whole_set_in_order() {
        #[derive(Default)]
        struct Body(RefCell<Vec<(String, 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> {
                let line = call.line();
                if let Call::Api { body, .. } = call {
                    self.0.borrow_mut().push((line, body));
                }
                Ok(Reply { status: 200, body: Value::Null, text: String::new() })
            }
        }
        let https = FirewallRule {
            direction: "in".into(),
            action: "accept".into(),
            family: "IPv4".into(),
            protocol: "tcp".into(),
            destination_port_start: "443".into(),
            destination_port_end: "443".into(),
            comment: "HTTPS".into(),
            ..Default::default()
        };
        let drop = FirewallRule { direction: "in".into(), action: "drop".into(), ..Default::default() };
        let rec = Body::default();
        Over(&rec).set_firewall_rules("00-s", &[https, drop]).expect("sent");
        let calls = rec.0.borrow().clone();
        assert_eq!(calls.len(), 1, "one write replaces the set");
        assert_eq!(calls[0].0, "PUT /server/00-s/firewall_rule");
        assert_eq!(
            calls[0].1,
            Some(json!({"firewall_rules": {"firewall_rule": [
                {"direction": "in", "action": "accept", "family": "IPv4", "protocol": "tcp", "destination_port_start": "443", "destination_port_end": "443", "comment": "HTTPS"},
                {"direction": "in", "action": "drop"}
            ]}}))
        );
    }

    /// **A size goes out spelled as its caller always sent it.** The port must
    /// not change a request's wire format: a number stays a number (the money
    /// path's), a string stays a string (private-gunnar-ops').
    #[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(),
            ]
        );
    }
}