plansolve 0.25.4

Official Rust client library for the PlanSolve optimization API.
Documentation
use std::time::Duration;

use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::error::{Error, Result};

const API_KEY_HEADER: &str = "X-API-KEY";

/// Shared HTTP transport used by every service client. Cloning is cheap —
/// `reqwest::Client` wraps an internal `Arc` and shares a connection pool.
#[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
    }

    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?)
    }
}

/// Turns the API's typed error bodies into a single human-readable message. The
/// solver endpoints document three error shapes (see the OpenAPI):
/// `ValidationProblemDetails` (400 — `errors` keyed by field), `ErrorResponse`
/// (402/422 — `error`), and `ProblemDetails` (401/403/502 — `title`/`detail`).
/// Anything else falls back to the raw body so no information is ever lost.
fn extract_error_message(body: &str) -> String {
    let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
        return body.to_string();
    };

    // ValidationProblemDetails: { "errors": { "field": ["msg", ...], ... } }
    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("; ");
        }
    }

    // ErrorResponse { "error": "..." }, then ProblemDetails { "detail"/"title": "..." }.
    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");
    }
}