Skip to main content

floopfloop/
lib.rs

1//! Official Rust SDK for the [FloopFloop](https://www.floopfloop.com) API.
2//!
3//! # Quickstart
4//!
5//! ```no_run
6//! use floopfloop::{Client, CreateProjectInput};
7//!
8//! # async fn main_() -> Result<(), Box<dyn std::error::Error>> {
9//! let client = Client::new(std::env::var("FLOOP_API_KEY")?)?;
10//!
11//! let created = client.projects().create(CreateProjectInput {
12//!     prompt: "A landing page for a cat cafe".into(),
13//!     subdomain: Some("cat-cafe".into()),
14//!     bot_type: Some("site".into()),
15//!     ..Default::default()
16//! }).await?;
17//!
18//! let live = client.projects().wait_for_live(&created.project.id, None).await?;
19//! println!("Live at: {}", live.url.unwrap_or_default());
20//! # Ok(()) }
21//! ```
22//!
23//! # Resources
24//!
25//! * [`projects`](Projects) — create / list / get / status / refine / cancel / reactivate / conversations / stream / wait_for_live
26//! * [`subdomains`](Subdomains) — check / suggest
27//! * [`secrets`](Secrets) — list / set / remove
28//! * [`library`](Library) — list / clone
29//! * [`usage`](Usage) — summary
30//! * [`api_keys`](ApiKeys) — list / create / remove (remove accepts id OR name)
31//! * [`uploads`](Uploads) — create (S3 presign + direct PUT)
32//! * [`user`](UserApi) — me
33//!
34//! Every resource method returns [`Result<T, FloopError>`] on failure.
35//! `FloopError.code` is a [`FloopErrorCode`] enum with a catch-all `Other`
36//! variant so unknown server codes still round-trip losslessly.
37
38#![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
71/// Library semver, kept in sync with the latest `v*` git tag.
72pub 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/// The main entry point. Construct once with [`Client::new`] or
78/// [`Client::builder`] and reuse across tasks; all methods are `&self`.
79///
80/// Cheap to clone — internally wraps an `Arc` so clones share the same
81/// HTTP connection pool.
82#[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/// Configuration for [`Client::builder`]. Defaults:
95///
96/// * `base_url` = `https://www.floopfloop.com`
97/// * `timeout` = 30s
98/// * `user_agent` = `floopfloop-rust-sdk/<version>`
99#[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    /// Shortcut for `Client::builder(api_key).build()` — panics on bad
110    /// input (empty key, http client init failure). Prefer `builder`
111    /// for customised setups.
112    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    // ── Resource accessors ──────────────────────────────────────────
127
128    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    // ── Internal transport ──────────────────────────────────────────
158
159    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    /// Raw request helper used by upload's two-step flow (returns the
186    /// underlying `http` client so callers can PUT binary bodies
187    /// directly to S3).
188    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        // Unwrap the {data: ...} envelope when present so callers
253        // deserialize the inner shape directly.
254        let unwrapped = unwrap_data_envelope(&raw);
255        Ok(unwrapped.unwrap_or(raw))
256    }
257}
258
259impl ClientBuilder {
260    /// Override the base URL (e.g. for staging or a local dev server).
261    /// Trailing slashes are stripped.
262    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    /// Set the per-request timeout. Ignored if `http_client` is also
272    /// supplied (configure the timeout on your custom client yourself).
273    pub fn timeout(mut self, d: Duration) -> Self {
274        self.timeout = d;
275        self
276    }
277
278    /// Append a suffix to the User-Agent header (after
279    /// `floopfloop-rust-sdk/<version>`).
280    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    /// Supply a caller-built `reqwest::Client`. Overrides `.timeout()`
286    /// — configure the timeout on the custom client yourself.
287    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
358/// Unwrap `{"data": ...}` envelopes. Returns the inner JSON text as a
359/// string, or `None` if the body doesn't match the envelope shape (in
360/// which case the caller should use the raw body as-is).
361fn 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}