Skip to main content

nubis_sdk/
http.rs

1use std::time::Duration;
2
3use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
4use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
5use reqwest::{Client, Method};
6use serde::de::DeserializeOwned;
7use serde::Serialize;
8use serde_json::{json, Value};
9
10use crate::NubisError;
11
12const DEFAULT_BASE_URL: &str = "https://nubis-core.onrender.com";
13const DEFAULT_TIMEOUT_SECS: u64 = 30;
14
15#[derive(Debug, Clone)]
16pub struct NubisClient {
17    http: Client,
18    base_url: String,
19    api_key: Option<String>,
20}
21
22#[derive(Debug, Clone)]
23pub struct NubisClientBuilder {
24    base_url: String,
25    api_key: Option<String>,
26    timeout: Duration,
27    default_headers: HeaderMap,
28}
29
30impl NubisClientBuilder {
31    pub fn new() -> Self {
32        let mut default_headers = HeaderMap::new();
33        default_headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
34        Self {
35            base_url: DEFAULT_BASE_URL.to_string(),
36            api_key: None,
37            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
38            default_headers,
39        }
40    }
41
42    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
43        self.base_url = base_url.into();
44        self
45    }
46
47    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
48        self.api_key = Some(api_key.into());
49        self
50    }
51
52    pub fn timeout(mut self, timeout: Duration) -> Self {
53        self.timeout = timeout;
54        self
55    }
56
57    pub fn default_header(mut self, key: &'static str, value: impl AsRef<str>) -> Self {
58        if let Ok(header_value) = HeaderValue::from_str(value.as_ref()) {
59            self.default_headers.insert(key, header_value);
60        }
61        self
62    }
63
64    pub fn build(self) -> Result<NubisClient, NubisError> {
65        let http = Client::builder()
66            .timeout(self.timeout)
67            .default_headers(self.default_headers)
68            .build()?;
69
70        Ok(NubisClient {
71            http,
72            base_url: self.base_url,
73            api_key: self.api_key,
74        })
75    }
76}
77
78impl Default for NubisClientBuilder {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl NubisClient {
85    pub fn builder() -> NubisClientBuilder {
86        NubisClientBuilder::new()
87    }
88
89    pub fn new(api_key: impl Into<String>) -> Result<Self, NubisError> {
90        Self::builder().api_key(api_key).build()
91    }
92
93    pub fn with_base_url(base_url: impl Into<String>) -> Result<Self, NubisError> {
94        Self::builder().base_url(base_url).build()
95    }
96
97    pub async fn request_value<B: Serialize + ?Sized>(
98        &self,
99        method: Method,
100        path_template: &str,
101        path_params: &[(&str, &str)],
102        query: Option<&[(&str, &str)]>,
103        body: Option<&B>,
104    ) -> Result<Value, NubisError> {
105        let rendered_path = render_path(path_template, path_params);
106        let url = build_url(&self.base_url, &rendered_path);
107
108        let mut req = self.http.request(method, url);
109        if let Some(api_key) = &self.api_key {
110            req = req.header(AUTHORIZATION, format!("Bearer {api_key}"));
111        }
112        if let Some(query) = query {
113            req = req.query(query);
114        }
115        if let Some(body) = body {
116            req = req.header(CONTENT_TYPE, "application/json").json(body);
117        }
118
119        let response = req.send().await?;
120        let status = response.status();
121        let text = response.text().await?;
122        let body_json = parse_body(&text);
123
124        if !status.is_success() {
125            let message = extract_error_message(
126                &body_json,
127                status.canonical_reason().unwrap_or("Request failed"),
128            );
129            return Err(NubisError::Http {
130                status: status.as_u16(),
131                message,
132                body: body_json,
133            });
134        }
135
136        Ok(body_json)
137    }
138
139    pub async fn request<T, B>(
140        &self,
141        method: Method,
142        path_template: &str,
143        path_params: &[(&str, &str)],
144        query: Option<&[(&str, &str)]>,
145        body: Option<&B>,
146    ) -> Result<T, NubisError>
147    where
148        T: DeserializeOwned,
149        B: Serialize + ?Sized,
150    {
151        let value = self
152            .request_value(method, path_template, path_params, query, body)
153            .await?;
154        Ok(serde_json::from_value(value)?)
155    }
156}
157
158fn parse_body(text: &str) -> Value {
159    if text.trim().is_empty() {
160        Value::Null
161    } else {
162        serde_json::from_str(text).unwrap_or_else(|_| json!(text))
163    }
164}
165
166fn extract_error_message(body: &Value, fallback: &str) -> String {
167    if let Some(message) = body
168        .get("error")
169        .and_then(|error| error.get("message"))
170        .and_then(Value::as_str)
171        .filter(|value| !value.trim().is_empty())
172    {
173        return message.to_string();
174    }
175
176    if let Some(message) = body
177        .get("message")
178        .and_then(Value::as_str)
179        .filter(|value| !value.trim().is_empty())
180    {
181        return message.to_string();
182    }
183
184    if let Some(message) = body.as_str().filter(|value| !value.trim().is_empty()) {
185        return message.to_string();
186    }
187
188    fallback.to_string()
189}
190
191pub(crate) fn render_path(path_template: &str, path_params: &[(&str, &str)]) -> String {
192    let mut rendered = path_template.to_string();
193    for (name, value) in path_params {
194        let placeholder = format!(":{name}");
195        let encoded = utf8_percent_encode(value, NON_ALPHANUMERIC).to_string();
196        rendered = rendered.replace(&placeholder, &encoded);
197    }
198    rendered
199}
200
201fn build_url(base_url: &str, path: &str) -> String {
202    if path.starts_with("http://") || path.starts_with("https://") {
203        return path.to_string();
204    }
205    format!(
206        "{}/{}",
207        base_url.trim_end_matches('/'),
208        path.trim_start_matches('/')
209    )
210}