08919752e3
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>
158 lines
4.6 KiB
Rust
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}");
|
|
}
|
|
}
|