Skip to main content

matomo/
transport.rs

1use std::error::Error;
2use std::future::Future;
3
4use bytes::Bytes;
5use http::{Request, Response};
6use serde::de::DeserializeOwned;
7use serde_json::Value;
8use thiserror::Error;
9
10use crate::error::ApiErrorKind;
11use crate::request::Params;
12
13/// A transport capable of dispatching a single Matomo API request.
14///
15/// Implementors inject the base URL (`{base}/index.php`) and auth; the generic
16/// [`Query`] layer hands them a relative request whose body holds only the form
17/// params (`module`/`method`/`format`/...).
18pub trait Client {
19    type Error: Error + Send + Sync + 'static;
20
21    fn execute(
22        &self,
23        req: Request<Bytes>,
24    ) -> impl Future<Output = Result<Response<Bytes>, Self::Error>> + Send;
25}
26
27/// A single Matomo API endpoint: its `Module.action` method name, its form
28/// params, and how to decode the response.
29pub trait Endpoint {
30    type Response: DeserializeOwned;
31
32    /// The Matomo method, e.g. `"VisitsSummary.get"`.
33    fn method(&self) -> &'static str;
34
35    /// The call-specific form fields (without `module`/`format`/auth).
36    fn params(&self) -> Params;
37
38    /// Decode the response body. The default enforces Matomo's single-parse
39    /// contract: bytes → `Value` once, branch on the `{"result":"error"}`
40    /// envelope, else `from_value::<Response>`.
41    fn parse_response(&self, body: &[u8]) -> Result<Self::Response, ParseError> {
42        let method = self.method();
43        let value: Value = serde_json::from_slice(body).map_err(|_| ParseError::NonJsonBody {
44            body: String::from_utf8_lossy(body).into_owned(),
45        })?;
46        if value.get("result").and_then(Value::as_str) == Some("error") {
47            let message = value
48                .get("message")
49                .and_then(Value::as_str)
50                .unwrap_or("unknown error")
51                .to_string();
52            return Err(ParseError::Api {
53                kind: ApiErrorKind::classify(&message),
54                message,
55            });
56        }
57        serde_json::from_value(value).map_err(|source| ParseError::Decode { source, method })
58    }
59}
60
61/// Outcome of [`Endpoint::parse_response`], lifted into [`QueryError`] by the
62/// blanket [`Query`] impl.
63#[derive(Debug, Error)]
64pub enum ParseError {
65    #[error("matomo api error: {message}")]
66    Api { message: String, kind: ApiErrorKind },
67    #[error("failed to decode {method} response: {source}")]
68    Decode {
69        source: serde_json::Error,
70        method: &'static str,
71    },
72    #[error("non-json body: {body}")]
73    NonJsonBody { body: String },
74}
75
76/// An asynchronous query against a [`Client`].
77pub trait Query<C> {
78    type Result;
79    fn execute(self, client: &C) -> impl Future<Output = Self::Result> + Send;
80}
81
82/// Error returned by [`Query::execute`], generic over the transport error.
83#[derive(Debug, Error)]
84#[non_exhaustive]
85pub enum QueryError<E>
86where
87    E: Error + Send + Sync + 'static,
88{
89    /// Underlying transport failure (DNS, TLS, timeout, header build, ...).
90    #[error("transport error: {source}")]
91    Transport { source: E },
92
93    /// Matomo returned `{"result":"error", ...}` with HTTP 200.
94    #[error("matomo api error in {method}: {message}")]
95    Api {
96        message: String,
97        method: &'static str,
98        kind: ApiErrorKind,
99    },
100
101    /// The body was not JSON at all (e.g. an HTML error page).
102    #[error("non-json body from {method}: {body}")]
103    NonJsonBody { method: &'static str, body: String },
104
105    /// The body was valid JSON but did not match the expected typed shape.
106    #[error("failed to decode {method} response: {source}")]
107    Decode {
108        source: serde_json::Error,
109        method: &'static str,
110    },
111
112    /// Failed to construct the HTTP request.
113    #[error("failed to build request: {source}")]
114    Build {
115        #[from]
116        source: http::Error,
117    },
118}
119
120impl<E> QueryError<E>
121where
122    E: Error + Send + Sync + 'static,
123{
124    pub fn transport(source: E) -> Self {
125        QueryError::Transport { source }
126    }
127}
128
129const DISPATCH_PATH: &str = "/index.php";
130
131impl<T, C> Query<C> for T
132where
133    T: Endpoint + Send + Sync,
134    C: Client + Send + Sync,
135{
136    type Result = Result<T::Response, QueryError<C::Error>>;
137
138    async fn execute(self, client: &C) -> Self::Result {
139        let method = self.method();
140
141        let mut form: Vec<(&str, &str)> =
142            vec![("module", "API"), ("method", method), ("format", "json")];
143        let params = self.params();
144        for (k, v) in params.fields() {
145            form.push((k.as_str(), v.as_str()));
146        }
147        let body: Bytes = serde_urlencoded::to_string(&form)
148            .map(Bytes::from)
149            .unwrap_or_default();
150
151        let http_req = http::Request::builder()
152            .method(http::Method::POST)
153            .uri(DISPATCH_PATH)
154            .header("Content-Type", "application/x-www-form-urlencoded")
155            .header("Accept", "application/json")
156            .body(body)?;
157
158        let response = client
159            .execute(http_req)
160            .await
161            .map_err(QueryError::transport)?;
162
163        self.parse_response(response.body()).map_err(|e| match e {
164            ParseError::Api { message, kind } => QueryError::Api {
165                message,
166                method,
167                kind,
168            },
169            ParseError::Decode { source, method } => QueryError::Decode { source, method },
170            ParseError::NonJsonBody { body } => QueryError::NonJsonBody { method, body },
171        })
172    }
173}