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;
pub trait Client {
type Error: Error + Send + Sync + 'static;
fn execute(
&self,
req: Request<Bytes>,
) -> impl Future<Output = Result<Response<Bytes>, Self::Error>> + Send;
}
pub trait Endpoint {
type Response: DeserializeOwned;
fn method(&self) -> &'static str;
fn params(&self) -> Params;
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: body_snippet(body),
})?;
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 })
}
}
#[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 },
}
pub trait Query<C> {
type Result;
fn execute(self, client: &C) -> impl Future<Output = Self::Result> + Send;
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum QueryError<E>
where
E: Error + Send + Sync + 'static,
{
#[error("transport error: {source}")]
Transport { source: E },
#[error("matomo api error in {method}: {message}")]
Api {
message: String,
method: &'static str,
kind: ApiErrorKind,
},
#[error("non-json body from {method}: {body}")]
NonJsonBody { method: &'static str, body: String },
#[error("failed to decode {method} response: {source}")]
Decode {
source: serde_json::Error,
method: &'static str,
},
#[error("failed to build request: {source}")]
Build {
#[from]
source: http::Error,
},
#[error("failed to encode form body: {source}")]
Encode {
#[from]
source: serde_urlencoded::ser::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";
const BODY_SNIPPET_MAX: usize = 2048;
pub(crate) fn body_snippet(body: &[u8]) -> String {
let total = body.len();
let cut = total.min(BODY_SNIPPET_MAX);
let mut out: String = String::from_utf8_lossy(&body[..cut])
.chars()
.map(|c| {
if c.is_ascii_control() && c != '\n' {
' '
} else {
c
}
})
.collect();
if total > BODY_SNIPPET_MAX {
use std::fmt::Write;
let _ = write!(out, " ...(truncated, {total} bytes total)");
}
out
}
pub(crate) fn build_dispatch_request<E>(
method: &str,
params: &Params,
) -> Result<Request<Bytes>, QueryError<E>>
where
E: Error + Send + Sync + 'static,
{
let mut form: Vec<(&str, &str)> =
vec![("module", "API"), ("method", method), ("format", "json")];
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)?;
let 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)?;
Ok(req)
}
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 params = self.params();
let http_req = build_dispatch_request(method, ¶ms)?;
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 },
})
}
}