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;
#[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()
}
}
#[derive(Debug, Error)]
pub enum MatomoClientError {
#[error("communication with matomo: {source}")]
Communication {
#[from]
source: ::reqwest::Error,
},
#[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 {
self.0
.base_url
.join("index.php")
.expect("index.php is a valid relative ref")
}
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)
}
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)
}
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)
}
pub async fn call(&self, method: &'static str, params: &Params) -> Result<Value> {
self.query(RawEndpoint {
method,
params: params.clone(),
})
.await
}
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 })
}
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())
}
}
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
}
pub fn reqwest_client(mut self, http: ::reqwest::Client) -> Self {
self.http = Some(http);
self
}
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 {
pub fn new(base_url: impl Into<String>, auth: Auth) -> Result<Self> {
Self::builder().base_url(base_url).auth(auth).build()
}
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()
}
}