Files
system_update/app_rust/system-update-gnome/src/api.rs
T
gilles 08919752e3 feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK).
- tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/
  hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002.
- tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif,
  dual-read + backfill). Migration 0003. Fix séquence journal de migration.
- tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate),
  SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH),
  SJ-3 (reboot vérifié boot_id).
- WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics,
  scaffold app_rust, ajustements frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00

158 lines
4.6 KiB
Rust

use std::io::{Read, Write};
use std::net::TcpStream;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpUrl {
pub host: String,
pub port: u16,
pub base_path: String,
}
impl HttpUrl {
pub fn parse(server_url: &str) -> Result<Self, String> {
let without_scheme = server_url
.strip_prefix("http://")
.ok_or_else(|| "ce premier client supporte seulement http://".to_string())?;
let (host_port, base_path) = match without_scheme.split_once('/') {
Some((host_port, path)) => (host_port, format!("/{path}")),
None => (without_scheme, String::new()),
};
let (host, port) = match host_port.rsplit_once(':') {
Some((host, port)) => {
let parsed_port = port
.parse::<u16>()
.map_err(|_| "port serveur invalide".to_string())?;
(host.to_string(), parsed_port)
}
None => (host_port.to_string(), 80),
};
if host.is_empty() {
return Err("hôte serveur manquant".to_string());
}
Ok(Self {
host,
port,
base_path,
})
}
pub fn path(&self, endpoint: &str) -> String {
let base = self.base_path.trim_end_matches('/');
let endpoint = endpoint.trim_start_matches('/');
if base.is_empty() {
format!("/{endpoint}")
} else {
format!("{base}/{endpoint}")
}
}
}
pub struct ApiClient {
server: HttpUrl,
token: Option<String>,
}
impl ApiClient {
pub fn new(server_url: &str, token: Option<String>) -> Result<Self, String> {
Ok(Self {
server: HttpUrl::parse(server_url)?,
token,
})
}
pub fn get_capabilities(&self) -> Result<String, String> {
self.get("/api/capabilities")
}
pub fn get_system_status(&self) -> Result<String, String> {
self.get("/api/system/status")
}
pub fn get_system_metrics(&self) -> Result<String, String> {
self.get("/api/system/metrics")
}
pub fn get_machines(&self) -> Result<String, String> {
self.get("/api/machines")
}
fn get(&self, endpoint: &str) -> Result<String, String> {
let path = self.server.path(endpoint);
let mut request = format!(
"GET {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: system-update-gnome/0.1\r\nAccept: application/json\r\nConnection: close\r\n",
self.server.host
);
if let Some(token) = &self.token {
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
request.push_str("\r\n");
let mut stream = TcpStream::connect((&*self.server.host, self.server.port))
.map_err(|err| format!("connexion serveur échouée: {err}"))?;
stream
.write_all(request.as_bytes())
.map_err(|err| format!("envoi requête échoué: {err}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("lecture réponse échouée: {err}"))?;
split_http_response(&response)
}
}
fn split_http_response(response: &str) -> Result<String, String> {
let (headers, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "réponse HTTP invalide".to_string())?;
let status_line = headers.lines().next().unwrap_or_default();
if !status_line.contains(" 200 ") {
return Err(format!("réponse serveur inattendue: {status_line}"));
}
Ok(body.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_http_port() {
let url = HttpUrl::parse("http://localhost").expect("url");
assert_eq!(url.host, "localhost");
assert_eq!(url.port, 80);
assert_eq!(url.path("/api/capabilities"), "/api/capabilities");
}
#[test]
fn parses_explicit_http_port_and_base_path() {
let url = HttpUrl::parse("http://10.0.0.80:8787/system-update").expect("url");
assert_eq!(url.host, "10.0.0.80");
assert_eq!(url.port, 8787);
assert_eq!(
url.path("/api/capabilities"),
"/system-update/api/capabilities"
);
}
#[test]
fn rejects_https_until_tls_client_is_added() {
assert!(HttpUrl::parse("https://10.0.0.80:8787").is_err());
}
#[test]
fn extracts_success_body() {
let body = split_http_response(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}",
)
.expect("body");
assert_eq!(body, "{\"ok\":true}");
}
}