use std::fmt::Display;
use anyhow::{Context, Result};
use reqwest::{
blocking::{Client, RequestBuilder, Response},
header::{HeaderMap, HeaderValue},
Method, Url,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::debug;
use crate::invocation_context::InvocationConfig;
#[derive(Clone)]
pub struct PHClient {
config: InvocationConfig,
base_url: Url,
client: Client,
}
#[derive(Error, Debug)]
pub enum ClientError {
RequestError(reqwest::Error),
ApiError(u16, Box<Url>, String),
InvalidUrl(String, String),
}
impl Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::RequestError(err) => write!(f, "Request error: {err}"),
ClientError::InvalidUrl(base_url, path) => {
write!(f, "Failed to build URL: {base_url} {path}")
}
ClientError::ApiError(status, url, body) => {
match serde_json::from_str::<ApiErrorResponse>(body) {
Ok(api_error) => {
write!(f, "API error: {api_error}")
}
Err(_) => write!(
f,
"API error: status='{status}' url='{url}' message='{body}'",
),
}
}
}
}
}
impl From<reqwest::Error> for ClientError {
fn from(error: reqwest::Error) -> Self {
ClientError::RequestError(error)
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ApiErrorResponse {
r#type: String,
code: String,
detail: String,
attr: Option<String>,
}
impl Display for ApiErrorResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"error='{}' code='{}' details='{}'",
self.r#type, self.code, self.detail
)?;
if let Some(attr) = &self.attr {
write!(f, ", attributes='{attr}'")?;
}
Ok(())
}
}
pub trait SendRequestFn: FnOnce(RequestBuilder) -> RequestBuilder {}
impl PHClient {
pub fn from_config(config: InvocationConfig) -> anyhow::Result<Self> {
let base_url = Self::build_base_url(&config)?;
let client = Self::build_client(&config)?;
Ok(Self {
config,
base_url,
client,
})
}
pub fn get(&self, path: &str) -> Result<RequestBuilder, ClientError> {
self.create_request(Method::GET, path)
}
pub fn post(&self, path: &str) -> Result<RequestBuilder, ClientError> {
self.create_request(Method::POST, path)
}
pub fn put(&self, path: &str) -> Result<RequestBuilder, ClientError> {
self.create_request(Method::PUT, path)
}
pub fn delete(&self, path: &str) -> Result<RequestBuilder, ClientError> {
self.create_request(Method::DELETE, path)
}
pub fn patch(&self, path: &str) -> Result<RequestBuilder, ClientError> {
self.create_request(Method::PATCH, path)
}
pub fn send_get<F: FnOnce(RequestBuilder) -> RequestBuilder>(
&self,
path: &str,
builder: F,
) -> Result<Response, ClientError> {
self.send_request(Method::GET, path, builder)
}
pub fn send_post<F: FnOnce(RequestBuilder) -> RequestBuilder>(
&self,
path: &str,
builder: F,
) -> Result<Response, ClientError> {
self.send_request(Method::POST, path, builder)
}
pub fn send_delete<F: FnOnce(RequestBuilder) -> RequestBuilder>(
&self,
path: &str,
builder: F,
) -> Result<Response, ClientError> {
self.send_request(Method::DELETE, path, builder)
}
pub fn send_put<F: FnOnce(RequestBuilder) -> RequestBuilder>(
&self,
path: &str,
builder: F,
) -> Result<Response, ClientError> {
self.send_request(Method::PUT, path, builder)
}
pub fn send_request<F: FnOnce(RequestBuilder) -> RequestBuilder>(
&self,
method: Method,
path: &str,
builder: F,
) -> Result<Response, ClientError> {
let request = builder(self.create_request(method, path)?);
match request.send() {
Ok(response) => {
if response.status().is_success() {
Ok(response)
} else {
let status = response.status().as_u16();
let box_url = Box::new(response.url().clone());
let body = response.text()?;
Err(ClientError::ApiError(status, box_url, body))
}
}
Err(err) => Err(ClientError::from(err)),
}
}
pub fn get_env_id(&self) -> &String {
&self.config.env_id
}
fn create_request(&self, method: Method, path: &str) -> Result<RequestBuilder, ClientError> {
let url = self.build_url(path)?;
let headers = self.build_headers();
debug!("building request for {method} {url}");
Ok(self
.client
.request(method, url)
.bearer_auth(&self.config.api_key)
.headers(headers))
}
fn build_client(config: &InvocationConfig) -> anyhow::Result<Client> {
let client = Client::builder()
.danger_accept_invalid_certs(config.skip_ssl)
.build()?;
Ok(client)
}
fn build_base_url(config: &InvocationConfig) -> anyhow::Result<Url> {
let base_url = Url::parse(&format!(
"{}/api/environments/{}/",
config.host, config.env_id
))
.context("Invalid base URL")?;
Ok(base_url)
}
fn build_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
headers.insert("User-Agent", HeaderValue::from_static("posthog-cli"));
headers
}
fn build_url(&self, path: &str) -> Result<Url, ClientError> {
self.base_url
.join(path)
.map_err(|_| ClientError::InvalidUrl(self.base_url.clone().into(), path.to_string()))
}
}