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    ///
42    /// # Errors
43    ///
44    /// Returns a [`ParseError`] when the body is not JSON, carries Matomo's
45    /// error envelope, or does not match the expected typed shape.
46    fn parse_response(&self, body: &[u8]) -> Result<Self::Response, ParseError> {
47        let method = self.method();
48        let value: Value = serde_json::from_slice(body).map_err(|_| ParseError::NonJsonBody {
49            body: body_snippet(body),
50        })?;
51        if value.get("result").and_then(Value::as_str) == Some("error") {
52            let message = value
53                .get("message")
54                .and_then(Value::as_str)
55                .unwrap_or("unknown error")
56                .to_string();
57            return Err(ParseError::Api {
58                kind: ApiErrorKind::classify(&message),
59                message,
60            });
61        }
62        serde_json::from_value(value).map_err(|source| ParseError::Decode { source, method })
63    }
64}
65
66/// Outcome of [`Endpoint::parse_response`], lifted into [`QueryError`] by the
67/// blanket [`Query`] impl.
68#[derive(Debug, Error)]
69pub enum ParseError {
70    #[error("matomo api error: {message}")]
71    Api { message: String, kind: ApiErrorKind },
72    #[error("failed to decode {method} response: {source}")]
73    Decode {
74        source: serde_json::Error,
75        method: &'static str,
76    },
77    #[error("non-json body: {body}")]
78    NonJsonBody { body: String },
79}
80
81/// An asynchronous query against a [`Client`].
82pub trait Query<C> {
83    type Result;
84    fn execute(self, client: &C) -> impl Future<Output = Self::Result> + Send;
85}
86
87/// Error returned by [`Query::execute`], generic over the transport error.
88#[derive(Debug, Error)]
89#[non_exhaustive]
90pub enum QueryError<E>
91where
92    E: Error + Send + Sync + 'static,
93{
94    /// Underlying transport failure (DNS, TLS, timeout, header build, ...).
95    #[error("transport error: {source}")]
96    Transport { source: E },
97
98    /// Matomo returned `{"result":"error", ...}` with HTTP 200.
99    #[error("matomo api error in {method}: {message}")]
100    Api {
101        message: String,
102        method: &'static str,
103        kind: ApiErrorKind,
104    },
105
106    /// The body was not JSON at all (e.g. an HTML error page). The captured
107    /// body is sanitized and truncated to 2 KiB.
108    #[error("non-json body from {method}: {body}")]
109    NonJsonBody { method: &'static str, body: String },
110
111    /// The body was valid JSON but did not match the expected typed shape.
112    #[error("failed to decode {method} response: {source}")]
113    Decode {
114        source: serde_json::Error,
115        method: &'static str,
116    },
117
118    /// Failed to construct the HTTP request.
119    #[error("failed to build request: {source}")]
120    Build {
121        #[from]
122        source: http::Error,
123    },
124
125    /// Failed to url-encode the form body.
126    #[error("failed to encode form body: {source}")]
127    Encode {
128        #[from]
129        source: serde_urlencoded::ser::Error,
130    },
131}
132
133impl<E> QueryError<E>
134where
135    E: Error + Send + Sync + 'static,
136{
137    pub fn transport(source: E) -> Self {
138        QueryError::Transport { source }
139    }
140}
141
142const DISPATCH_PATH: &str = "/index.php";
143
144const BODY_SNIPPET_MAX: usize = 2048;
145
146/// Capture a server body for error display: lossy UTF-8, control characters
147/// (except newline) replaced, truncated to 2 KiB with an explicit marker.
148pub(crate) fn body_snippet(body: &[u8]) -> String {
149    let total = body.len();
150    let cut = total.min(BODY_SNIPPET_MAX);
151    let mut out: String = String::from_utf8_lossy(&body[..cut])
152        .chars()
153        .map(|c| {
154            if c.is_ascii_control() && c != '\n' {
155                ' '
156            } else {
157                c
158            }
159        })
160        .collect();
161    if total > BODY_SNIPPET_MAX {
162        use std::fmt::Write;
163        // Writing to a String cannot fail.
164        let _ = write!(out, " ...(truncated, {total} bytes total)");
165    }
166    out
167}
168
169/// Build the dispatch `http::Request` for a `(method, params)` pair. Shared by
170/// the blanket [`Query`] impl and the raw call path so both send identical
171/// headers and map build errors the same way.
172pub(crate) fn build_dispatch_request<E>(
173    method: &str,
174    params: &Params,
175) -> Result<Request<Bytes>, QueryError<E>>
176where
177    E: Error + Send + Sync + 'static,
178{
179    let mut form: Vec<(&str, &str)> =
180        vec![("module", "API"), ("method", method), ("format", "json")];
181    for (k, v) in params.fields() {
182        form.push((k.as_str(), v.as_str()));
183    }
184    let body: Bytes = serde_urlencoded::to_string(&form).map(Bytes::from)?;
185
186    let req = http::Request::builder()
187        .method(http::Method::POST)
188        .uri(DISPATCH_PATH)
189        .header("Content-Type", "application/x-www-form-urlencoded")
190        .header("Accept", "application/json")
191        .body(body)?;
192    Ok(req)
193}
194
195impl<T, C> Query<C> for T
196where
197    T: Endpoint + Send + Sync,
198    C: Client + Send + Sync,
199{
200    type Result = Result<T::Response, QueryError<C::Error>>;
201
202    async fn execute(self, client: &C) -> Self::Result {
203        let method = self.method();
204        let params = self.params();
205        let http_req = build_dispatch_request(method, &params)?;
206
207        let response = client
208            .execute(http_req)
209            .await
210            .map_err(QueryError::transport)?;
211
212        self.parse_response(response.body()).map_err(|e| match e {
213            ParseError::Api { message, kind } => QueryError::Api {
214                message,
215                method,
216                kind,
217            },
218            ParseError::Decode { source, method } => QueryError::Decode { source, method },
219            ParseError::NonJsonBody { body } => QueryError::NonJsonBody { method, body },
220        })
221    }
222}