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(),
]
);
}
}