use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use hyper::{Method, Request, StatusCode};
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use super::session::Session;
const TIMEOUT: Duration = Duration::from_secs(60);
type HttpsClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
#[derive(Debug)]
pub(crate) enum RemoteError {
Transport(String),
Status { status: u16, body: String },
Decode(String),
}
impl std::fmt::Display for RemoteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transport(e) => write!(f, "falha ao falar com o daemon: {e}"),
Self::Status { status, body } => {
write!(f, "daemon respondeu HTTP {status}: {body}")
}
Self::Decode(e) => write!(f, "resposta inválida do daemon: {e}"),
}
}
}
#[derive(Clone)]
pub(crate) struct Remote {
http: HttpsClient,
}
impl Remote {
pub(crate) fn new() -> Result<Self, String> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let tls = hyper_rustls::HttpsConnectorBuilder::new()
.with_provider_and_webpki_roots(provider)
.map_err(|e| format!("TLS: {e}"))?
.https_or_http()
.enable_http1()
.build();
Ok(Self {
http: Client::builder(TokioExecutor::new()).build(tls),
})
}
pub(crate) async fn rpc(
&self,
session: &Session,
cmd: &serde_json::Value,
) -> Result<serde_json::Value, RemoteError> {
let corpo = serde_json::to_vec(cmd)
.map_err(|e| RemoteError::Decode(format!("comando não serializável: {e}")))?;
let uri = format!("{}/api/rpc", session.base_url);
let mut req = Request::builder()
.method(Method::POST)
.uri(&uri)
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json");
if let Some(token) = &session.token {
req = req.header(AUTHORIZATION, format!("Bearer {token}"));
}
let req = req
.body(Full::new(Bytes::from(corpo)))
.map_err(|e| RemoteError::Transport(format!("requisição inválida para {uri}: {e}")))?;
let resp = tokio::time::timeout(TIMEOUT, self.http.request(req))
.await
.map_err(|_| RemoteError::Transport(format!("timeout após {}s", TIMEOUT.as_secs())))?
.map_err(|e| RemoteError::Transport(e.to_string()))?;
let status = resp.status();
let bytes = resp
.into_body()
.collect()
.await
.map_err(|e| RemoteError::Transport(format!("corpo truncado: {e}")))?
.to_bytes();
if status != StatusCode::OK {
return Err(RemoteError::Status {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).chars().take(500).collect(),
});
}
serde_json::from_slice(&bytes).map_err(|e| RemoteError::Decode(e.to_string()))
}
pub(crate) async fn upload_archive(
&self,
session: &Session,
service_id: &str,
filename: &str,
zip: Vec<u8>,
) -> Result<serde_json::Value, RemoteError> {
let uri = format!("{}/api/services/{}/archive", session.base_url, service_id);
let mut req = Request::builder()
.method(Method::POST)
.uri(&uri)
.header(CONTENT_TYPE, "application/zip")
.header("X-Rustploy-Filename", filename);
if let Some(token) = &session.token {
req = req.header(AUTHORIZATION, format!("Bearer {token}"));
}
let req = req
.body(Full::new(Bytes::from(zip)))
.map_err(|e| RemoteError::Transport(format!("requisição inválida para {uri}: {e}")))?;
let resp = tokio::time::timeout(Duration::from_secs(600), self.http.request(req))
.await
.map_err(|_| RemoteError::Transport("timeout no upload do zip".into()))?
.map_err(|e| RemoteError::Transport(e.to_string()))?;
let status = resp.status();
let bytes = resp
.into_body()
.collect()
.await
.map_err(|e| RemoteError::Transport(format!("corpo truncado: {e}")))?
.to_bytes();
if status != StatusCode::OK {
return Err(RemoteError::Status {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).chars().take(500).collect(),
});
}
serde_json::from_slice(&bytes).map_err(|e| RemoteError::Decode(e.to_string()))
}
}
pub(crate) fn response_kind(v: &serde_json::Value) -> Option<&str> {
match v {
serde_json::Value::String(s) => Some(s.as_str()),
serde_json::Value::Object(m) if m.len() == 1 => m.keys().next().map(String::as_str),
_ => None,
}
}
pub(crate) fn response_payload(v: &serde_json::Value) -> Option<&serde_json::Value> {
v.as_object()
.filter(|m| m.len() == 1)
.and_then(|m| m.values().next())
}
pub(crate) fn response_error(v: &serde_json::Value) -> Option<(String, String)> {
let err = v.get("Err")?;
Some((
err.get("code")
.and_then(|c| c.as_str())
.unwrap_or("Err")
.to_string(),
err.get("message")
.and_then(|m| m.as_str())
.unwrap_or_default()
.to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn variante_com_campos_e_reconhecida() {
let v = json!({ "Projects": [] });
assert_eq!(response_kind(&v), Some("Projects"));
assert_eq!(response_payload(&v), Some(&json!([])));
}
#[test]
fn variante_unitaria_e_string_nua() {
let v = json!("Ok");
assert_eq!(response_kind(&v), Some("Ok"));
assert_eq!(response_payload(&v), None);
}
#[test]
fn erro_do_daemon_e_extraido() {
let v = json!({ "Err": { "code": "NotFound", "message": "service not found" } });
let (code, msg) = response_error(&v).unwrap();
assert_eq!(code, "NotFound");
assert_eq!(msg, "service not found");
assert!(response_error(&json!({ "Projects": [] })).is_none());
}
}