matomo-rs 0.1.0

Async client for the Matomo Reporting API, focused on data export and migration
Documentation
//! `reqwest`-backed [`Client`] implementation and the ergonomic high-level API.
//!
//! Gated behind the `reqwest` feature. No TLS backend is pulled in by default;
//! pair it with `reqwest-rustls` or `reqwest-native-tls`, or supply your own
//! [`reqwest::Client`] via [`MatomoClient::with_reqwest_client`].

use std::sync::Arc;
use std::time::Duration;

use bytes::Bytes;
use http::{Request, Response};
use secrecy::ExposeSecret;
use serde::de::DeserializeOwned;
use serde_json::Value;
use thiserror::Error;
use url::Url;

use crate::auth::Auth;
use crate::error::{Error, Result};
use crate::request::Params;
use crate::transport::{Client, Endpoint, Query, QueryError};

mod handles;
mod preflight;

pub use handles::{
    ActionsHandle, ApiHandle, Cursor, LiveHandle, ReferrersHandle, VisitStream, VisitsSummaryHandle,
};

use preflight::PreflightState;

/// A reqwest-based Matomo API client and the ergonomic entry point.
#[derive(Clone)]
pub struct MatomoClient(Arc<Inner>);

pub(crate) struct Inner {
    http: ::reqwest::Client,
    base_url: Url,
    auth: Auth,
    skip_preflight: bool,
    preflight: PreflightState,
}

impl std::fmt::Debug for MatomoClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MatomoClient")
            .field("base_url", &self.0.base_url.as_str())
            .field("auth", &self.0.auth)
            .field("skip_preflight", &self.0.skip_preflight)
            .finish_non_exhaustive()
    }
}

/// Transport-level errors surfaced by [`MatomoClient`].
#[derive(Debug, Error)]
pub enum MatomoClientError {
    /// The underlying `reqwest` call failed (DNS, TLS, timeout, ...).
    #[error("communication with matomo: {source}")]
    Communication {
        #[from]
        source: ::reqwest::Error,
    },
    /// Constructing the `http::Response` from the reqwest response failed.
    #[error("http error: {source}")]
    Http {
        #[from]
        source: http::Error,
    },
}

impl MatomoClient {
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    pub(crate) fn inner(&self) -> &Arc<Inner> {
        &self.0
    }

    fn dispatch_url(&self) -> Url {
        // base_url is normalized with a trailing slash, so this joins onto the
        // sub-path instead of replacing it.
        self.0
            .base_url
            .join("index.php")
            .expect("index.php is a valid relative ref")
    }

    /// Run an [`Endpoint`] and map its [`QueryError`] onto the public [`Error`].
    pub(crate) async fn query<T: Endpoint + Send + Sync>(
        &self,
        endpoint: T,
    ) -> Result<T::Response> {
        if !self.0.skip_preflight {
            let id_site = endpoint
                .params()
                .fields()
                .iter()
                .find(|(k, _)| k == "idSite")
                .map(|(_, v)| v.clone());
            preflight::run(self, endpoint.method(), id_site.as_deref()).await?;
        }
        endpoint.execute(self).await.map_err(map_query_error)
    }

    /// Like [`Self::query`] but skips preflight (used by preflight itself).
    pub(crate) async fn query_unchecked<T: Endpoint + Send + Sync>(
        &self,
        endpoint: T,
    ) -> Result<T::Response> {
        endpoint.execute(self).await.map_err(map_query_error)
    }

    // Module accessors.
    pub fn api(&self) -> ApiHandle<'_> {
        ApiHandle::new(self)
    }
    pub fn visits_summary(&self) -> VisitsSummaryHandle<'_> {
        VisitsSummaryHandle::new(self)
    }
    pub fn live(&self) -> LiveHandle<'_> {
        LiveHandle::new(self)
    }
    pub fn actions(&self) -> ActionsHandle<'_> {
        ActionsHandle::new(self)
    }
    pub fn referrers(&self) -> ReferrersHandle<'_> {
        ReferrersHandle::new(self)
    }

    // Escape hatches.

    /// Parse once into a `Value`, branching on Matomo's error envelope.
    pub async fn call(&self, method: &'static str, params: &Params) -> Result<Value> {
        self.query(RawEndpoint {
            method,
            params: params.clone(),
        })
        .await
    }

    /// Typed call. Single parse: bytes → `Value` once, error-check, then decode.
    pub async fn call_typed<T: DeserializeOwned>(
        &self,
        method: &'static str,
        params: &Params,
    ) -> Result<T> {
        let value = self.call(method, params).await?;
        serde_json::from_value(value).map_err(|source| Error::Decode { source, method })
    }

    /// Lowest-level call: the raw response bytes, no parsing.
    pub async fn call_raw(&self, method: &'static str, params: &Params) -> Result<Bytes> {
        if !self.0.skip_preflight {
            let id_site = params
                .fields()
                .iter()
                .find(|(k, _)| k == "idSite")
                .map(|(_, v)| v.clone());
            preflight::run(self, method, id_site.as_deref()).await?;
        }
        self.call_raw_unchecked(method, params).await
    }

    pub(crate) async fn call_raw_unchecked(
        &self,
        method: &'static str,
        params: &Params,
    ) -> Result<Bytes> {
        let mut form: Vec<(String, String)> = vec![
            ("module".to_string(), "API".to_string()),
            ("method".to_string(), method.to_string()),
            ("format".to_string(), "json".to_string()),
        ];
        form.extend(params.fields().iter().cloned());

        let body = serde_urlencoded::to_string(&form).map_err(|e| Error::Config(e.to_string()))?;
        let req = http::Request::builder()
            .method(http::Method::POST)
            .uri("/index.php")
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(Bytes::from(body))
            .map_err(|e| Error::Config(e.to_string()))?;

        let resp = self.execute(req).await.map_err(map_transport_only)?;
        Ok(resp.into_body())
    }
}

/// Endpoint used by the `call`/`call_typed` escape hatches: any method, any
/// params, decode to a raw `Value`.
struct RawEndpoint {
    method: &'static str,
    params: Params,
}

impl Endpoint for RawEndpoint {
    type Response = Value;
    fn method(&self) -> &'static str {
        self.method
    }
    fn params(&self) -> Params {
        self.params.clone()
    }
}

fn map_query_error(e: QueryError<MatomoClientError>) -> Error {
    match e {
        QueryError::Transport { source } => map_transport_only(source),
        QueryError::Api {
            message,
            method,
            kind,
        } => Error::Api {
            message,
            method,
            kind,
        },
        QueryError::NonJsonBody { method, body } => Error::NonJsonBody { method, body },
        QueryError::Decode { source, method } => Error::Decode { source, method },
        QueryError::Build { source } => Error::Config(source.to_string()),
    }
}

fn map_transport_only(e: MatomoClientError) -> Error {
    match e {
        MatomoClientError::Communication { source } => Error::Http(source),
        other => Error::Config(other.to_string()),
    }
}

impl Client for MatomoClient {
    type Error = MatomoClientError;

    async fn execute(
        &self,
        req: Request<Bytes>,
    ) -> std::result::Result<Response<Bytes>, Self::Error> {
        let url = self.dispatch_url();
        let mut builder = self.0.http.post(url);

        if let Some(ct) = req.headers().get(http::header::CONTENT_TYPE) {
            builder = builder.header(http::header::CONTENT_TYPE, ct.clone());
        }

        let mut body = req.into_body();
        match &self.0.auth {
            Auth::Token(t) => {
                let extra =
                    serde_urlencoded::to_string([("token_auth", t.expose_secret())]).unwrap();
                let mut buf = Vec::with_capacity(body.len() + 1 + extra.len());
                buf.extend_from_slice(&body);
                if !body.is_empty() {
                    buf.push(b'&');
                }
                buf.extend_from_slice(extra.as_bytes());
                body = Bytes::from(buf);
            }
            Auth::Bearer(t) => {
                builder = builder.bearer_auth(t.expose_secret());
            }
        }

        let reqwest_resp = builder.body(body).send().await?.error_for_status()?;

        let status = reqwest_resp.status();
        let version = reqwest_resp.version();
        let mut resp = Response::builder().status(status).version(version);
        if let Some(headers) = resp.headers_mut() {
            for (k, v) in reqwest_resp.headers() {
                headers.insert(k, v.clone());
            }
        }
        Ok(resp.body(reqwest_resp.bytes().await?)?)
    }
}

#[derive(Default)]
#[must_use]
pub struct ClientBuilder {
    base_url: Option<String>,
    auth: Option<Auth>,
    timeout: Option<Duration>,
    skip_preflight: bool,
    http: Option<::reqwest::Client>,
}

impl ClientBuilder {
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = Some(base_url.into());
        self
    }

    pub fn auth(mut self, auth: Auth) -> Self {
        self.auth = Some(auth);
        self
    }

    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Supply a pre-configured `reqwest::Client` (custom TLS, proxy, timeouts).
    pub fn reqwest_client(mut self, http: ::reqwest::Client) -> Self {
        self.http = Some(http);
        self
    }

    /// Opt out of the lazy preflight checks.
    pub fn skip_preflight(mut self) -> Self {
        self.skip_preflight = true;
        self
    }

    pub fn build(self) -> Result<MatomoClient> {
        let raw = self
            .base_url
            .ok_or_else(|| Error::Config("base_url is required".to_string()))?;
        let auth = self
            .auth
            .ok_or_else(|| Error::Config("auth is required".to_string()))?;

        let normalized = if raw.ends_with('/') {
            raw
        } else {
            format!("{raw}/")
        };
        let base_url =
            Url::parse(&normalized).map_err(|e| Error::Config(format!("invalid base_url: {e}")))?;
        if base_url.cannot_be_a_base() {
            return Err(Error::Config(
                "base_url must be a valid base URL".to_string(),
            ));
        }

        let http = match self.http {
            Some(http) => http,
            None => {
                let mut b = ::reqwest::Client::builder();
                b = b.timeout(self.timeout.unwrap_or(Duration::from_secs(60)));
                b.build().map_err(Error::Http)?
            }
        };

        Ok(MatomoClient(Arc::new(Inner {
            http,
            base_url,
            auth,
            skip_preflight: self.skip_preflight,
            preflight: PreflightState::default(),
        })))
    }
}

impl MatomoClient {
    /// Convenience constructor with the default reqwest client.
    pub fn new(base_url: impl Into<String>, auth: Auth) -> Result<Self> {
        Self::builder().base_url(base_url).auth(auth).build()
    }

    /// Constructor that takes a pre-configured `reqwest::Client`.
    pub fn with_reqwest_client(
        base_url: impl Into<String>,
        auth: Auth,
        http: ::reqwest::Client,
    ) -> Result<Self> {
        Self::builder()
            .base_url(base_url)
            .auth(auth)
            .reqwest_client(http)
            .build()
    }
}