use serde::de::DeserializeOwned;
use crate::http_client::{Client, ClientError, RequestBuilder};
use crate::wire::Endpoint;
#[derive(Debug, thiserror::Error)]
pub enum WireError {
#[error("service call to {endpoint} failed: {source}")]
Transport {
endpoint: &'static str,
#[source]
source: ClientError,
},
#[error("service call to {endpoint} returned {status}: {body}")]
Status {
endpoint: &'static str,
status: u16,
body: String,
},
#[error("service call to {endpoint} declares unsupported method `{method}`")]
UnsupportedMethod {
endpoint: &'static str,
method: &'static str,
},
}
impl WireError {
#[must_use]
pub const fn status(&self) -> http::StatusCode {
http::StatusCode::BAD_GATEWAY
}
}
const MAX_ERROR_BODY: usize = 512;
pub async fn call<E: Endpoint>(
http: &Client,
base_url: &str,
path: &str,
request: &E::Request,
) -> Result<E::Response, WireError> {
let endpoint = E::NAME;
let url = join_url(base_url, path);
let Some(mut builder) = request_builder::<E>(http, &url) else {
return Err(WireError::UnsupportedMethod {
endpoint,
method: E::METHOD,
});
};
if E::HAS_BODY {
builder = builder.json(request);
}
let response = builder
.send()
.await
.map_err(|source| WireError::Transport { endpoint, source })?;
let status = response.status().as_u16();
if !response.is_success() {
return Err(WireError::Status {
endpoint,
status,
body: truncate_on_boundary(response.text(), MAX_ERROR_BODY),
});
}
decode::<E::Response>(response).map_err(|source| WireError::Transport { endpoint, source })
}
fn truncate_on_boundary(mut body: String, max: usize) -> String {
if body.len() <= max {
return body;
}
let cut = (0..=max)
.rev()
.find(|&i| body.is_char_boundary(i))
.unwrap_or(0);
body.truncate(cut);
body
}
fn request_builder<E: Endpoint>(http: &Client, url: &str) -> Option<RequestBuilder> {
let builder = match E::METHOD {
"GET" => http.get(url),
"POST" => http.post(url),
"PUT" => http.put(url),
"PATCH" => http.patch(url),
"DELETE" => http.delete(url),
_ => return None,
};
Some(builder.no_redirect())
}
fn decode<T: DeserializeOwned>(response: crate::http_client::Response) -> Result<T, ClientError> {
let bytes = response.bytes();
if bytes.is_empty() {
return serde_json::from_slice(b"null").map_err(ClientError::Json);
}
serde_json::from_slice(&bytes).map_err(ClientError::Json)
}
fn join_url(base: &str, path: &str) -> String {
let base = base.split(['?', '#']).next().unwrap_or(base);
let base = base.trim_end_matches('/');
if path.starts_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
#[cfg(test)]
mod tests {
use super::{join_url, truncate_on_boundary};
#[test]
fn join_url_never_doubles_or_drops_the_slash() {
assert_eq!(join_url("http://a", "/items"), "http://a/items");
assert_eq!(join_url("http://a/", "/items"), "http://a/items");
assert_eq!(join_url("http://a/", "items"), "http://a/items");
assert_eq!(join_url("http://a/v1/", "/items"), "http://a/v1/items");
}
#[test]
fn join_url_drops_a_query_or_fragment_on_the_base() {
assert_eq!(
join_url("http://a/?trace=1", "/items/7"),
"http://a/items/7"
);
assert_eq!(join_url("http://a#frag", "/items/7"), "http://a/items/7");
}
#[test]
fn an_error_body_is_cut_at_a_character_boundary() {
let body = format!("{}\u{e9}tail", "a".repeat(511));
let cut = truncate_on_boundary(body, 512);
assert_eq!(cut.len(), 511, "must step back to the boundary");
assert!(cut.is_char_boundary(cut.len()));
}
#[test]
fn a_short_error_body_is_untouched() {
assert_eq!(truncate_on_boundary("short".to_owned(), 512), "short");
}
#[test]
fn an_error_body_that_is_one_huge_character_run_cuts_to_empty_rather_than_panicking() {
let body = "\u{1f600}".repeat(4);
assert!(truncate_on_boundary(body, 2).is_empty());
}
}