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 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 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}