use crate::{
error::SocketError,
metric::{Field, Metric, Tag},
protocol::http::{BuildStrategy, HttpParser, rest::RestRequest},
};
use bytes::Bytes;
use chrono::Utc;
use std::borrow::Cow;
#[derive(Debug)]
pub struct RestClient<'a, Strategy, Parser> {
pub http_client: reqwest::Client,
pub base_url: Cow<'a, str>,
pub strategy: Strategy,
pub parser: Parser,
}
impl<Strategy, Parser> RestClient<'_, Strategy, Parser>
where
Strategy: BuildStrategy,
Parser: HttpParser,
{
pub async fn execute<Request>(
&self,
request: Request,
) -> Result<(Request::Response, Metric), Parser::OutputError>
where
Request: RestRequest,
{
let request = self.build(request)?;
let (status, payload, latency) = self.measured_execution::<Request>(request).await?;
self.parser
.parse::<Request::Response>(status, &payload)
.map(|response| (response, latency))
}
pub fn build<Request>(&self, request: Request) -> Result<reqwest::Request, SocketError>
where
Request: RestRequest,
{
let url = format!("{}{}", self.base_url, request.path());
let mut builder = self
.http_client
.request(Request::method(), url)
.timeout(request.timeout());
if let Some(query_params) = request.query_params() {
builder = builder.query(query_params);
}
if let Some(body) = request.body() {
builder = builder.json(body);
}
self.strategy.build(request, builder)
}
pub async fn measured_execution<Request>(
&self,
request: reqwest::Request,
) -> Result<(reqwest::StatusCode, Bytes, Metric), SocketError>
where
Request: RestRequest,
{
#[allow(clippy::cast_sign_loss)]
let time = Utc::now().timestamp_millis() as u64;
let mut latency = Metric {
name: "http_request_duration",
time,
tags: vec![
Tag::new("http_method", Request::method().as_str()),
Tag::new("base_url", self.base_url.as_ref()),
Tag::new("path", request.url().path()),
],
fields: Vec::with_capacity(1),
};
let start = std::time::Instant::now();
let response = self.http_client.execute(request).await?;
#[allow(clippy::cast_possible_truncation)]
let duration = start.elapsed().as_millis() as u64;
latency
.tags
.push(Tag::new("status_code", response.status().as_str()));
latency.fields.push(Field::new("duration", duration));
let status_code = response.status();
let payload = response.bytes().await?;
Ok((status_code, payload, latency))
}
}
impl<'a, Strategy, Parser> RestClient<'a, Strategy, Parser> {
pub fn new<Url: Into<Cow<'a, str>>>(base_url: Url, strategy: Strategy, parser: Parser) -> Self {
Self {
http_client: reqwest::Client::new(),
base_url: base_url.into(),
strategy,
parser,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use crate::protocol::http::{HttpParser, public::PublicNoHeaders};
use reqwest::StatusCode;
use serde::Deserialize;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const GZIPPED_BODY: &[u8] = &[
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x4b, 0xcc,
0x29, 0x4d, 0x55, 0xb2, 0x52, 0x4a, 0x49, 0x4d, 0xce, 0x4f, 0x49, 0x4d, 0x51, 0xaa, 0x05,
0x00, 0xba, 0xf3, 0x5c, 0x77, 0x13, 0x00, 0x00, 0x00,
];
#[derive(Debug, Deserialize, PartialEq)]
struct Payload {
value: String,
}
struct GetPayload;
impl RestRequest for GetPayload {
type Response = Payload;
type QueryParams = ();
type Body = ();
fn path(&self) -> Cow<'static, str> {
Cow::Borrowed("/payload")
}
fn method() -> reqwest::Method {
reqwest::Method::GET
}
}
struct JsonParser;
impl HttpParser for JsonParser {
type ApiError = serde_json::Value;
type OutputError = SocketError;
fn parse_api_error(&self, status: StatusCode, error: Self::ApiError) -> Self::OutputError {
SocketError::HttpResponse(status, error.to_string())
}
}
#[tokio::test]
async fn the_default_client_negotiates_and_decodes_gzip() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payload"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-encoding", "gzip")
.insert_header("content-type", "application/json")
.set_body_bytes(GZIPPED_BODY),
)
.mount(&server)
.await;
let client = RestClient::new(server.uri(), PublicNoHeaders, JsonParser);
let (response, _) = client.execute(GetPayload).await.unwrap();
assert_eq!(
response,
Payload {
value: "decoded".to_owned()
}
);
let requests = server.received_requests().await.unwrap();
let accept_encoding = requests[0]
.headers
.get("accept-encoding")
.and_then(|value| value.to_str().ok());
assert!(
accept_encoding.is_some_and(|value| value.contains("gzip")),
"expected gzip in Accept-Encoding, sent {accept_encoding:?}"
);
}
}