matomo-rs 0.1.0

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>`.
    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: String::from_utf8_lossy(body).into_owned(),
        })?;
        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).
    #[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,
    },
}

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";

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 mut form: Vec<(&str, &str)> =
            vec![("module", "API"), ("method", method), ("format", "json")];
        let params = self.params();
        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)
            .unwrap_or_default();

        let http_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)?;

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