1use std::sync::Arc;
8use std::time::Duration;
9
10use bytes::Bytes;
11use http::{Request, Response};
12use secrecy::ExposeSecret;
13use serde::de::DeserializeOwned;
14use serde_json::Value;
15use thiserror::Error;
16use url::Url;
17
18use crate::auth::Auth;
19use crate::error::{Error, Result};
20use crate::request::Params;
21use crate::transport::{Client, Endpoint, Query, QueryError};
22
23mod handles;
24mod preflight;
25
26pub use handles::{
27 ActionsHandle, ApiHandle, Cursor, LiveHandle, ReferrersHandle, VisitStream, VisitsSummaryHandle,
28};
29
30use preflight::PreflightState;
31
32#[derive(Clone)]
34pub struct MatomoClient(Arc<Inner>);
35
36pub(crate) struct Inner {
37 http: ::reqwest::Client,
38 base_url: Url,
39 auth: Auth,
40 skip_preflight: bool,
41 preflight: PreflightState,
42}
43
44impl std::fmt::Debug for MatomoClient {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("MatomoClient")
47 .field("base_url", &self.0.base_url.as_str())
48 .field("auth", &self.0.auth)
49 .field("skip_preflight", &self.0.skip_preflight)
50 .finish_non_exhaustive()
51 }
52}
53
54#[derive(Debug, Error)]
56pub enum MatomoClientError {
57 #[error("communication with matomo: {source}")]
59 Communication {
60 #[from]
61 source: ::reqwest::Error,
62 },
63 #[error("http error: {source}")]
65 Http {
66 #[from]
67 source: http::Error,
68 },
69}
70
71impl MatomoClient {
72 pub fn builder() -> ClientBuilder {
73 ClientBuilder::default()
74 }
75
76 pub(crate) fn inner(&self) -> &Arc<Inner> {
77 &self.0
78 }
79
80 fn dispatch_url(&self) -> Url {
81 self.0
84 .base_url
85 .join("index.php")
86 .expect("index.php is a valid relative ref")
87 }
88
89 pub(crate) async fn query<T: Endpoint + Send + Sync>(
91 &self,
92 endpoint: T,
93 ) -> Result<T::Response> {
94 if !self.0.skip_preflight {
95 let id_site = endpoint
96 .params()
97 .fields()
98 .iter()
99 .find(|(k, _)| k == "idSite")
100 .map(|(_, v)| v.clone());
101 preflight::run(self, endpoint.method(), id_site.as_deref()).await?;
102 }
103 endpoint.execute(self).await.map_err(map_query_error)
104 }
105
106 pub(crate) async fn query_unchecked<T: Endpoint + Send + Sync>(
108 &self,
109 endpoint: T,
110 ) -> Result<T::Response> {
111 endpoint.execute(self).await.map_err(map_query_error)
112 }
113
114 pub fn api(&self) -> ApiHandle<'_> {
116 ApiHandle::new(self)
117 }
118 pub fn visits_summary(&self) -> VisitsSummaryHandle<'_> {
119 VisitsSummaryHandle::new(self)
120 }
121 pub fn live(&self) -> LiveHandle<'_> {
122 LiveHandle::new(self)
123 }
124 pub fn actions(&self) -> ActionsHandle<'_> {
125 ActionsHandle::new(self)
126 }
127 pub fn referrers(&self) -> ReferrersHandle<'_> {
128 ReferrersHandle::new(self)
129 }
130
131 pub async fn call(&self, method: &'static str, params: &Params) -> Result<Value> {
135 self.query(RawEndpoint {
136 method,
137 params: params.clone(),
138 })
139 .await
140 }
141
142 pub async fn call_typed<T: DeserializeOwned>(
144 &self,
145 method: &'static str,
146 params: &Params,
147 ) -> Result<T> {
148 let value = self.call(method, params).await?;
149 serde_json::from_value(value).map_err(|source| Error::Decode { source, method })
150 }
151
152 pub async fn call_raw(&self, method: &'static str, params: &Params) -> Result<Bytes> {
154 if !self.0.skip_preflight {
155 let id_site = params
156 .fields()
157 .iter()
158 .find(|(k, _)| k == "idSite")
159 .map(|(_, v)| v.clone());
160 preflight::run(self, method, id_site.as_deref()).await?;
161 }
162 self.call_raw_unchecked(method, params).await
163 }
164
165 pub(crate) async fn call_raw_unchecked(
166 &self,
167 method: &'static str,
168 params: &Params,
169 ) -> Result<Bytes> {
170 let mut form: Vec<(String, String)> = vec![
171 ("module".to_string(), "API".to_string()),
172 ("method".to_string(), method.to_string()),
173 ("format".to_string(), "json".to_string()),
174 ];
175 form.extend(params.fields().iter().cloned());
176
177 let body = serde_urlencoded::to_string(&form).map_err(|e| Error::Config(e.to_string()))?;
178 let req = http::Request::builder()
179 .method(http::Method::POST)
180 .uri("/index.php")
181 .header("Content-Type", "application/x-www-form-urlencoded")
182 .body(Bytes::from(body))
183 .map_err(|e| Error::Config(e.to_string()))?;
184
185 let resp = self.execute(req).await.map_err(map_transport_only)?;
186 Ok(resp.into_body())
187 }
188}
189
190struct RawEndpoint {
193 method: &'static str,
194 params: Params,
195}
196
197impl Endpoint for RawEndpoint {
198 type Response = Value;
199 fn method(&self) -> &'static str {
200 self.method
201 }
202 fn params(&self) -> Params {
203 self.params.clone()
204 }
205}
206
207fn map_query_error(e: QueryError<MatomoClientError>) -> Error {
208 match e {
209 QueryError::Transport { source } => map_transport_only(source),
210 QueryError::Api {
211 message,
212 method,
213 kind,
214 } => Error::Api {
215 message,
216 method,
217 kind,
218 },
219 QueryError::NonJsonBody { method, body } => Error::NonJsonBody { method, body },
220 QueryError::Decode { source, method } => Error::Decode { source, method },
221 QueryError::Build { source } => Error::Config(source.to_string()),
222 }
223}
224
225fn map_transport_only(e: MatomoClientError) -> Error {
226 match e {
227 MatomoClientError::Communication { source } => Error::Http(source),
228 other => Error::Config(other.to_string()),
229 }
230}
231
232impl Client for MatomoClient {
233 type Error = MatomoClientError;
234
235 async fn execute(
236 &self,
237 req: Request<Bytes>,
238 ) -> std::result::Result<Response<Bytes>, Self::Error> {
239 let url = self.dispatch_url();
240 let mut builder = self.0.http.post(url);
241
242 if let Some(ct) = req.headers().get(http::header::CONTENT_TYPE) {
243 builder = builder.header(http::header::CONTENT_TYPE, ct.clone());
244 }
245
246 let mut body = req.into_body();
247 match &self.0.auth {
248 Auth::Token(t) => {
249 let extra =
250 serde_urlencoded::to_string([("token_auth", t.expose_secret())]).unwrap();
251 let mut buf = Vec::with_capacity(body.len() + 1 + extra.len());
252 buf.extend_from_slice(&body);
253 if !body.is_empty() {
254 buf.push(b'&');
255 }
256 buf.extend_from_slice(extra.as_bytes());
257 body = Bytes::from(buf);
258 }
259 Auth::Bearer(t) => {
260 builder = builder.bearer_auth(t.expose_secret());
261 }
262 }
263
264 let reqwest_resp = builder.body(body).send().await?.error_for_status()?;
265
266 let status = reqwest_resp.status();
267 let version = reqwest_resp.version();
268 let mut resp = Response::builder().status(status).version(version);
269 if let Some(headers) = resp.headers_mut() {
270 for (k, v) in reqwest_resp.headers() {
271 headers.insert(k, v.clone());
272 }
273 }
274 Ok(resp.body(reqwest_resp.bytes().await?)?)
275 }
276}
277
278#[derive(Default)]
279#[must_use]
280pub struct ClientBuilder {
281 base_url: Option<String>,
282 auth: Option<Auth>,
283 timeout: Option<Duration>,
284 skip_preflight: bool,
285 http: Option<::reqwest::Client>,
286}
287
288impl ClientBuilder {
289 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
290 self.base_url = Some(base_url.into());
291 self
292 }
293
294 pub fn auth(mut self, auth: Auth) -> Self {
295 self.auth = Some(auth);
296 self
297 }
298
299 pub fn timeout(mut self, timeout: Duration) -> Self {
300 self.timeout = Some(timeout);
301 self
302 }
303
304 pub fn reqwest_client(mut self, http: ::reqwest::Client) -> Self {
306 self.http = Some(http);
307 self
308 }
309
310 pub fn skip_preflight(mut self) -> Self {
312 self.skip_preflight = true;
313 self
314 }
315
316 pub fn build(self) -> Result<MatomoClient> {
317 let raw = self
318 .base_url
319 .ok_or_else(|| Error::Config("base_url is required".to_string()))?;
320 let auth = self
321 .auth
322 .ok_or_else(|| Error::Config("auth is required".to_string()))?;
323
324 let normalized = if raw.ends_with('/') {
325 raw
326 } else {
327 format!("{raw}/")
328 };
329 let base_url =
330 Url::parse(&normalized).map_err(|e| Error::Config(format!("invalid base_url: {e}")))?;
331 if base_url.cannot_be_a_base() {
332 return Err(Error::Config(
333 "base_url must be a valid base URL".to_string(),
334 ));
335 }
336
337 let http = match self.http {
338 Some(http) => http,
339 None => {
340 let mut b = ::reqwest::Client::builder();
341 b = b.timeout(self.timeout.unwrap_or(Duration::from_secs(60)));
342 b.build().map_err(Error::Http)?
343 }
344 };
345
346 Ok(MatomoClient(Arc::new(Inner {
347 http,
348 base_url,
349 auth,
350 skip_preflight: self.skip_preflight,
351 preflight: PreflightState::default(),
352 })))
353 }
354}
355
356impl MatomoClient {
357 pub fn new(base_url: impl Into<String>, auth: Auth) -> Result<Self> {
359 Self::builder().base_url(base_url).auth(auth).build()
360 }
361
362 pub fn with_reqwest_client(
364 base_url: impl Into<String>,
365 auth: Auth,
366 http: ::reqwest::Client,
367 ) -> Result<Self> {
368 Self::builder()
369 .base_url(base_url)
370 .auth(auth)
371 .reqwest_client(http)
372 .build()
373 }
374}