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}