use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::config::default_api_base;
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, serde::Deserialize)]
struct RawEnvelope {
ok: bool,
data: Option<serde_json::Value>,
#[serde(default)]
error: Option<ApiErrorBody>,
}
#[derive(Debug, serde::Deserialize)]
struct ApiErrorBody {
#[allow(dead_code)]
pub code: String,
pub message: String,
}
pub struct ControlPlaneClient {
base: String,
http: reqwest::Client,
}
impl ControlPlaneClient {
pub fn new(base: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.timeout(HTTP_REQUEST_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
base: base.into().trim_end_matches('/').to_string(),
http,
}
}
#[allow(dead_code)]
pub fn from_env() -> Self {
Self::new(default_api_base())
}
pub async fn post<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
bearer: Option<&str>,
) -> anyhow::Result<T> {
let mut req = self.http.post(format!("{}{path}", self.base)).json(body);
if let Some(token) = bearer {
req = req.bearer_auth(token);
}
let response = req
.send()
.await
.map_err(|err| map_http_err(&self.base, err))?;
self.decode(response).await
}
pub async fn get<T: DeserializeOwned>(&self, path: &str, bearer: &str) -> anyhow::Result<T> {
let response = self
.http
.get(format!("{}{path}", self.base))
.bearer_auth(bearer)
.send()
.await
.map_err(|err| map_http_err(&self.base, err))?;
self.decode(response).await
}
pub async fn delete<T: DeserializeOwned>(&self, path: &str, bearer: &str) -> anyhow::Result<T> {
let response = self
.http
.delete(format!("{}{path}", self.base))
.bearer_auth(bearer)
.send()
.await
.map_err(|err| map_http_err(&self.base, err))?;
self.decode(response).await
}
async fn decode<T: DeserializeOwned>(&self, response: reqwest::Response) -> anyhow::Result<T> {
let status = response.status();
let bytes = response.bytes().await?;
if bytes.is_empty() {
anyhow::bail!(
"{status} empty response from control plane — is flatland-control-plane up to date? (restart after pulling)"
);
}
let envelope: RawEnvelope = serde_json::from_slice(&bytes).map_err(|err| {
let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(200)]);
anyhow::anyhow!("{status} invalid JSON from control plane: {err} (body: {preview})")
})?;
if !envelope.ok {
let message = envelope
.error
.map(|err| err.message)
.unwrap_or_else(|| "request failed".into());
anyhow::bail!("{status} {message}");
}
let data = envelope
.data
.ok_or_else(|| anyhow::anyhow!("{status} response missing data"))?;
Ok(serde_json::from_value(data)?)
}
}
fn map_http_err(base: &str, err: reqwest::Error) -> anyhow::Error {
if err.is_timeout() || err.is_connect() {
anyhow::anyhow!(
"cannot reach control plane at {base} ({err}). \
Check DNS, that :7380 is open in the OCI Security List/NSG, and that flatland-control-plane is running."
)
} else {
err.into()
}
}