Skip to main content

matomo/
reqwest.rs

1//! `reqwest`-backed [`Client`] implementation and the ergonomic high-level API.
2//!
3//! Gated behind the `reqwest` feature. No TLS backend is pulled in by default;
4//! pair it with `reqwest-rustls` or `reqwest-native-tls`, or supply your own
5//! [`reqwest::Client`] via [`MatomoClient::with_reqwest_client`].
6
7use 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/// A reqwest-based Matomo API client and the ergonomic entry point.
33#[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/// Transport-level errors surfaced by [`MatomoClient`].
55#[derive(Debug, Error)]
56pub enum MatomoClientError {
57    /// The underlying `reqwest` call failed (DNS, TLS, timeout, ...).
58    #[error("communication with matomo: {source}")]
59    Communication {
60        #[from]
61        source: ::reqwest::Error,
62    },
63    /// Constructing the `http::Response` from the reqwest response failed.
64    #[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        // base_url is normalized with a trailing slash, so this joins onto the
82        // sub-path instead of replacing it.
83        self.0
84            .base_url
85            .join("index.php")
86            .expect("index.php is a valid relative ref")
87    }
88
89    /// Run an [`Endpoint`] and map its [`QueryError`] onto the public [`Error`].
90    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    /// Like [`Self::query`] but skips preflight (used by preflight itself).
107    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    // Module accessors.
115    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    // Escape hatches.
132
133    /// Parse once into a `Value`, branching on Matomo's error envelope.
134    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    /// Typed call. Single parse: bytes → `Value` once, error-check, then decode.
143    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    /// Lowest-level call: the raw response bytes, no parsing.
153    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
190/// Endpoint used by the `call`/`call_typed` escape hatches: any method, any
191/// params, decode to a raw `Value`.
192struct 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    /// Supply a pre-configured `reqwest::Client` (custom TLS, proxy, timeouts).
305    pub fn reqwest_client(mut self, http: ::reqwest::Client) -> Self {
306        self.http = Some(http);
307        self
308    }
309
310    /// Opt out of the lazy preflight checks.
311    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    /// Convenience constructor with the default reqwest client.
358    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    /// Constructor that takes a pre-configured `reqwest::Client`.
363    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}