matomo-rs 0.1.0

Async client for the Matomo Reporting API, focused on data export and migration
Documentation
use std::sync::Mutex;

use serde_json::Value;

use crate::auth::Auth;
use crate::endpoints::{ApiGetMatomoVersion, ApiGetReportMetadata};
use crate::error::{Error, Result};
use crate::request::Params;
use crate::reqwest::MatomoClient;

/// Methods we depend on and want confirmed present in the instance's report
/// metadata. `Live.getLastVisitsDetails` is intentionally absent: the Live
/// module is not reportable, so we gate it purely on the version check.
const REQUIRED_METADATA_METHODS: &[(&str, &str)] = &[("VisitsSummary", "get")];

#[derive(Default)]
pub(crate) struct PreflightState {
    done: Mutex<Option<std::result::Result<(), String>>>,
}

fn minimum_version(auth: &Auth) -> (u32, u32) {
    match auth {
        Auth::Bearer(_) => (5, 4),
        Auth::Token(_) => (5, 0),
    }
}

fn parse_major_minor(version: &str) -> Option<(u32, u32)> {
    let mut parts = version.split('.');
    let major = parts.next()?.parse().ok()?;
    let minor = parts.next().unwrap_or("0").parse().ok()?;
    Some((major, minor))
}

pub(crate) async fn run(
    client: &MatomoClient,
    method: &'static str,
    id_site: Option<&str>,
) -> Result<()> {
    if let Some(prev) = client.inner().preflight.done.lock().unwrap().clone() {
        return prev.map_err(Error::Preflight);
    }

    let outcome = perform(client, method, id_site).await;
    let stored = match &outcome {
        Ok(()) => Ok(()),
        Err(e) => Err(e.to_string()),
    };
    *client.inner().preflight.done.lock().unwrap() = Some(stored);
    outcome
}

async fn perform(client: &MatomoClient, method: &'static str, id_site: Option<&str>) -> Result<()> {
    let value = client.query_unchecked(ApiGetMatomoVersion).await?;
    let version = value
        .get("value")
        .and_then(Value::as_str)
        .ok_or_else(|| Error::Preflight("getMatomoVersion returned no value".to_string()))?;
    let got = parse_major_minor(version)
        .ok_or_else(|| Error::Preflight(format!("unparseable version: {version}")))?;
    let want = minimum_version(&client.inner().auth);
    if got < want {
        return Err(Error::Preflight(format!(
            "Matomo {}.{} is below the required {}.{} (method {method})",
            got.0, got.1, want.0, want.1
        )));
    }

    // Report-metadata check. Matomo has required an idSite on getReportMetadata
    // since 3.0, so skip this sub-check when the triggering call carried none.
    let Some(id_site) = id_site else {
        return Ok(());
    };
    let value = client
        .query_unchecked(ApiGetReportMetadata {
            params: Params::new().set("idSite", id_site),
        })
        .await?;
    let reports = value
        .as_array()
        .ok_or_else(|| Error::Preflight("getReportMetadata was not an array".to_string()))?;

    for (module, action) in REQUIRED_METADATA_METHODS {
        let present = reports.iter().any(|r| {
            r.get("module").and_then(Value::as_str) == Some(*module)
                && r.get("action").and_then(Value::as_str) == Some(*action)
        });
        if !present {
            return Err(Error::Preflight(format!(
                "instance does not expose required report {module}.{action}"
            )));
        }
    }

    Ok(())
}