use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use bitrouter_providers::hosted::account::credentials::CredentialKind;
use bitrouter_providers::hosted::account::manager::CredentialManager;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use reqwest::{Method, StatusCode};
use serde::Serialize;
use serde::de::DeserializeOwned;
pub mod billing;
pub mod budgets;
pub mod byok;
pub mod error;
pub mod keys;
pub mod namespaces;
pub mod oauth_clients;
pub mod policies;
pub mod presets;
pub mod types;
pub mod usage;
#[cfg(test)]
mod tests;
use error::Error;
pub type Result<T> = std::result::Result<T, Error>;
pub struct ManagementClient {
base_url: String,
http: reqwest::Client,
manager: Arc<CredentialManager>,
namespace_id: Option<String>,
credential_kind: CredentialKind,
}
impl std::fmt::Debug for ManagementClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ManagementClient")
.field("base_url", &self.base_url)
.field("namespace_id", &self.namespace_id)
.field("credential_kind", &self.credential_kind)
.field("manager", &"<redacted>")
.finish_non_exhaustive()
}
}
impl ManagementClient {
pub async fn from_manager(manager: Arc<CredentialManager>) -> Result<Self> {
let creds = manager
.current()
.await
.context("reading BitRouter Cloud credentials")
.map_err(Error::Auth)?;
let creds = creds.ok_or(Error::NotSignedIn)?;
let base_url = creds.base_url().trim_end_matches('/').to_owned();
let namespace_id = creds.namespace_id().map(ToOwned::to_owned);
let credential_kind = creds.kind();
let http = build_http_client()?;
Ok(Self {
base_url,
http,
manager,
namespace_id,
credential_kind,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn namespace_id(&self) -> Option<&str> {
self.namespace_id.as_deref()
}
pub(super) fn namespaced(&self, suffix: &str) -> Result<String> {
let nsid = match (self.namespace_id.as_deref(), self.credential_kind) {
(Some(namespace_id), _) => namespace_id,
(None, CredentialKind::ApiKey) => "me",
(None, CredentialKind::Oauth) => return Err(Error::NoNamespace),
};
Ok(format!("/v1/namespaces/{nsid}{suffix}"))
}
async fn bearer(&self) -> Result<String> {
self.manager
.resolve_bearer(None, Some(&self.base_url))
.await
.map(|credential| credential.secret().to_owned())
.map_err(|error| Error::Auth(anyhow::anyhow!(error)))
}
async fn authed(&self, method: Method, path: &str) -> Result<reqwest::RequestBuilder> {
let bearer = self.bearer().await?;
let url = format!("{}{}", self.base_url, path);
let value = HeaderValue::from_str(&format!("Bearer {bearer}"))
.context("encoding Authorization header")
.map_err(Error::Auth)?;
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, value);
Ok(self.http.request(method, url).headers(headers))
}
pub(super) async fn get_json<Resp: DeserializeOwned>(&self, path: &str) -> Result<Resp> {
let req = self.authed(Method::GET, path).await?;
execute_json(req).await
}
pub(super) async fn get_with_query<Q: Serialize, Resp: DeserializeOwned>(
&self,
path: &str,
query: &Q,
) -> Result<Resp> {
let req = self.authed(Method::GET, path).await?.query(query);
execute_json(req).await
}
pub(super) async fn post_json<Body: Serialize, Resp: DeserializeOwned>(
&self,
path: &str,
body: &Body,
) -> Result<Resp> {
let req = self.authed(Method::POST, path).await?.json(body);
execute_json(req).await
}
pub(super) async fn post_empty<Resp: DeserializeOwned>(&self, path: &str) -> Result<Resp> {
let req = self.authed(Method::POST, path).await?;
execute_json(req).await
}
pub(super) async fn put_json<Body: Serialize, Resp: DeserializeOwned>(
&self,
path: &str,
body: &Body,
) -> Result<Resp> {
let req = self.authed(Method::PUT, path).await?.json(body);
execute_json(req).await
}
pub(super) async fn delete_json<Resp: DeserializeOwned>(&self, path: &str) -> Result<Resp> {
let req = self.authed(Method::DELETE, path).await?;
execute_json(req).await
}
}
fn build_http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(concat!("bitrouter/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()
.context("building BitRouter Cloud management HTTP client")
.map_err(Error::Auth)
}
async fn execute_json<Resp: DeserializeOwned>(req: reqwest::RequestBuilder) -> Result<Resp> {
let resp = req.send().await?;
let status = resp.status();
let body_bytes = resp.bytes().await?;
if status.is_success() {
let parsed = serde_json::from_slice::<Resp>(&body_bytes)?;
return Ok(parsed);
}
Err(map_error_response(status, &body_bytes))
}
fn map_error_response(status: StatusCode, body: &[u8]) -> Error {
if let Ok(envelope) = serde_json::from_slice::<error::ErrorBody>(body) {
return envelope.into_error(status.as_u16());
}
let message = String::from_utf8_lossy(body).into_owned();
let message = if message.trim().is_empty() {
status
.canonical_reason()
.unwrap_or("unexpected status")
.to_owned()
} else {
message
};
match status.as_u16() {
400 => Error::BadRequest { message },
401 => Error::Unauthorized { message },
403 => Error::Forbidden {
message,
missing_scope: None,
},
404 => Error::NotFound { message },
409 => Error::Conflict { message },
s => Error::Server { status: s, message },
}
}