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 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
75/// Library semver, kept in sync with the latest `v*` git tag.
76pub 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/// The main entry point. Construct once with [`Client::new`] or
82/// [`Client::builder`] and reuse across tasks; all methods are `&self`.
83///
84/// Cheap to clone — internally wraps an `Arc` so clones share the same
85/// HTTP connection pool.
86#[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/// Configuration for [`Client::builder`]. Defaults:
99///
100/// * `base_url` = `https://www.floopfloop.com`
101/// * `timeout` = 30s
102/// * `user_agent` = `floopfloop-rust-sdk/<version>`
103#[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    /// Shortcut for `Client::builder(api_key).build()` — panics on bad
114    /// input (empty key, http client init failure). Prefer `builder`
115    /// for customised setups.
116    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    // ── Resource accessors ──────────────────────────────────────────
131
132    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    // ── Internal transport ──────────────────────────────────────────
165
166    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    /// Raw request helper used by upload's two-step flow (returns the
193    /// underlying `http` client so callers can PUT binary bodies
194    /// directly to S3).
195    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        // Unwrap the {data: ...} envelope when present so callers
260        // deserialize the inner shape directly.
261        let unwrapped = unwrap_data_envelope(&raw);
262        Ok(unwrapped.unwrap_or(raw))
263    }
264}
265
266impl ClientBuilder {
267    /// Override the base URL (e.g. for staging or a local dev server).
268    /// Trailing slashes are stripped.
269    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    /// Set the per-request timeout. Ignored if `http_client` is also
279    /// supplied (configure the timeout on your custom client yourself).
280    pub fn timeout(mut self, d: Duration) -> Self {
281        self.timeout = d;
282        self
283    }
284
285    /// Append a suffix to the User-Agent header (after
286    /// `floopfloop-rust-sdk/<version>`).
287    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    /// Supply a caller-built `reqwest::Client`. Overrides `.timeout()`
293    /// — configure the timeout on the custom client yourself.
294    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
365/// Unwrap `{"data": ...}` envelopes. Returns the inner JSON text as a
366/// string, or `None` if the body doesn't match the envelope shape (in
367/// which case the caller should use the raw body as-is).
368fn 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}