1#![deny(unsafe_code)]
39
40mod api_keys;
41mod error;
42mod library;
43mod projects;
44mod secrets;
45mod subdomains;
46mod subscriptions;
47mod uploads;
48mod usage;
49mod user;
50
51pub use api_keys::{ApiKeySummary, ApiKeys, CreateApiKeyInput, IssuedApiKey};
52pub use error::{FloopError, FloopErrorCode};
53pub use library::{
54 CloneLibraryProjectInput, ClonedProject, Library, LibraryListOptions, LibraryProject,
55};
56pub use projects::{
57 ConversationMessage, ConversationsOptions, ConversationsResult, CreateProjectInput,
58 CreatedProject, Deployment, ListProjectsOptions, Project, Projects, RefineAttachment,
59 RefineInput, RefineResult, StatusEvent, StreamHandler, StreamOptions, WaitForLiveOptions,
60};
61pub use secrets::{SecretSummary, Secrets};
62pub use subdomains::{SubdomainCheckResult, SubdomainSuggestResult, Subdomains};
63pub use subscriptions::{
64 CurrentSubscription, SubscriptionCredits, SubscriptionPlan, Subscriptions,
65};
66pub use uploads::{CreateUploadInput, UploadedAttachment, Uploads, MAX_UPLOAD_BYTES};
67pub use usage::{Usage, UsageSummary};
68pub use user::{User, UserApi};
69
70use reqwest::StatusCode;
71use serde::de::DeserializeOwned;
72use std::sync::Arc;
73use std::time::Duration;
74
75pub const VERSION: &str = "0.1.0-alpha.4";
77
78const DEFAULT_BASE_URL: &str = "https://www.floopfloop.com";
79const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
80
81#[derive(Clone)]
87pub struct Client {
88 inner: Arc<ClientInner>,
89}
90
91struct ClientInner {
92 api_key: String,
93 base_url: String,
94 http: reqwest::Client,
95 user_agent: String,
96}
97
98#[must_use]
104pub struct ClientBuilder {
105 api_key: String,
106 base_url: String,
107 timeout: Duration,
108 user_agent_suffix: Option<String>,
109 http: Option<reqwest::Client>,
110}
111
112impl Client {
113 pub fn new(api_key: impl Into<String>) -> Result<Self, FloopError> {
117 Self::builder(api_key).build()
118 }
119
120 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
121 ClientBuilder {
122 api_key: api_key.into(),
123 base_url: DEFAULT_BASE_URL.to_owned(),
124 timeout: DEFAULT_TIMEOUT,
125 user_agent_suffix: None,
126 http: None,
127 }
128 }
129
130 pub fn projects(&self) -> Projects<'_> {
133 Projects { client: self }
134 }
135 pub fn subdomains(&self) -> Subdomains<'_> {
136 Subdomains { client: self }
137 }
138 pub fn secrets(&self) -> Secrets<'_> {
139 Secrets { client: self }
140 }
141 pub fn library(&self) -> Library<'_> {
142 Library { client: self }
143 }
144 pub fn usage(&self) -> Usage<'_> {
145 Usage { client: self }
146 }
147 pub fn subscriptions(&self) -> Subscriptions<'_> {
148 Subscriptions { client: self }
149 }
150 pub fn api_keys(&self) -> ApiKeys<'_> {
151 ApiKeys { client: self }
152 }
153 pub fn uploads(&self) -> Uploads<'_> {
154 Uploads { client: self }
155 }
156 pub fn user(&self) -> UserApi<'_> {
157 UserApi { client: self }
158 }
159
160 pub fn base_url(&self) -> &str {
161 &self.inner.base_url
162 }
163
164 pub(crate) async fn request_json<O: DeserializeOwned>(
167 &self,
168 method: reqwest::Method,
169 path: &str,
170 body: Option<&serde_json::Value>,
171 ) -> Result<O, FloopError> {
172 let text = self.request_text(method, path, body).await?;
173 serde_json::from_str(&text).map_err(|e| {
174 FloopError::new(
175 FloopErrorCode::Unknown,
176 0,
177 format!("failed to decode response: {e}"),
178 )
179 })
180 }
181
182 pub(crate) async fn request_empty(
183 &self,
184 method: reqwest::Method,
185 path: &str,
186 body: Option<&serde_json::Value>,
187 ) -> Result<(), FloopError> {
188 let _ = self.request_text(method, path, body).await?;
189 Ok(())
190 }
191
192 pub(crate) fn http(&self) -> &reqwest::Client {
196 &self.inner.http
197 }
198
199 async fn request_text(
200 &self,
201 method: reqwest::Method,
202 path: &str,
203 body: Option<&serde_json::Value>,
204 ) -> Result<String, FloopError> {
205 let url = format!("{}{}", self.inner.base_url, path);
206 let mut req = self
207 .inner
208 .http
209 .request(method, &url)
210 .bearer_auth(&self.inner.api_key)
211 .header(reqwest::header::USER_AGENT, &self.inner.user_agent)
212 .header(reqwest::header::ACCEPT, "application/json");
213 if let Some(b) = body {
214 req = req.json(b);
215 }
216
217 let resp = req.send().await.map_err(|err| {
218 let code = if err.is_timeout() {
219 FloopErrorCode::Timeout
220 } else {
221 FloopErrorCode::NetworkError
222 };
223 let msg = if err.is_timeout() {
224 "request timed out".to_owned()
225 } else {
226 format!("could not reach {} ({})", self.inner.base_url, err)
227 };
228 FloopError::new(code, 0, msg)
229 })?;
230
231 let status = resp.status();
232 let request_id = resp
233 .headers()
234 .get("x-request-id")
235 .and_then(|v| v.to_str().ok())
236 .map(str::to_owned);
237 let retry_after = resp
238 .headers()
239 .get(reqwest::header::RETRY_AFTER)
240 .and_then(|v| v.to_str().ok())
241 .and_then(|s| error::parse_retry_after(Some(s)));
242
243 let raw = resp.text().await.map_err(|err| {
244 FloopError::new(
245 FloopErrorCode::NetworkError,
246 status.as_u16(),
247 format!("failed to read response: {err}"),
248 )
249 })?;
250
251 if !status.is_success() {
252 let (code, message) = parse_error_envelope(&raw, status);
253 let mut fe = FloopError::new(code, status.as_u16(), message);
254 fe.request_id = request_id;
255 fe.retry_after = retry_after;
256 return Err(fe);
257 }
258
259 let unwrapped = unwrap_data_envelope(&raw);
262 Ok(unwrapped.unwrap_or(raw))
263 }
264}
265
266impl ClientBuilder {
267 pub fn base_url(mut self, url: impl Into<String>) -> Self {
270 let mut s = url.into();
271 while s.ends_with('/') {
272 s.pop();
273 }
274 self.base_url = s;
275 self
276 }
277
278 pub fn timeout(mut self, d: Duration) -> Self {
281 self.timeout = d;
282 self
283 }
284
285 pub fn user_agent_suffix(mut self, suffix: impl Into<String>) -> Self {
288 self.user_agent_suffix = Some(suffix.into());
289 self
290 }
291
292 pub fn http_client(mut self, http: reqwest::Client) -> Self {
295 self.http = Some(http);
296 self
297 }
298
299 pub fn build(self) -> Result<Client, FloopError> {
300 if self.api_key.is_empty() {
301 return Err(FloopError::new(
302 FloopErrorCode::ValidationError,
303 0,
304 "api_key is required",
305 ));
306 }
307 let http = match self.http {
308 Some(h) => h,
309 None => reqwest::Client::builder()
310 .timeout(self.timeout)
311 .build()
312 .map_err(|e| {
313 FloopError::new(FloopErrorCode::Unknown, 0, format!("reqwest init: {e}"))
314 })?,
315 };
316 let user_agent = match self.user_agent_suffix {
317 Some(s) => format!("floopfloop-rust-sdk/{VERSION} {s}"),
318 None => format!("floopfloop-rust-sdk/{VERSION}"),
319 };
320 Ok(Client {
321 inner: Arc::new(ClientInner {
322 api_key: self.api_key,
323 base_url: self.base_url,
324 http,
325 user_agent,
326 }),
327 })
328 }
329}
330
331fn default_code_for_status(status: StatusCode) -> FloopErrorCode {
332 match status.as_u16() {
333 401 => FloopErrorCode::Unauthorized,
334 403 => FloopErrorCode::Forbidden,
335 404 => FloopErrorCode::NotFound,
336 409 => FloopErrorCode::Conflict,
337 422 => FloopErrorCode::ValidationError,
338 429 => FloopErrorCode::RateLimited,
339 503 => FloopErrorCode::ServiceUnavailable,
340 s if s >= 500 => FloopErrorCode::ServerError,
341 _ => FloopErrorCode::Unknown,
342 }
343}
344
345fn parse_error_envelope(raw: &str, status: StatusCode) -> (FloopErrorCode, String) {
346 if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
347 if let Some(err) = v.get("error").and_then(|e| e.as_object()) {
348 let code = err.get("code").and_then(|c| c.as_str()).map_or_else(
349 || default_code_for_status(status),
350 FloopErrorCode::from_wire,
351 );
352 let msg = err.get("message").and_then(|m| m.as_str()).map_or_else(
353 || format!("request failed ({})", status.as_u16()),
354 ToOwned::to_owned,
355 );
356 return (code, msg);
357 }
358 }
359 (
360 default_code_for_status(status),
361 format!("request failed ({})", status.as_u16()),
362 )
363}
364
365fn unwrap_data_envelope(raw: &str) -> Option<String> {
369 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
370 let inner = v.as_object()?.get("data")?;
371 Some(inner.to_string())
372}