matomo-rs 0.1.1

Async client for the Matomo Reporting API, focused on data export and migration
Documentation
use std::error::Error;
use std::future::Future;

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

use crate::error::ApiErrorKind;
use crate::request::Params;

/// A transport capable of dispatching a single Matomo API request.
///
/// Implementors inject the base URL (`{base}/index.php`) and auth; the generic
/// [`Query`] layer hands them a relative request whose body holds only the form
/// params (`module`/`method`/`format`/...).
pub trait Client {
    type Error: Error + Send + Sync + 'static;

    fn execute(
        &self,
        req: Request<Bytes>,
    ) -> impl Future<Output = Result<Response<Bytes>, Self::Error>> + Send;
}

/// A single Matomo API endpoint: its `Module.action` method name, its form
/// params, and how to decode the response.
pub trait Endpoint {
    type Response: DeserializeOwned;

    /// The Matomo method, e.g. `"VisitsSummary.get"`.
    fn method(&self) -> &'static str;

    /// The call-specific form fields (without `module`/`format`/auth).
    fn params(&self) -> Params;

    /// Decode the response body. The default enforces Matomo's single-parse
    /// contract: bytes → `Value` once, branch on the `{"result":"error"}`
    /// envelope, else `from_value::<Response>`.
    ///
    /// # Errors
    ///
    /// Returns a [`ParseError`] when the body is not JSON, carries Matomo's
    /// error envelope, or does not match the expected typed shape.
    fn parse_response(&self, body: &[u8]) -> Result<Self::Response, ParseError> {
        let method = self.method();
        let value: Value = serde_json::from_slice(body).map_err(|_| ParseError::NonJsonBody {
            body: body_snippet(body),
        })?;
        if value.get("result").and_then(Value::as_str) == Some("error") {
            let message = value
                .get("message")
                .and_then(Value::as_str)
                .unwrap_or("unknown error")
                .to_string();
            return Err(ParseError::Api {
                kind: ApiErrorKind::classify(&message),
                message,
            });
        }
        serde_json::from_value(value).map_err(|source| ParseError::Decode { source, method })
    }
}

/// Outcome of [`Endpoint::parse_response`], lifted into [`QueryError`] by the
/// blanket [`Query`] impl.
#[derive(Debug, Error)]
pub enum ParseError {
    #[error("matomo api error: {message}")]
    Api { message: String, kind: ApiErrorKind },
    #[error("failed to decode {method} response: {source}")]
    Decode {
        source: serde_json::Error,
        method: &'static str,
    },
    #[error("non-json body: {body}")]
    NonJsonBody { body: String },
}

/// An asynchronous query against a [`Client`].
pub trait Query<C> {
    type Result;
    fn execute(self, client: &C) -> impl Future<Output = Self::Result> + Send;
}

/// Error returned by [`Query::execute`], generic over the transport error.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum QueryError<E>
where
    E: Error + Send + Sync + 'static,
{
    /// Underlying transport failure (DNS, TLS, timeout, header build, ...).
    #[error("transport error: {source}")]
    Transport { source: E },

    /// Matomo returned `{"result":"error", ...}` with HTTP 200.
    #[error("matomo api error in {method}: {message}")]
    Api {
        message: String,
        method: &'static str,
        kind: ApiErrorKind,
    },

    /// The body was not JSON at all (e.g. an HTML error page). The captured
    /// body is sanitized and truncated to 2 KiB.
    #[error("non-json body from {method}: {body}")]
    NonJsonBody { method: &'static str, body: String },

    /// The body was valid JSON but did not match the expected typed shape.
    #[error("failed to decode {method} response: {source}")]
    Decode {
        source: serde_json::Error,
        method: &'static str,
    },

    /// Failed to construct the HTTP request.
    #[error("failed to build request: {source}")]
    Build {
        #[from]
        source: http::Error,
    },

    /// Failed to url-encode the form body.
    #[error("failed to encode form body: {source}")]
    Encode {
        #[from]
        source: serde_urlencoded::ser::Error,
    },
}

impl<E> QueryError<E>
where
    E: Error + Send + Sync + 'static,
{
    pub fn transport(source: E) -> Self {
        QueryError::Transport { source }
    }
}

const DISPATCH_PATH: &str = "/index.php";

const BODY_SNIPPET_MAX: usize = 2048;

/// Capture a server body for error display: lossy UTF-8, control characters
/// (except newline) replaced, truncated to 2 KiB with an explicit marker.
pub(crate) fn body_snippet(body: &[u8]) -> String {
    let total = body.len();
    let cut = total.min(BODY_SNIPPET_MAX);
    let mut out: String = String::from_utf8_lossy(&body[..cut])
        .chars()
        .map(|c| {
            if c.is_ascii_control() && c != '\n' {
                ' '
            } else {
                c
            }
        })
        .collect();
    if total > BODY_SNIPPET_MAX {
        use std::fmt::Write;
        // Writing to a String cannot fail.
        let _ = write!(out, " ...(truncated, {total} bytes total)");
    }
    out
}

/// Build the dispatch `http::Request` for a `(method, params)` pair. Shared by
/// the blanket [`Query`] impl and the raw call path so both send identical
/// headers and map build errors the same way.
pub(crate) fn build_dispatch_request<E>(
    method: &str,
    params: &Params,
) -> Result<Request<Bytes>, QueryError<E>>
where
    E: Error + Send + Sync + 'static,
{
    let mut form: Vec<(&str, &str)> =
        vec![("module", "API"), ("method", method), ("format", "json")];
    for (k, v) in params.fields() {
        form.push((k.as_str(), v.as_str()));
    }
    let body: Bytes = serde_urlencoded::to_string(&form).map(Bytes::from)?;

    let req = http::Request::builder()
        .method(http::Method::POST)
        .uri(DISPATCH_PATH)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .header("Accept", "application/json")
        .body(body)?;
    Ok(req)
}

impl<T, C> Query<C> for T
where
    T: Endpoint + Send + Sync,
    C: Client + Send + Sync,
{
    type Result = Result<T::Response, QueryError<C::Error>>;

    async fn execute(self, client: &C) -> Self::Result {
        let method = self.method();
        let params = self.params();
        let http_req = build_dispatch_request(method, &params)?;

        let response = client
            .execute(http_req)
            .await
            .map_err(QueryError::transport)?;

        self.parse_response(response.body()).map_err(|e| match e {
            ParseError::Api { message, kind } => QueryError::Api {
                message,
                method,
                kind,
            },
            ParseError::Decode { source, method } => QueryError::Decode { source, method },
            ParseError::NonJsonBody { body } => QueryError::NonJsonBody { method, body },
        })
    }
}