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