use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::error::{Error, Result};
const API_KEY_HEADER: &str = "X-API-KEY";
#[derive(Clone)]
pub(crate) struct Transport {
http: reqwest::Client,
base_url: String,
api_key: String,
}
impl Transport {
pub(crate) fn new(base_url: String, api_key: String) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("failed to build HTTP client");
Self {
http,
base_url,
api_key,
}
}
pub(crate) async fn post_json<B, R>(&self, path: &str, body: &B) -> Result<R>
where
B: Serialize + ?Sized,
R: DeserializeOwned,
{
let mut req = self.http.post(self.url(path)).json(body);
req = self.with_api_key(req);
Self::decode(req.send().await?).await
}
pub(crate) async fn get_json<R>(&self, path: &str) -> Result<R>
where
R: DeserializeOwned,
{
let mut req = self.http.get(self.url(path));
req = self.with_api_key(req);
Self::decode(req.send().await?).await
}
pub(crate) async fn delete_json<R>(&self, path: &str) -> Result<R>
where
R: DeserializeOwned,
{
let mut req = self.http.delete(self.url(path));
req = self.with_api_key(req);
Self::decode(req.send().await?).await
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn with_api_key(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if self.api_key.is_empty() {
req
} else {
req.header(API_KEY_HEADER, &self.api_key)
}
}
async fn decode<R>(resp: reqwest::Response) -> Result<R>
where
R: DeserializeOwned,
{
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
body: extract_error_message(&body),
});
}
Ok(resp.json::<R>().await?)
}
}
fn extract_error_message(body: &str) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
};
if let Some(errors) = value.get("errors").and_then(|e| e.as_object()) {
let mut messages: Vec<String> = errors
.iter()
.flat_map(|(field, msgs)| {
msgs.as_array()
.into_iter()
.flatten()
.filter_map(|m| m.as_str())
.map(move |m| format!("{field}: {m}"))
})
.collect();
if !messages.is_empty() {
messages.sort();
return messages.join("; ");
}
}
for key in ["error", "detail", "title"] {
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
if !text.is_empty() {
return text.to_string();
}
}
}
body.to_string()
}
#[cfg(test)]
mod tests {
use super::extract_error_message;
#[test]
fn extracts_validation_problem_field_errors() {
let body = r#"{"type":"...","title":"One or more validation errors occurred.","status":400,
"errors":{"Vehicles":["At least one vehicle is required."],
"Weights[minimizeTravelTime]":["must be in score notation like '1hard/0medium/0soft'."]}}"#;
let msg = extract_error_message(body);
assert!(
msg.contains("Vehicles: At least one vehicle is required."),
"{msg}"
);
assert!(msg.contains("must be in score notation"), "{msg}");
}
#[test]
fn extracts_error_response() {
let msg = extract_error_message(r#"{"error":"No active subscription for this solver."}"#);
assert_eq!(msg, "No active subscription for this solver.");
}
#[test]
fn extracts_problem_details_detail() {
let msg = extract_error_message(
r#"{"title":"Solver Error","detail":"temporarily unavailable","status":502}"#,
);
assert_eq!(msg, "temporarily unavailable");
}
#[test]
fn falls_back_to_raw_body_for_unknown_shapes() {
assert_eq!(extract_error_message("plain text boom"), "plain text boom");
}
}