1use std::time::Duration;
4
5use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
6use reqwest::{Client as HttpClient, Method, Response, StatusCode};
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9
10use crate::errors::{Error, parse_api_error};
11use crate::types::QueryOptions;
12
13const DEFAULT_BASE_URL: &str = "https://api.gitverse.ru";
14const DEFAULT_API_VERSION: &str = "1";
15const DEFAULT_TIMEOUT_SECS: u64 = 30;
16const DEFAULT_USER_AGENT: &str = "gitverse-sdk";
17
18#[derive(Debug, Clone)]
20pub struct RateLimitInfo {
21 pub limit: i64,
23 pub remaining: i64,
25 pub reset: i64,
27 pub retry_after: i64,
29}
30
31#[derive(Debug, Clone)]
33pub struct ApiVersionInfo {
34 pub version: String,
36 pub latest_version: String,
38 pub deprecated: bool,
40 pub decommissioning: String,
42}
43
44#[derive(Default)]
46pub struct ClientConfig {
47 pub base_url: Option<String>,
49 pub token: String,
51 pub api_version: Option<String>,
53 pub timeout_secs: Option<u64>,
55 pub user_agent: Option<String>,
57}
58
59pub struct Client {
61 base_url: String,
62 token: String,
63 api_version: String,
64 http_client: HttpClient,
65 user_agent: String,
66}
67
68impl Client {
69 pub fn new(config: ClientConfig) -> Result<Self, Error> {
71 let base_url = config
72 .base_url
73 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
74 .trim_end_matches('/')
75 .to_string();
76
77 let api_version = config
78 .api_version
79 .unwrap_or_else(|| DEFAULT_API_VERSION.to_string());
80
81 let user_agent = config
82 .user_agent
83 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
84
85 let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
86
87 let http_client = HttpClient::builder().timeout(timeout).build()?;
88
89 Ok(Self {
90 base_url,
91 token: config.token,
92 api_version,
93 http_client,
94 user_agent,
95 })
96 }
97
98 pub fn set_token(&mut self, token: impl Into<String>) {
100 self.token = token.into();
101 }
102
103 pub(crate) async fn send_get(
105 &self,
106 path: &str,
107 query: Option<&QueryOptions>,
108 ) -> Result<Response, Error> {
109 self.request(Method::GET, path, query, Option::<&()>::None)
110 .await
111 }
112
113 pub(crate) async fn send_post<B: Serialize>(
115 &self,
116 path: &str,
117 body: Option<&B>,
118 ) -> Result<Response, Error> {
119 self.request(Method::POST, path, None, body).await
120 }
121
122 pub(crate) async fn send_put<B: Serialize>(
124 &self,
125 path: &str,
126 body: Option<&B>,
127 ) -> Result<Response, Error> {
128 self.request(Method::PUT, path, None, body).await
129 }
130
131 pub(crate) async fn send_patch<B: Serialize>(
133 &self,
134 path: &str,
135 body: Option<&B>,
136 ) -> Result<Response, Error> {
137 self.request(Method::PATCH, path, None, body).await
138 }
139
140 pub(crate) async fn send_delete<B: Serialize>(
142 &self,
143 path: &str,
144 body: Option<&B>,
145 ) -> Result<Response, Error> {
146 self.request(Method::DELETE, path, None, body).await
147 }
148
149 async fn request<B: Serialize>(
151 &self,
152 method: Method,
153 path: &str,
154 query: Option<&QueryOptions>,
155 body: Option<&B>,
156 ) -> Result<Response, Error> {
157 let clean_path = path.trim_start_matches('/');
158 let url = format!("{}/{}", self.base_url, clean_path);
159
160 let mut request = self.http_client.request(method, &url);
161
162 let mut headers = HeaderMap::new();
164
165 let ua = HeaderValue::from_str(&self.user_agent).map_err(|_| {
166 Error::Api(crate::errors::ApiError {
167 status_code: StatusCode::INTERNAL_SERVER_ERROR,
168 message: format!("invalid User-Agent value: {:?}", self.user_agent),
169 documentation_url: String::new(),
170 request_id: String::new(),
171 })
172 })?;
173 headers.insert(USER_AGENT, ua);
174
175 let accept = HeaderValue::from_str(&format!(
176 "application/vnd.gitverse.object+json; version={}",
177 self.api_version
178 ))
179 .map_err(|_| {
180 Error::Api(crate::errors::ApiError {
181 status_code: StatusCode::INTERNAL_SERVER_ERROR,
182 message: format!("invalid API version value: {:?}", self.api_version),
183 documentation_url: String::new(),
184 request_id: String::new(),
185 })
186 })?;
187 headers.insert(ACCEPT, accept);
188
189 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
190
191 if !self.token.is_empty() {
192 let auth = HeaderValue::from_str(&format!("Bearer {}", self.token)).map_err(|_| {
193 Error::Api(crate::errors::ApiError {
194 status_code: StatusCode::INTERNAL_SERVER_ERROR,
195 message: "invalid token: contains non-ASCII characters".to_string(),
196 documentation_url: String::new(),
197 request_id: String::new(),
198 })
199 })?;
200 headers.insert(AUTHORIZATION, auth);
201 }
202
203 request = request.headers(headers);
204
205 if let Some(opts) = query {
207 request = request.query(opts);
208 }
209
210 if let Some(body) = body {
212 request = request.json(body);
213 }
214
215 let resp = request.send().await?;
216 Ok(resp)
217 }
218}
219
220pub(crate) async fn decode_response<T: DeserializeOwned>(resp: Response) -> Result<T, Error> {
222 let status = resp.status();
223
224 if status.is_client_error() || status.is_server_error() {
225 return Err(parse_api_error(resp).await);
226 }
227
228 if status == StatusCode::NO_CONTENT {
230 let text = resp.text().await.unwrap_or_default();
232 if text.is_empty() {
233 return serde_json::from_str("null").map_err(Error::from);
234 }
235 return serde_json::from_str(&text).map_err(Error::from);
236 }
237
238 resp.json::<T>().await.map_err(Error::from)
239}
240
241pub(crate) async fn handle_empty_response(resp: Response) -> Result<(), Error> {
243 let status = resp.status();
244
245 if status.is_client_error() || status.is_server_error() {
246 return Err(parse_api_error(resp).await);
247 }
248
249 Ok(())
250}