posthog_cli/api/
client.rs

1use std::fmt::Display;
2
3use anyhow::{Context, Result};
4use reqwest::{
5    blocking::{Client, RequestBuilder, Response},
6    header::{HeaderMap, HeaderValue},
7    Method, Url,
8};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use tracing::debug;
12
13use crate::invocation_context::InvocationConfig;
14
15#[derive(Clone)]
16pub struct PHClient {
17    config: InvocationConfig,
18    base_url: Url,
19    client: Client,
20}
21
22#[derive(Error, Debug)]
23pub enum ClientError {
24    RequestError(reqwest::Error),
25    // All invalid status codes
26    ApiError(u16, Box<Url>, String),
27    InvalidUrl(String, String),
28}
29
30impl Display for ClientError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            ClientError::RequestError(err) => write!(f, "Request error: {err}"),
34            ClientError::InvalidUrl(base_url, path) => {
35                write!(f, "Failed to build URL: {base_url} {path}")
36            }
37            ClientError::ApiError(status, url, body) => {
38                // We only parse api error on display to catch all errors even when the body is not JSON
39                match serde_json::from_str::<ApiErrorResponse>(body) {
40                    Ok(api_error) => {
41                        write!(f, "API error: {api_error}")
42                    }
43                    Err(_) => write!(
44                        f,
45                        "API error: status='{status}' url='{url}' message='{body}'",
46                    ),
47                }
48            }
49        }
50    }
51}
52
53impl From<reqwest::Error> for ClientError {
54    fn from(error: reqwest::Error) -> Self {
55        ClientError::RequestError(error)
56    }
57}
58
59#[derive(Serialize, Deserialize, Debug)]
60pub struct ApiErrorResponse {
61    r#type: String,
62    code: String,
63    detail: String,
64    attr: Option<String>,
65}
66
67impl Display for ApiErrorResponse {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(
70            f,
71            "error='{}' code='{}' details='{}'",
72            self.r#type, self.code, self.detail
73        )?;
74        if let Some(attr) = &self.attr {
75            write!(f, ", attributes='{attr}'")?;
76        }
77        Ok(())
78    }
79}
80
81pub trait SendRequestFn: FnOnce(RequestBuilder) -> RequestBuilder {}
82
83impl PHClient {
84    pub fn from_config(config: InvocationConfig) -> anyhow::Result<Self> {
85        let base_url = Self::build_base_url(&config)?;
86        let client = Self::build_client(&config)?;
87        Ok(Self {
88            config,
89            base_url,
90            client,
91        })
92    }
93
94    pub fn get(&self, path: &str) -> Result<RequestBuilder, ClientError> {
95        self.create_request(Method::GET, path)
96    }
97
98    pub fn post(&self, path: &str) -> Result<RequestBuilder, ClientError> {
99        self.create_request(Method::POST, path)
100    }
101
102    pub fn put(&self, path: &str) -> Result<RequestBuilder, ClientError> {
103        self.create_request(Method::PUT, path)
104    }
105
106    pub fn delete(&self, path: &str) -> Result<RequestBuilder, ClientError> {
107        self.create_request(Method::DELETE, path)
108    }
109
110    pub fn patch(&self, path: &str) -> Result<RequestBuilder, ClientError> {
111        self.create_request(Method::PATCH, path)
112    }
113
114    pub fn send_get<F: FnOnce(RequestBuilder) -> RequestBuilder>(
115        &self,
116        path: &str,
117        builder: F,
118    ) -> Result<Response, ClientError> {
119        self.send_request(Method::GET, path, builder)
120    }
121
122    pub fn send_post<F: FnOnce(RequestBuilder) -> RequestBuilder>(
123        &self,
124        path: &str,
125        builder: F,
126    ) -> Result<Response, ClientError> {
127        self.send_request(Method::POST, path, builder)
128    }
129
130    pub fn send_delete<F: FnOnce(RequestBuilder) -> RequestBuilder>(
131        &self,
132        path: &str,
133        builder: F,
134    ) -> Result<Response, ClientError> {
135        self.send_request(Method::DELETE, path, builder)
136    }
137
138    pub fn send_put<F: FnOnce(RequestBuilder) -> RequestBuilder>(
139        &self,
140        path: &str,
141        builder: F,
142    ) -> Result<Response, ClientError> {
143        self.send_request(Method::PUT, path, builder)
144    }
145
146    pub fn send_request<F: FnOnce(RequestBuilder) -> RequestBuilder>(
147        &self,
148        method: Method,
149        path: &str,
150        builder: F,
151    ) -> Result<Response, ClientError> {
152        let request = builder(self.create_request(method, path)?);
153        match request.send() {
154            Ok(response) => {
155                if response.status().is_success() {
156                    Ok(response)
157                } else {
158                    let status = response.status().as_u16();
159                    let box_url = Box::new(response.url().clone());
160                    let body = response.text()?;
161                    Err(ClientError::ApiError(status, box_url, body))
162                }
163            }
164            Err(err) => Err(ClientError::from(err)),
165        }
166    }
167
168    pub fn get_env_id(&self) -> &String {
169        &self.config.env_id
170    }
171
172    fn create_request(&self, method: Method, path: &str) -> Result<RequestBuilder, ClientError> {
173        let url = self.build_url(path)?;
174        let headers = self.build_headers();
175        debug!("building request for {method} {url}");
176        Ok(self
177            .client
178            .request(method, url)
179            .bearer_auth(&self.config.api_key)
180            .headers(headers))
181    }
182
183    fn build_client(config: &InvocationConfig) -> anyhow::Result<Client> {
184        let client = Client::builder()
185            .danger_accept_invalid_certs(config.skip_ssl)
186            .build()?;
187        Ok(client)
188    }
189
190    fn build_base_url(config: &InvocationConfig) -> anyhow::Result<Url> {
191        let base_url = Url::parse(&format!(
192            "{}/api/environments/{}/",
193            config.host, config.env_id
194        ))
195        .context("Invalid base URL")?;
196        Ok(base_url)
197    }
198
199    fn build_headers(&self) -> HeaderMap {
200        let mut headers = HeaderMap::new();
201        headers.insert("Content-Type", HeaderValue::from_static("application/json"));
202        headers.insert("User-Agent", HeaderValue::from_static("posthog-cli"));
203        headers
204    }
205
206    fn build_url(&self, path: &str) -> Result<Url, ClientError> {
207        self.base_url
208            .join(path)
209            .map_err(|_| ClientError::InvalidUrl(self.base_url.clone().into(), path.to_string()))
210    }
211}