Skip to main content

cufflink_fn/
lib.rs

1//! # cufflink-fn
2//!
3//! Write custom Cufflink handlers in Rust that compile to WASM.
4//!
5//! This crate wraps the raw WASM host ABI so you write normal Rust code
6//! instead of pointer manipulation. Use it with `cufflink` services running
7//! in WASM mode.
8//!
9//! ## Quick Start
10//!
11//! ```rust,ignore
12//! use cufflink_fn::prelude::*;
13//!
14//! cufflink_fn::init!();
15//!
16//! handler!(hello, |req: Request| {
17//!     let name = req.body()["name"].as_str().unwrap_or("world");
18//!     Response::json(&json!({"message": format!("Hello, {}!", name)}))
19//! });
20//! ```
21//!
22//! ## Architecture
23//!
24//! Organize your code in layers:
25//!
26//! - **Handlers** (thin) — parse request, call operation, return response
27//! - **Operations** (fat) — validation, business rules, orchestration
28//! - **Repos** (data) — pure SQL via [`db::query`] / [`db::execute`]
29
30use serde_json::Value;
31use std::collections::HashMap;
32
33/// Read the host response buffer after a batch host call and parse it as a
34/// JSON array. Returns `None` if the buffer was empty or non-JSON.
35#[cfg(target_arch = "wasm32")]
36pub(crate) fn read_batch_response() -> Option<Vec<Value>> {
37    let len = unsafe { get_host_response_len() };
38    if len <= 0 {
39        return None;
40    }
41    let mut buf = vec![0u8; len as usize];
42    let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
43    if read <= 0 {
44        return None;
45    }
46    buf.truncate(read as usize);
47    let s = String::from_utf8(buf).ok()?;
48    let v: Value = serde_json::from_str(&s).ok()?;
49    v.as_array().cloned()
50}
51
52// ─── Raw FFI ─────────────────────────────────────────────────────────────────
53// These are the host functions provided by the Cufflink platform WASM runtime.
54// Users never call these directly — use the `db`, `nats`, and `log` modules.
55
56#[cfg(target_arch = "wasm32")]
57extern "C" {
58    #[link_name = "cufflink_log"]
59    fn cufflink_log(level: i32, msg_ptr: i32, msg_len: i32);
60    fn db_query(sql_ptr: i32, sql_len: i32) -> i32;
61    fn db_execute(sql_ptr: i32, sql_len: i32) -> i32;
62    fn get_host_response_len() -> i32;
63    fn get_host_response(buf_ptr: i32, buf_len: i32) -> i32;
64    fn nats_publish(subj_ptr: i32, subj_len: i32, payload_ptr: i32, payload_len: i32) -> i32;
65    fn nats_request(
66        subj_ptr: i32,
67        subj_len: i32,
68        payload_ptr: i32,
69        payload_len: i32,
70        timeout_ms: i32,
71    ) -> i32;
72    fn http_fetch(
73        method_ptr: i32,
74        method_len: i32,
75        url_ptr: i32,
76        url_len: i32,
77        headers_ptr: i32,
78        headers_len: i32,
79        body_ptr: i32,
80        body_len: i32,
81    ) -> i32;
82    fn get_config(key_ptr: i32, key_len: i32) -> i32;
83    fn s3_download(bucket_ptr: i32, bucket_len: i32, key_ptr: i32, key_len: i32) -> i32;
84    fn s3_download_many(items_ptr: i32, items_len: i32) -> i32;
85    fn http_fetch_many(items_ptr: i32, items_len: i32) -> i32;
86    fn image_transform_jpeg(
87        bucket_ptr: i32,
88        bucket_len: i32,
89        in_key_ptr: i32,
90        in_key_len: i32,
91        out_key_ptr: i32,
92        out_key_len: i32,
93        max_dim: i32,
94        quality: i32,
95    ) -> i32;
96    fn s3_presign_upload(
97        bucket_ptr: i32,
98        bucket_len: i32,
99        key_ptr: i32,
100        key_len: i32,
101        content_type_ptr: i32,
102        content_type_len: i32,
103        expires_secs: i32,
104    ) -> i32;
105    fn redis_get(key_ptr: i32, key_len: i32) -> i32;
106    fn redis_set(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32, ttl_secs: i32) -> i32;
107    fn redis_del(key_ptr: i32, key_len: i32) -> i32;
108    fn generate_uuid() -> i32;
109    fn current_time() -> i32;
110}
111
112// ─── Auth ────────────────────────────────────────────────────────────────────
113
114/// Authenticated user context, validated by the Cufflink platform.
115///
116/// The platform validates the JWT token (via Keycloak) and extracts claims
117/// before passing them to your handler. You never need to validate tokens
118/// yourself — the `auth` field is only present when the token is valid.
119///
120/// ```rust,ignore
121/// handler!(protected, |req: Request| {
122///     let auth = match req.require_auth() {
123///         Ok(auth) => auth,
124///         Err(resp) => return resp,
125///     };
126///     if !auth.has_role("admin") {
127///         return Response::error("Forbidden");
128///     }
129///     Response::json(&json!({"user": auth.sub}))
130/// });
131/// ```
132#[derive(Debug, Clone)]
133pub struct Auth {
134    /// Keycloak subject ID (unique user identifier).
135    pub sub: String,
136    /// Preferred username from Keycloak.
137    pub preferred_username: Option<String>,
138    /// Display name.
139    pub name: Option<String>,
140    /// Email address.
141    pub email: Option<String>,
142    /// Realm roles assigned to the user in Keycloak.
143    pub realm_roles: Vec<String>,
144    /// All other JWT claims (custom Keycloak mappers, resource_access, etc.).
145    pub claims: HashMap<String, Value>,
146    /// Cufflink permissions resolved from the service's tenant roles (e.g., `["staff:create", "items:*"]`).
147    pub permissions: Vec<String>,
148    /// Cufflink role names assigned to the user (e.g., `["admin", "manager"]`).
149    pub role_names: Vec<String>,
150    /// Whether this is a Keycloak service account (client credentials grant).
151    /// Service accounts bypass permission checks at the platform level.
152    pub is_service_account: bool,
153}
154
155impl Auth {
156    /// Check if the user has a specific Keycloak realm role.
157    pub fn has_role(&self, role: &str) -> bool {
158        self.realm_roles.iter().any(|r| r == role)
159    }
160
161    /// Check if the user has a specific Cufflink permission.
162    ///
163    /// Supports wildcards: `"staff:*"` matches any operation in the "staff" area,
164    /// and `"*"` matches everything.
165    ///
166    /// ```rust,ignore
167    /// if !auth.can("staff", "create") {
168    ///     return Response::error("Forbidden: missing staff:create permission");
169    /// }
170    /// ```
171    pub fn can(&self, area: &str, operation: &str) -> bool {
172        let required = format!("{}:{}", area, operation);
173        let wildcard = format!("{}:*", area);
174        self.permissions
175            .iter()
176            .any(|p| p == &required || p == &wildcard || p == "*")
177    }
178
179    /// Check if the user has a specific Cufflink role (by name).
180    pub fn has_cufflink_role(&self, role: &str) -> bool {
181        self.role_names.iter().any(|r| r == role)
182    }
183
184    /// Get a specific claim value by key.
185    pub fn claim(&self, key: &str) -> Option<&Value> {
186        self.claims.get(key)
187    }
188}
189
190// ─── JobContext ──────────────────────────────────────────────────────────────
191
192/// Context the platform attaches to a request when it was delivered via the
193/// long-running jobs runtime. Absent on direct HTTP invocations.
194///
195/// Use this when a handler needs to distinguish a fresh invocation from a
196/// worker redelivery — e.g. to skip an idempotency-sensitive side effect that
197/// the prior attempt already paid for.
198#[derive(Debug, Clone)]
199pub struct JobContext {
200    pub id: String,
201    pub attempt: u32,
202    pub max_attempts: u32,
203}
204
205impl JobContext {
206    /// `true` once the worker has already delivered this job at least once.
207    pub fn is_retry(&self) -> bool {
208        self.attempt > 1
209    }
210}
211
212// ─── Request ─────────────────────────────────────────────────────────────────
213
214/// An incoming HTTP request from the Cufflink platform.
215///
216/// The platform serializes the full request context (method, headers, body,
217/// tenant, service name, auth) into JSON and passes it to your handler.
218#[derive(Debug, Clone)]
219pub struct Request {
220    method: String,
221    handler: String,
222    headers: HashMap<String, String>,
223    body: Value,
224    raw_body: Vec<u8>,
225    tenant: String,
226    service: String,
227    auth: Option<Auth>,
228    job: Option<JobContext>,
229}
230
231impl Request {
232    /// Parse a `Request` from the JSON the platform provides.
233    pub fn from_json(json: &str) -> Option<Self> {
234        let v: Value = serde_json::from_str(json).ok()?;
235        let headers = v["headers"]
236            .as_object()
237            .map(|m| {
238                m.iter()
239                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
240                    .collect()
241            })
242            .unwrap_or_default();
243
244        let auth = v["auth"].as_object().map(|auth_obj| {
245            let a = Value::Object(auth_obj.clone());
246            Auth {
247                sub: a["sub"].as_str().unwrap_or("").to_string(),
248                preferred_username: a["preferred_username"].as_str().map(|s| s.to_string()),
249                name: a["name"].as_str().map(|s| s.to_string()),
250                email: a["email"].as_str().map(|s| s.to_string()),
251                realm_roles: a["realm_roles"]
252                    .as_array()
253                    .map(|arr| {
254                        arr.iter()
255                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
256                            .collect()
257                    })
258                    .unwrap_or_default(),
259                claims: a["claims"]
260                    .as_object()
261                    .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
262                    .unwrap_or_default(),
263                permissions: a["permissions"]
264                    .as_array()
265                    .map(|arr| {
266                        arr.iter()
267                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
268                            .collect()
269                    })
270                    .unwrap_or_default(),
271                role_names: a["role_names"]
272                    .as_array()
273                    .map(|arr| {
274                        arr.iter()
275                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
276                            .collect()
277                    })
278                    .unwrap_or_default(),
279                is_service_account: a["is_service_account"].as_bool().unwrap_or(false),
280            }
281        });
282
283        let raw_body = v["body_raw_b64"]
284            .as_str()
285            .filter(|s| !s.is_empty())
286            .and_then(|s| {
287                use base64::{engine::general_purpose, Engine};
288                general_purpose::STANDARD.decode(s).ok()
289            })
290            .unwrap_or_default();
291
292        let job = v["_job"].as_object().and_then(|j| {
293            let id = j.get("id")?.as_str()?.to_string();
294            let attempt = u32::try_from(j.get("attempt")?.as_u64()?).ok()?;
295            let max_attempts = u32::try_from(j.get("max_attempts")?.as_u64()?).ok()?;
296            Some(JobContext {
297                id,
298                attempt,
299                max_attempts,
300            })
301        });
302
303        Some(Self {
304            method: v["method"].as_str().unwrap_or("GET").to_string(),
305            handler: v["handler"].as_str().unwrap_or("").to_string(),
306            headers,
307            body: v["body"].clone(),
308            raw_body,
309            tenant: v["tenant"].as_str().unwrap_or("").to_string(),
310            service: v["service"].as_str().unwrap_or("").to_string(),
311            auth,
312            job,
313        })
314    }
315
316    /// The HTTP method (GET, POST, PUT, DELETE).
317    pub fn method(&self) -> &str {
318        &self.method
319    }
320
321    /// The handler name from the URL path.
322    pub fn handler(&self) -> &str {
323        &self.handler
324    }
325
326    /// All HTTP headers as a map.
327    pub fn headers(&self) -> &HashMap<String, String> {
328        &self.headers
329    }
330
331    /// Get a specific header value.
332    pub fn header(&self, name: &str) -> Option<&str> {
333        self.headers.get(name).map(|s| s.as_str())
334    }
335
336    /// The parsed JSON body. Returns `Value::Null` if no body was sent.
337    pub fn body(&self) -> &Value {
338        &self.body
339    }
340
341    /// The raw, unparsed request body bytes as the caller sent them.
342    ///
343    /// Use this when verifying webhook signatures that HMAC over the
344    /// original payload (Stripe, OpenPhone/Quo, etc.) — the parsed
345    /// `body()` is canonicalized by `serde_json` and will not byte-match
346    /// what the sender signed. Returns an empty slice when no body was
347    /// sent.
348    pub fn raw_body(&self) -> &[u8] {
349        &self.raw_body
350    }
351
352    /// The tenant slug from the URL.
353    pub fn tenant(&self) -> &str {
354        &self.tenant
355    }
356
357    /// The service name from the URL.
358    pub fn service(&self) -> &str {
359        &self.service
360    }
361
362    /// Get the authenticated user context, if present.
363    ///
364    /// Returns `None` if no valid JWT or API key was provided with the request.
365    pub fn auth(&self) -> Option<&Auth> {
366        self.auth.as_ref()
367    }
368
369    /// Get the job context attached by the long-running jobs runtime.
370    ///
371    /// Returns `None` when the handler is invoked directly via HTTP. Present
372    /// for handlers annotated with `job(...)` and dispatched by a worker.
373    pub fn job(&self) -> Option<&JobContext> {
374        self.job.as_ref()
375    }
376
377    /// Require authentication. Returns the auth context or an error response.
378    ///
379    /// ```rust,ignore
380    /// handler!(protected, |req: Request| {
381    ///     let auth = match req.require_auth() {
382    ///         Ok(auth) => auth,
383    ///         Err(resp) => return resp,
384    ///     };
385    ///     Response::json(&json!({"user": auth.sub}))
386    /// });
387    /// ```
388    pub fn require_auth(&self) -> Result<&Auth, Response> {
389        self.auth.as_ref().ok_or_else(|| {
390            Response::json(&serde_json::json!({
391                "error": "Authentication required",
392                "status": 401
393            }))
394        })
395    }
396}
397
398// ─── Response ────────────────────────────────────────────────────────────────
399
400/// An HTTP response to return from your handler.
401#[derive(Debug, Clone)]
402pub struct Response {
403    data: String,
404    status: u16,
405}
406
407impl Response {
408    /// Return a JSON response with HTTP 200.
409    pub fn json(value: &Value) -> Self {
410        Self {
411            data: serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
412            status: 200,
413        }
414    }
415
416    /// Return a plain text response (wrapped in a JSON string).
417    pub fn text(s: &str) -> Self {
418        Self::json(&Value::String(s.to_string()))
419    }
420
421    /// Return an error response with HTTP 400.
422    pub fn error(message: &str) -> Self {
423        Self {
424            data: serde_json::json!({"error": message}).to_string(),
425            status: 400,
426        }
427    }
428
429    /// Return a 404 Not Found error.
430    pub fn not_found(message: &str) -> Self {
431        Self {
432            data: serde_json::json!({"error": message}).to_string(),
433            status: 404,
434        }
435    }
436
437    /// Return a 403 Forbidden error.
438    pub fn forbidden(message: &str) -> Self {
439        Self {
440            data: serde_json::json!({"error": message}).to_string(),
441            status: 403,
442        }
443    }
444
445    /// Return an empty success response.
446    pub fn empty() -> Self {
447        Self::json(&serde_json::json!({"ok": true}))
448    }
449
450    /// Set a custom HTTP status code on the response.
451    pub fn with_status(mut self, status: u16) -> Self {
452        self.status = status;
453        self
454    }
455
456    /// Get the raw response string.
457    /// Encodes the status code into the response so the platform can extract it.
458    pub fn into_data(self) -> String {
459        if self.status == 200 {
460            // No wrapping needed for 200 — backwards compatible
461            self.data
462        } else {
463            // Wrap with __status so the platform can set the HTTP status code
464            serde_json::json!({
465                "__status": self.status,
466                "__body": serde_json::from_str::<Value>(&self.data).unwrap_or(Value::String(self.data)),
467            })
468            .to_string()
469        }
470    }
471}
472
473// ─── db module ───────────────────────────────────────────────────────────────
474
475/// Database access — run SQL queries against your service's tables.
476///
477/// All queries run in the tenant's schema automatically. You don't need
478/// to qualify table names with a schema prefix.
479pub mod db {
480    use super::*;
481
482    /// Run a SELECT query and return all rows as a `Vec<Value>`.
483    ///
484    /// Each row is a JSON object with column names as keys.
485    ///
486    /// ```rust,ignore
487    /// let users = db::query("SELECT id, name, email FROM users WHERE active = true");
488    /// for user in &users {
489    ///     log::info(&format!("User: {}", user["name"]));
490    /// }
491    /// ```
492    pub fn query(sql: &str) -> Vec<Value> {
493        #[cfg(target_arch = "wasm32")]
494        {
495            let bytes = sql.as_bytes();
496            let result = unsafe { db_query(bytes.as_ptr() as i32, bytes.len() as i32) };
497            if result < 0 {
498                return vec![];
499            }
500            read_host_response()
501        }
502        #[cfg(not(target_arch = "wasm32"))]
503        {
504            let _ = sql;
505            vec![]
506        }
507    }
508
509    /// Run a SELECT query and return the first row, or `None` if empty.
510    ///
511    /// ```rust,ignore
512    /// if let Some(user) = db::query_one("SELECT * FROM users WHERE id = 'abc'") {
513    ///     log::info(&format!("Found user: {}", user["name"]));
514    /// }
515    /// ```
516    pub fn query_one(sql: &str) -> Option<Value> {
517        query(sql).into_iter().next()
518    }
519
520    /// Run an INSERT, UPDATE, or DELETE statement.
521    ///
522    /// Returns the number of affected rows, or -1 on error.
523    ///
524    /// ```rust,ignore
525    /// let affected = db::execute("UPDATE orders SET status = 'shipped' WHERE id = 'abc'");
526    /// log::info(&format!("Updated {} rows", affected));
527    /// ```
528    pub fn execute(sql: &str) -> i32 {
529        #[cfg(target_arch = "wasm32")]
530        {
531            let bytes = sql.as_bytes();
532            unsafe { db_execute(bytes.as_ptr() as i32, bytes.len() as i32) }
533        }
534        #[cfg(not(target_arch = "wasm32"))]
535        {
536            let _ = sql;
537            0
538        }
539    }
540
541    /// Read the host response buffer (used internally after db_query).
542    #[cfg(target_arch = "wasm32")]
543    fn read_host_response() -> Vec<Value> {
544        let len = unsafe { get_host_response_len() };
545        if len <= 0 {
546            return vec![];
547        }
548        let mut buf = vec![0u8; len as usize];
549        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
550        if read <= 0 {
551            return vec![];
552        }
553        buf.truncate(read as usize);
554        let json_str = String::from_utf8_lossy(&buf);
555        serde_json::from_str(&json_str).unwrap_or_default()
556    }
557}
558
559// ─── nats module ─────────────────────────────────────────────────────────────
560
561/// Publish messages to NATS for event-driven communication.
562///
563/// Use this to notify other services, trigger subscriptions, or emit
564/// domain events.
565pub mod nats {
566    #[allow(unused_imports)]
567    use super::*;
568
569    /// Publish a message to a NATS subject.
570    ///
571    /// Returns `true` on success, `false` on failure.
572    ///
573    /// ```rust,ignore
574    /// nats::publish(
575    ///     "dw.acme.order-service.orders.created",
576    ///     &serde_json::json!({"order_id": "abc", "total": 4500}).to_string(),
577    /// );
578    /// ```
579    pub fn publish(subject: &str, payload: &str) -> bool {
580        #[cfg(target_arch = "wasm32")]
581        {
582            let subj_bytes = subject.as_bytes();
583            let payload_bytes = payload.as_bytes();
584            let result = unsafe {
585                nats_publish(
586                    subj_bytes.as_ptr() as i32,
587                    subj_bytes.len() as i32,
588                    payload_bytes.as_ptr() as i32,
589                    payload_bytes.len() as i32,
590                )
591            };
592            result == 0
593        }
594        #[cfg(not(target_arch = "wasm32"))]
595        {
596            let _ = (subject, payload);
597            true
598        }
599    }
600
601    /// Send a NATS request and wait for a reply (synchronous request-reply).
602    ///
603    /// Returns the reply payload as a string, or `None` on timeout/failure.
604    ///
605    /// ```rust,ignore
606    /// let reply = nats::request(
607    ///     "dw.acme.user-service.users.lookup",
608    ///     &serde_json::json!({"customer_id": "abc"}).to_string(),
609    ///     5000, // timeout in ms
610    /// );
611    /// ```
612    pub fn request(subject: &str, payload: &str, timeout_ms: i32) -> Option<String> {
613        #[cfg(target_arch = "wasm32")]
614        {
615            let subj_bytes = subject.as_bytes();
616            let payload_bytes = payload.as_bytes();
617            let result = unsafe {
618                nats_request(
619                    subj_bytes.as_ptr() as i32,
620                    subj_bytes.len() as i32,
621                    payload_bytes.as_ptr() as i32,
622                    payload_bytes.len() as i32,
623                    timeout_ms,
624                )
625            };
626            if result != 0 {
627                return None;
628            }
629            let len = unsafe { get_host_response_len() };
630            if len <= 0 {
631                return None;
632            }
633            let mut buf = vec![0u8; len as usize];
634            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
635            if read <= 0 {
636                return None;
637            }
638            String::from_utf8(buf[..read as usize].to_vec()).ok()
639        }
640        #[cfg(not(target_arch = "wasm32"))]
641        {
642            let _ = (subject, payload, timeout_ms);
643            None
644        }
645    }
646}
647
648// ─── log module ──────────────────────────────────────────────────────────────
649
650/// Structured logging from inside your WASM handler.
651///
652/// Messages appear in the platform's log output prefixed with `[wasm]`.
653pub mod log {
654    #[allow(unused_imports)]
655    use super::*;
656
657    /// Log an error message (level 0).
658    pub fn error(msg: &str) {
659        write(0, msg);
660    }
661
662    /// Log a warning message (level 1).
663    pub fn warn(msg: &str) {
664        write(1, msg);
665    }
666
667    /// Log an info message (level 2).
668    pub fn info(msg: &str) {
669        write(2, msg);
670    }
671
672    /// Log a debug message (level 3).
673    pub fn debug(msg: &str) {
674        write(3, msg);
675    }
676
677    fn write(level: i32, msg: &str) {
678        #[cfg(target_arch = "wasm32")]
679        {
680            let bytes = msg.as_bytes();
681            unsafe {
682                super::cufflink_log(level, bytes.as_ptr() as i32, bytes.len() as i32);
683            }
684        }
685        #[cfg(not(target_arch = "wasm32"))]
686        {
687            let _ = (level, msg);
688        }
689    }
690}
691
692// ─── http module ────────────────────────────────────────────────────────────
693
694/// Make HTTP requests from inside your WASM handler.
695///
696/// Use this to call external APIs (Keycloak admin, third-party services, etc.)
697/// from your handler code.
698pub mod http {
699    #[allow(unused_imports)]
700    use super::*;
701
702    /// Response from an HTTP request.
703    #[derive(Debug, Clone)]
704    pub struct FetchResponse {
705        /// HTTP status code (e.g., 200, 404, 500).
706        pub status: i32,
707        /// Response body as a string (may be base64-encoded for binary content).
708        pub body: String,
709        /// Body encoding: "utf8" for text, "base64" for binary content.
710        pub body_encoding: String,
711        /// Response headers.
712        pub headers: HashMap<String, String>,
713    }
714
715    impl FetchResponse {
716        /// Parse the response body as JSON.
717        pub fn json(&self) -> Option<Value> {
718            serde_json::from_str(&self.body).ok()
719        }
720
721        /// Check if the response status indicates success (2xx).
722        pub fn is_success(&self) -> bool {
723            (200..300).contains(&self.status)
724        }
725
726        /// Check if the body is base64-encoded (binary content).
727        pub fn is_base64(&self) -> bool {
728            self.body_encoding == "base64"
729        }
730    }
731
732    /// Make an HTTP request.
733    ///
734    /// ```rust,ignore
735    /// let resp = http::fetch("GET", "https://api.example.com/data", &[], None);
736    /// if let Some(resp) = resp {
737    ///     if resp.is_success() {
738    ///         log::info(&format!("Got: {}", resp.body));
739    ///     }
740    /// }
741    /// ```
742    pub fn fetch(
743        method: &str,
744        url: &str,
745        headers: &[(&str, &str)],
746        body: Option<&str>,
747    ) -> Option<FetchResponse> {
748        #[cfg(target_arch = "wasm32")]
749        {
750            let method_bytes = method.as_bytes();
751            let url_bytes = url.as_bytes();
752            let headers_map: HashMap<&str, &str> = headers.iter().copied().collect();
753            let headers_json = serde_json::to_string(&headers_map).unwrap_or_default();
754            let headers_bytes = headers_json.as_bytes();
755            let body_bytes = body.unwrap_or("").as_bytes();
756            let body_len = body.map(|b| b.len()).unwrap_or(0);
757
758            let result = unsafe {
759                http_fetch(
760                    method_bytes.as_ptr() as i32,
761                    method_bytes.len() as i32,
762                    url_bytes.as_ptr() as i32,
763                    url_bytes.len() as i32,
764                    headers_bytes.as_ptr() as i32,
765                    headers_bytes.len() as i32,
766                    body_bytes.as_ptr() as i32,
767                    body_len as i32,
768                )
769            };
770
771            if result < 0 {
772                return None;
773            }
774
775            read_fetch_response()
776        }
777        #[cfg(not(target_arch = "wasm32"))]
778        {
779            let _ = (method, url, headers, body);
780            None
781        }
782    }
783
784    /// Make a GET request.
785    pub fn get(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
786        fetch("GET", url, headers, None)
787    }
788
789    /// Make a POST request with a body.
790    pub fn post(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
791        fetch("POST", url, headers, Some(body))
792    }
793
794    /// Make a PUT request with a body.
795    pub fn put(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
796        fetch("PUT", url, headers, Some(body))
797    }
798
799    /// Make a DELETE request.
800    pub fn delete(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
801        fetch("DELETE", url, headers, None)
802    }
803
804    /// Make a PATCH request with a body.
805    pub fn patch(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
806        fetch("PATCH", url, headers, Some(body))
807    }
808
809    /// A single request inside a [`fetch_many`] batch.
810    #[derive(Debug, Clone)]
811    pub struct FetchRequest<'a> {
812        pub method: &'a str,
813        pub url: &'a str,
814        pub headers: &'a [(&'a str, &'a str)],
815        pub body: Option<&'a str>,
816    }
817
818    /// Make N HTTP requests concurrently from the host. Per-item errors are
819    /// reported in the returned slot (other slots still succeed). Use this
820    /// when you'd otherwise call [`fetch`] in a loop — sequential blocking
821    /// fetches inside WASM are linear, the host fan-out is parallel.
822    ///
823    /// The host bounds concurrency (default 32) so an unbounded list won't
824    /// exhaust connections.
825    ///
826    /// ```rust,ignore
827    /// let results = http::fetch_many(&[
828    ///     http::FetchRequest { method: "GET", url: "https://a", headers: &[], body: None },
829    ///     http::FetchRequest { method: "GET", url: "https://b", headers: &[], body: None },
830    /// ]);
831    /// ```
832    pub fn fetch_many(requests: &[FetchRequest<'_>]) -> Vec<Result<FetchResponse, String>> {
833        #[cfg(target_arch = "wasm32")]
834        {
835            let items: Vec<Value> = requests
836                .iter()
837                .map(|r| {
838                    let h: HashMap<&str, &str> = r.headers.iter().copied().collect();
839                    serde_json::json!({
840                        "method": r.method,
841                        "url": r.url,
842                        "headers": h,
843                        "body": r.body,
844                    })
845                })
846                .collect();
847            let payload = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
848            let bytes = payload.as_bytes();
849            let rc = unsafe { super::http_fetch_many(bytes.as_ptr() as i32, bytes.len() as i32) };
850            if rc < 0 {
851                return requests
852                    .iter()
853                    .map(|_| Err("http_fetch_many host call failed".to_string()))
854                    .collect();
855            }
856            super::read_batch_response()
857                .map(|v| v.into_iter().map(parse_fetch_slot).collect())
858                .unwrap_or_else(|| {
859                    requests
860                        .iter()
861                        .map(|_| Err("malformed host response".to_string()))
862                        .collect()
863                })
864        }
865        #[cfg(not(target_arch = "wasm32"))]
866        {
867            let _ = requests;
868            vec![]
869        }
870    }
871
872    #[cfg(target_arch = "wasm32")]
873    fn fetch_response_from_json(v: &Value) -> FetchResponse {
874        FetchResponse {
875            status: v["status"].as_i64().unwrap_or(0) as i32,
876            body: v["body"].as_str().unwrap_or("").to_string(),
877            body_encoding: v["body_encoding"].as_str().unwrap_or("utf8").to_string(),
878            headers: v["headers"]
879                .as_object()
880                .map(|m| {
881                    m.iter()
882                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
883                        .collect()
884                })
885                .unwrap_or_default(),
886        }
887    }
888
889    #[cfg(target_arch = "wasm32")]
890    fn parse_fetch_slot(slot: Value) -> Result<FetchResponse, String> {
891        if !slot["ok"].as_bool().unwrap_or(false) {
892            return Err(slot["error"]
893                .as_str()
894                .unwrap_or("unknown error")
895                .to_string());
896        }
897        Ok(fetch_response_from_json(&slot))
898    }
899
900    /// Read the host response buffer after http_fetch.
901    #[cfg(target_arch = "wasm32")]
902    fn read_fetch_response() -> Option<FetchResponse> {
903        let len = unsafe { get_host_response_len() };
904        if len <= 0 {
905            return None;
906        }
907        let mut buf = vec![0u8; len as usize];
908        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
909        if read <= 0 {
910            return None;
911        }
912        buf.truncate(read as usize);
913        let json_str = String::from_utf8_lossy(&buf);
914        let v: Value = serde_json::from_str(&json_str).ok()?;
915        Some(fetch_response_from_json(&v))
916    }
917}
918
919// ─── config module ──────────────────────────────────────────────────────
920
921/// Read service configuration values set via `cufflink config set`.
922///
923/// Config values are stored in the platform's `service_configs` table,
924/// scoped to your service. Use `cufflink config set KEY VALUE [--secret]`
925/// to set values via the CLI.
926pub mod config {
927    #[allow(unused_imports)]
928    use super::*;
929
930    /// Get a config value by key. Returns `None` if the key doesn't exist.
931    ///
932    /// ```rust,ignore
933    /// let api_key = config::get("ANTHROPIC_API_KEY");
934    /// if let Some(key) = api_key {
935    ///     log::info(&format!("API key loaded ({} chars)", key.len()));
936    /// }
937    /// ```
938    pub fn get(key: &str) -> Option<String> {
939        #[cfg(target_arch = "wasm32")]
940        {
941            let bytes = key.as_bytes();
942            let result = unsafe { get_config(bytes.as_ptr() as i32, bytes.len() as i32) };
943            if result < 0 {
944                return None;
945            }
946            read_config_response()
947        }
948        #[cfg(not(target_arch = "wasm32"))]
949        {
950            let _ = key;
951            None
952        }
953    }
954
955    /// Read the host response buffer after get_config.
956    #[cfg(target_arch = "wasm32")]
957    fn read_config_response() -> Option<String> {
958        let len = unsafe { get_host_response_len() };
959        if len <= 0 {
960            return None;
961        }
962        let mut buf = vec![0u8; len as usize];
963        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
964        if read <= 0 {
965            return None;
966        }
967        buf.truncate(read as usize);
968        String::from_utf8(buf).ok()
969    }
970}
971
972// ─── storage module ─────────────────────────────────────────────────────
973
974/// Download files from S3-compatible object storage using the platform's credentials.
975///
976/// The platform uses its own S3 credentials (configured at deployment time) to
977/// perform authenticated downloads. This works with AWS S3, Hetzner Object Storage,
978/// MinIO, and any S3-compatible service.
979pub mod storage {
980    #[allow(unused_imports)]
981    use super::*;
982
983    /// Download a file from S3 and return its contents as a base64-encoded string.
984    ///
985    /// Returns `None` if the download fails (bucket not found, key not found,
986    /// S3 not configured, etc.).
987    ///
988    /// ```rust,ignore
989    /// if let Some(base64_data) = storage::download("my-bucket", "images/photo.jpg") {
990    ///     log::info(&format!("Downloaded {} bytes of base64", base64_data.len()));
991    /// }
992    /// ```
993    pub fn download(bucket: &str, key: &str) -> Option<String> {
994        #[cfg(target_arch = "wasm32")]
995        {
996            let bucket_bytes = bucket.as_bytes();
997            let key_bytes = key.as_bytes();
998            let result = unsafe {
999                s3_download(
1000                    bucket_bytes.as_ptr() as i32,
1001                    bucket_bytes.len() as i32,
1002                    key_bytes.as_ptr() as i32,
1003                    key_bytes.len() as i32,
1004                )
1005            };
1006            if result < 0 {
1007                return None;
1008            }
1009            read_storage_response()
1010        }
1011        #[cfg(not(target_arch = "wasm32"))]
1012        {
1013            let _ = (bucket, key);
1014            None
1015        }
1016    }
1017
1018    /// Generate a presigned PUT URL for uploading a file directly to S3.
1019    ///
1020    /// The returned URL is valid for `expires_secs` seconds and allows
1021    /// unauthenticated PUT requests. Clients can upload by sending a PUT
1022    /// request to the URL with the file data as the body.
1023    ///
1024    /// ```rust,ignore
1025    /// if let Some(url) = storage::presign_upload("my-bucket", "uploads/photo.jpg", "image/jpeg", 300) {
1026    ///     // Return this URL to the client for direct upload
1027    ///     log::info(&format!("Upload URL: {}", url));
1028    /// }
1029    /// ```
1030    pub fn presign_upload(
1031        bucket: &str,
1032        key: &str,
1033        content_type: &str,
1034        expires_secs: u64,
1035    ) -> Option<String> {
1036        #[cfg(target_arch = "wasm32")]
1037        {
1038            let bucket_bytes = bucket.as_bytes();
1039            let key_bytes = key.as_bytes();
1040            let ct_bytes = content_type.as_bytes();
1041            let result = unsafe {
1042                s3_presign_upload(
1043                    bucket_bytes.as_ptr() as i32,
1044                    bucket_bytes.len() as i32,
1045                    key_bytes.as_ptr() as i32,
1046                    key_bytes.len() as i32,
1047                    ct_bytes.as_ptr() as i32,
1048                    ct_bytes.len() as i32,
1049                    expires_secs as i32,
1050                )
1051            };
1052            if result < 0 {
1053                return None;
1054            }
1055            read_storage_response()
1056        }
1057        #[cfg(not(target_arch = "wasm32"))]
1058        {
1059            let _ = (bucket, key, content_type, expires_secs);
1060            None
1061        }
1062    }
1063
1064    /// Read the host response buffer after s3_download or s3_presign_upload.
1065    #[cfg(target_arch = "wasm32")]
1066    fn read_storage_response() -> Option<String> {
1067        let len = unsafe { get_host_response_len() };
1068        if len <= 0 {
1069            return None;
1070        }
1071        let mut buf = vec![0u8; len as usize];
1072        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1073        if read <= 0 {
1074            return None;
1075        }
1076        buf.truncate(read as usize);
1077        String::from_utf8(buf).ok()
1078    }
1079
1080    /// Download N files from object storage concurrently. Each slot is
1081    /// either the decoded bytes or a per-item error message. Use this
1082    /// instead of looping over [`download`] when N is large — the host
1083    /// fans out in parallel with a bounded semaphore (default 32).
1084    pub fn download_many(items: &[(&str, &str)]) -> Vec<Result<Vec<u8>, String>> {
1085        #[cfg(target_arch = "wasm32")]
1086        {
1087            let payload_items: Vec<Value> = items
1088                .iter()
1089                .map(|(b, k)| serde_json::json!({ "bucket": b, "key": k }))
1090                .collect();
1091            let payload = serde_json::to_string(&payload_items).unwrap_or_else(|_| "[]".into());
1092            let bytes = payload.as_bytes();
1093            let rc = unsafe { super::s3_download_many(bytes.as_ptr() as i32, bytes.len() as i32) };
1094            if rc < 0 {
1095                return items
1096                    .iter()
1097                    .map(|_| Err("s3_download_many host call failed".to_string()))
1098                    .collect();
1099            }
1100            super::read_batch_response()
1101                .map(|v| v.into_iter().map(parse_download_slot).collect())
1102                .unwrap_or_else(|| {
1103                    items
1104                        .iter()
1105                        .map(|_| Err("malformed host response".to_string()))
1106                        .collect()
1107                })
1108        }
1109        #[cfg(not(target_arch = "wasm32"))]
1110        {
1111            let _ = items;
1112            vec![]
1113        }
1114    }
1115
1116    #[cfg(target_arch = "wasm32")]
1117    fn parse_download_slot(slot: Value) -> Result<Vec<u8>, String> {
1118        if !slot["ok"].as_bool().unwrap_or(false) {
1119            return Err(slot["error"]
1120                .as_str()
1121                .unwrap_or("unknown error")
1122                .to_string());
1123        }
1124        let b64 = slot["data_b64"]
1125            .as_str()
1126            .ok_or_else(|| "missing data_b64".to_string())?;
1127        use base64::{engine::general_purpose, Engine};
1128        general_purpose::STANDARD
1129            .decode(b64)
1130            .map_err(|e| format!("base64 decode failed: {}", e))
1131    }
1132}
1133
1134// ─── image module ───────────────────────────────────────────────────────
1135
1136/// Image transforms executed on the platform host.
1137///
1138/// The platform downloads the source from object storage, decodes it,
1139/// optionally downscales the longest edge to `max_dim` (Lanczos3),
1140/// JPEG-encodes at the requested quality, and writes the result back to
1141/// the same bucket. CPU work runs in a host-side blocking task so it
1142/// does not stall the async runtime.
1143pub mod image {
1144    #[allow(unused_imports)]
1145    use super::*;
1146
1147    /// Resize an image stored at `(bucket, in_key)`, JPEG-encode it, and
1148    /// upload to `(bucket, out_key)`.
1149    ///
1150    /// `max_dim == 0` skips resizing. `quality` must be in `1..=100`.
1151    /// Returns the number of bytes written, or `None` on failure (the
1152    /// host logs the underlying error).
1153    ///
1154    /// ```rust,ignore
1155    /// let bytes = image::transform_jpeg("media", "raw/photo.jpg", "raw/photo.display.jpg", 1024, 80);
1156    /// ```
1157    pub fn transform_jpeg(
1158        bucket: &str,
1159        in_key: &str,
1160        out_key: &str,
1161        max_dim: u32,
1162        quality: u8,
1163    ) -> Option<u32> {
1164        #[cfg(target_arch = "wasm32")]
1165        {
1166            let bucket_bytes = bucket.as_bytes();
1167            let in_key_bytes = in_key.as_bytes();
1168            let out_key_bytes = out_key.as_bytes();
1169            let result = unsafe {
1170                super::image_transform_jpeg(
1171                    bucket_bytes.as_ptr() as i32,
1172                    bucket_bytes.len() as i32,
1173                    in_key_bytes.as_ptr() as i32,
1174                    in_key_bytes.len() as i32,
1175                    out_key_bytes.as_ptr() as i32,
1176                    out_key_bytes.len() as i32,
1177                    max_dim as i32,
1178                    quality as i32,
1179                )
1180            };
1181            if result < 0 {
1182                return None;
1183            }
1184            Some(result as u32)
1185        }
1186        #[cfg(not(target_arch = "wasm32"))]
1187        {
1188            let _ = (bucket, in_key, out_key, max_dim, quality);
1189            None
1190        }
1191    }
1192}
1193
1194// ─── redis module ────────────────────────────────────────────────────────
1195
1196/// Read and write values in Redis (backed by the platform's Redis connection).
1197///
1198/// Use this for caching, session storage, or any key-value data that needs
1199/// to be shared across services or requests with low latency.
1200pub mod redis {
1201    #[allow(unused_imports)]
1202    use super::*;
1203
1204    /// Get a value from Redis by key. Returns `None` if the key doesn't exist
1205    /// or Redis is not configured.
1206    ///
1207    /// ```rust,ignore
1208    /// if let Some(cached) = redis::get("auth:perms:user-123") {
1209    ///     log::info(&format!("Cache hit: {}", cached));
1210    /// }
1211    /// ```
1212    pub fn get(key: &str) -> Option<String> {
1213        #[cfg(target_arch = "wasm32")]
1214        {
1215            let bytes = key.as_bytes();
1216            let result = unsafe { redis_get(bytes.as_ptr() as i32, bytes.len() as i32) };
1217            if result < 0 {
1218                return None;
1219            }
1220            read_redis_response()
1221        }
1222        #[cfg(not(target_arch = "wasm32"))]
1223        {
1224            let _ = key;
1225            None
1226        }
1227    }
1228
1229    /// Set a value in Redis. Use `ttl_secs = 0` for no expiry.
1230    ///
1231    /// Returns `true` on success, `false` on failure.
1232    ///
1233    /// ```rust,ignore
1234    /// redis::set("auth:perms:user-123", &perms_json, 3600); // 1 hour TTL
1235    /// ```
1236    pub fn set(key: &str, value: &str, ttl_secs: i32) -> bool {
1237        #[cfg(target_arch = "wasm32")]
1238        {
1239            let key_bytes = key.as_bytes();
1240            let val_bytes = value.as_bytes();
1241            let result = unsafe {
1242                redis_set(
1243                    key_bytes.as_ptr() as i32,
1244                    key_bytes.len() as i32,
1245                    val_bytes.as_ptr() as i32,
1246                    val_bytes.len() as i32,
1247                    ttl_secs,
1248                )
1249            };
1250            result == 0
1251        }
1252        #[cfg(not(target_arch = "wasm32"))]
1253        {
1254            let _ = (key, value, ttl_secs);
1255            true
1256        }
1257    }
1258
1259    /// Delete a key from Redis.
1260    ///
1261    /// Returns `true` on success, `false` on failure.
1262    ///
1263    /// ```rust,ignore
1264    /// redis::del("auth:perms:user-123");
1265    /// ```
1266    pub fn del(key: &str) -> bool {
1267        #[cfg(target_arch = "wasm32")]
1268        {
1269            let bytes = key.as_bytes();
1270            let result = unsafe { redis_del(bytes.as_ptr() as i32, bytes.len() as i32) };
1271            result == 0
1272        }
1273        #[cfg(not(target_arch = "wasm32"))]
1274        {
1275            let _ = key;
1276            true
1277        }
1278    }
1279
1280    /// Read the host response buffer after redis_get.
1281    #[cfg(target_arch = "wasm32")]
1282    fn read_redis_response() -> Option<String> {
1283        let len = unsafe { get_host_response_len() };
1284        if len <= 0 {
1285            return None;
1286        }
1287        let mut buf = vec![0u8; len as usize];
1288        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1289        if read <= 0 {
1290            return None;
1291        }
1292        buf.truncate(read as usize);
1293        String::from_utf8(buf).ok()
1294    }
1295}
1296
1297// ─── util module ────────────────────────────────────────────────────────
1298
1299/// Utility functions for common operations in WASM handlers.
1300pub mod util {
1301    #[allow(unused_imports)]
1302    use super::*;
1303
1304    /// Generate a new random UUID v4 string.
1305    ///
1306    /// ```rust,ignore
1307    /// let id = util::generate_uuid();
1308    /// log::info(&format!("New ID: {}", id));
1309    /// ```
1310    /// Get the current UTC time as an RFC3339 string.
1311    ///
1312    /// In WASM, this calls the platform host function. Outside WASM, it uses `SystemTime`.
1313    ///
1314    /// ```rust,ignore
1315    /// let now = util::current_time();
1316    /// log::info(&format!("Current time: {}", now));
1317    /// ```
1318    pub fn current_time() -> String {
1319        #[cfg(target_arch = "wasm32")]
1320        {
1321            let result = unsafe { super::current_time() };
1322            if result < 0 {
1323                return String::new();
1324            }
1325            let len = unsafe { get_host_response_len() };
1326            if len <= 0 {
1327                return String::new();
1328            }
1329            let mut buf = vec![0u8; len as usize];
1330            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1331            if read <= 0 {
1332                return String::new();
1333            }
1334            buf.truncate(read as usize);
1335            String::from_utf8(buf).unwrap_or_default()
1336        }
1337
1338        #[cfg(not(target_arch = "wasm32"))]
1339        {
1340            let secs = std::time::SystemTime::now()
1341                .duration_since(std::time::UNIX_EPOCH)
1342                .map(|d| d.as_secs())
1343                .unwrap_or(0);
1344            format!("1970-01-01T00:00:00Z+{}", secs)
1345        }
1346    }
1347
1348    pub fn generate_uuid() -> String {
1349        #[cfg(target_arch = "wasm32")]
1350        {
1351            let result = unsafe { super::generate_uuid() };
1352            if result < 0 {
1353                return String::new();
1354            }
1355            let len = unsafe { get_host_response_len() };
1356            if len <= 0 {
1357                return String::new();
1358            }
1359            let mut buf = vec![0u8; len as usize];
1360            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1361            if read <= 0 {
1362                return String::new();
1363            }
1364            buf.truncate(read as usize);
1365            String::from_utf8(buf).unwrap_or_default()
1366        }
1367
1368        #[cfg(not(target_arch = "wasm32"))]
1369        {
1370            format!(
1371                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1372                std::time::SystemTime::now()
1373                    .duration_since(std::time::UNIX_EPOCH)
1374                    .map(|d| d.as_nanos() as u32)
1375                    .unwrap_or(0),
1376                std::process::id() as u16,
1377                0u16,
1378                0x8000u16,
1379                0u64,
1380            )
1381        }
1382    }
1383}
1384
1385// ─── Handler runtime ─────────────────────────────────────────────────────────
1386
1387/// Internal function used by the `handler!` macro. Do not call directly.
1388#[doc(hidden)]
1389pub fn __run_handler<F>(ptr: i32, len: i32, f: F) -> i32
1390where
1391    F: FnOnce(Request) -> Response,
1392{
1393    // Read the request JSON from guest memory
1394    let request_json = unsafe {
1395        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
1396        String::from_utf8_lossy(slice).into_owned()
1397    };
1398
1399    // Parse the request
1400    let request = Request::from_json(&request_json).unwrap_or_else(|| Request {
1401        method: "GET".to_string(),
1402        handler: String::new(),
1403        headers: HashMap::new(),
1404        body: Value::Null,
1405        raw_body: Vec::new(),
1406        tenant: String::new(),
1407        service: String::new(),
1408        auth: None,
1409        job: None,
1410    });
1411
1412    // Call the user's handler
1413    let response = f(request);
1414    let response_bytes = response.into_data().into_bytes();
1415
1416    // Write response to guest memory: [4-byte LE length][data]
1417    let total = 4 + response_bytes.len();
1418    let layout = std::alloc::Layout::from_size_align(total, 1).expect("invalid layout");
1419    let out_ptr = unsafe { std::alloc::alloc(layout) };
1420
1421    unsafe {
1422        let len_bytes = (response_bytes.len() as u32).to_le_bytes();
1423        std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out_ptr, 4);
1424        std::ptr::copy_nonoverlapping(
1425            response_bytes.as_ptr(),
1426            out_ptr.add(4),
1427            response_bytes.len(),
1428        );
1429    }
1430
1431    out_ptr as i32
1432}
1433
1434// ─── Macros ──────────────────────────────────────────────────────────────────
1435
1436/// Initialize the cufflink-fn runtime. Call this once at the top of your `lib.rs`.
1437///
1438/// Exports the `alloc` function that the platform needs to pass data into
1439/// your WASM module.
1440///
1441/// ```rust,ignore
1442/// use cufflink_fn::prelude::*;
1443///
1444/// cufflink_fn::init!();
1445/// ```
1446#[macro_export]
1447macro_rules! init {
1448    () => {
1449        #[no_mangle]
1450        pub extern "C" fn alloc(size: i32) -> i32 {
1451            let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
1452            unsafe { std::alloc::alloc(layout) as i32 }
1453        }
1454    };
1455}
1456
1457/// Define a handler function.
1458///
1459/// This macro generates the `#[no_mangle] extern "C"` boilerplate so your
1460/// handler is a plain Rust closure that receives a [`Request`] and returns
1461/// a [`Response`].
1462///
1463/// ```rust,ignore
1464/// use cufflink_fn::prelude::*;
1465///
1466/// cufflink_fn::init!();
1467///
1468/// handler!(get_stats, |req: Request| {
1469///     let rows = db::query("SELECT COUNT(*) as total FROM orders");
1470///     Response::json(&json!({"total": rows[0]["total"]}))
1471/// });
1472///
1473/// handler!(create_order, |req: Request| {
1474///     let body = req.body();
1475///     let customer = body["customer_id"].as_str().unwrap_or("unknown");
1476///     db::execute(&format!(
1477///         "INSERT INTO orders (customer_id, status) VALUES ('{}', 'pending')",
1478///         customer
1479///     ));
1480///     Response::json(&json!({"status": "created"}))
1481/// });
1482/// ```
1483#[macro_export]
1484macro_rules! handler {
1485    ($name:ident, |$req:ident : Request| $body:expr) => {
1486        #[no_mangle]
1487        pub extern "C" fn $name(ptr: i32, len: i32) -> i32 {
1488            $crate::__run_handler(ptr, len, |$req: $crate::Request| $body)
1489        }
1490    };
1491}
1492
1493// ─── Schema migration hook ───────────────────────────────────────────────────
1494
1495/// Helpers for the optional `on_migrate` schema migration hook.
1496///
1497/// Declared in your service:
1498///
1499/// ```rust,ignore
1500/// cufflink::service! {
1501///     name: "logistics-service",
1502///     mode: wasm,
1503///     tables: [PickupRequest],
1504///     on_migrate: "handle_on_migrate",
1505///     // ...
1506/// }
1507/// ```
1508///
1509/// And implemented as a handler that delegates to [`migrate::run`]:
1510///
1511/// ```rust,ignore
1512/// use cufflink_fn::prelude::*;
1513///
1514/// handler!(handle_on_migrate, |req: Request| {
1515///     migrate::run(req, |diff| {
1516///         if diff.added_column("pickup_requests", "approximate_item_count_min")
1517///             && diff.dropped_column("pickup_requests", "approximate_item_count")
1518///         {
1519///             db::execute("UPDATE pickup_requests \
1520///                          SET approximate_item_count_min = approximate_item_count \
1521///                          WHERE approximate_item_count_min IS NULL");
1522///         }
1523///         Ok(())
1524///     })
1525/// });
1526/// ```
1527///
1528/// The closure must be **idempotent** — cufflink may invoke it on retried
1529/// deploys, on no-op deploys, and on first-time deploys against a fresh
1530/// database. Use `WHERE … IS NULL` guards on every UPDATE.
1531pub mod migrate {
1532    use super::{Request, Response};
1533    pub use cufflink_types::SchemaDiff;
1534
1535    /// Parse the [`SchemaDiff`] from the request body and invoke `handler`.
1536    ///
1537    /// Returns a `200 {"ok": true}` response on success or a `400` with the
1538    /// error message if `handler` returns `Err`. If the request body fails
1539    /// to deserialise as a `SchemaDiff`, returns a `400` describing the
1540    /// parse error.
1541    pub fn run<F>(req: Request, handler: F) -> Response
1542    where
1543        F: FnOnce(SchemaDiff) -> Result<(), String>,
1544    {
1545        match serde_json::from_value::<SchemaDiff>(req.body().clone()) {
1546            Ok(diff) => match handler(diff) {
1547                Ok(()) => Response::json(&serde_json::json!({"ok": true})),
1548                Err(e) => Response::error(&e),
1549            },
1550            Err(e) => Response::error(&format!(
1551                "on_migrate: failed to parse SchemaDiff payload: {}",
1552                e
1553            )),
1554        }
1555    }
1556}
1557
1558// ─── Prelude ─────────────────────────────────────────────────────────────────
1559
1560/// Import everything you need to write handlers.
1561///
1562/// ```rust,ignore
1563/// use cufflink_fn::prelude::*;
1564/// ```
1565pub mod prelude {
1566    pub use crate::config;
1567    pub use crate::db;
1568    pub use crate::http;
1569    pub use crate::image;
1570    pub use crate::log;
1571    pub use crate::migrate;
1572    pub use crate::nats;
1573    pub use crate::redis;
1574    pub use crate::storage;
1575    pub use crate::util;
1576    pub use crate::Auth;
1577    pub use crate::JobContext;
1578    pub use crate::Request;
1579    pub use crate::Response;
1580    pub use serde_json::{json, Value};
1581}
1582
1583// ─── Tests ───────────────────────────────────────────────────────────────────
1584
1585#[cfg(test)]
1586mod tests {
1587    use super::*;
1588    use serde_json::json;
1589
1590    #[test]
1591    fn test_request_parsing() {
1592        let json = serde_json::to_string(&json!({
1593            "method": "POST",
1594            "handler": "checkout",
1595            "headers": {"content-type": "application/json"},
1596            "body": {"item": "widget", "qty": 3},
1597            "tenant": "acme",
1598            "service": "shop"
1599        }))
1600        .unwrap();
1601
1602        let req = Request::from_json(&json).unwrap();
1603        assert_eq!(req.method(), "POST");
1604        assert_eq!(req.handler(), "checkout");
1605        assert_eq!(req.tenant(), "acme");
1606        assert_eq!(req.service(), "shop");
1607        assert_eq!(req.body()["item"], "widget");
1608        assert_eq!(req.body()["qty"], 3);
1609        assert_eq!(req.header("content-type"), Some("application/json"));
1610    }
1611
1612    #[test]
1613    fn test_request_missing_fields() {
1614        let json = r#"{"method": "GET"}"#;
1615        let req = Request::from_json(json).unwrap();
1616        assert_eq!(req.method(), "GET");
1617        assert_eq!(req.handler(), "");
1618        assert_eq!(req.tenant(), "");
1619        assert_eq!(req.body(), &Value::Null);
1620        assert!(req.raw_body().is_empty());
1621    }
1622
1623    #[test]
1624    fn test_request_raw_body_round_trip() {
1625        use base64::{engine::general_purpose, Engine};
1626        let raw = br#"{"type":"message.delivered","data":{"object":{"id":"AC1"}}}"#;
1627        let json = serde_json::to_string(&json!({
1628            "method": "POST",
1629            "handler": "webhook",
1630            "headers": {"content-type": "application/json"},
1631            "body": serde_json::from_slice::<Value>(raw).unwrap(),
1632            "body_raw_b64": general_purpose::STANDARD.encode(raw),
1633            "tenant": "acme",
1634            "service": "shop",
1635        }))
1636        .unwrap();
1637        let req = Request::from_json(&json).unwrap();
1638        assert_eq!(req.raw_body(), raw);
1639    }
1640
1641    #[test]
1642    fn test_request_raw_body_invalid_base64_yields_empty() {
1643        let json = serde_json::to_string(&json!({
1644            "method": "POST",
1645            "handler": "webhook",
1646            "body": Value::Null,
1647            "body_raw_b64": "not%base64!",
1648            "tenant": "acme",
1649            "service": "shop",
1650        }))
1651        .unwrap();
1652        let req = Request::from_json(&json).unwrap();
1653        assert!(req.raw_body().is_empty());
1654    }
1655
1656    #[test]
1657    fn test_request_job_context_parsed_when_present() {
1658        let json = serde_json::to_string(&json!({
1659            "method": "POST",
1660            "handler": "handle_run_ai",
1661            "body": {"id": "item-1"},
1662            "tenant": "acme",
1663            "service": "asset",
1664            "_job": {
1665                "id": "11111111-1111-1111-1111-111111111111",
1666                "attempt": 2,
1667                "max_attempts": 3,
1668            },
1669        }))
1670        .unwrap();
1671        let req = Request::from_json(&json).unwrap();
1672        let job = req.job().expect("job context should be parsed");
1673        assert_eq!(job.id, "11111111-1111-1111-1111-111111111111");
1674        assert_eq!(job.attempt, 2);
1675        assert_eq!(job.max_attempts, 3);
1676        assert!(job.is_retry());
1677    }
1678
1679    #[test]
1680    fn test_request_job_context_absent_on_http_direct() {
1681        let json = serde_json::to_string(&json!({
1682            "method": "GET",
1683            "handler": "list",
1684            "body": Value::Null,
1685            "tenant": "acme",
1686            "service": "asset",
1687        }))
1688        .unwrap();
1689        let req = Request::from_json(&json).unwrap();
1690        assert!(req.job().is_none());
1691    }
1692
1693    #[test]
1694    fn test_request_job_context_first_attempt_is_not_retry() {
1695        let json = serde_json::to_string(&json!({
1696            "method": "POST",
1697            "handler": "handle_run_ai",
1698            "body": Value::Null,
1699            "tenant": "acme",
1700            "service": "asset",
1701            "_job": {
1702                "id": "22222222-2222-2222-2222-222222222222",
1703                "attempt": 1,
1704                "max_attempts": 2,
1705            },
1706        }))
1707        .unwrap();
1708        let req = Request::from_json(&json).unwrap();
1709        let job = req.job().expect("job context should be parsed");
1710        assert_eq!(job.attempt, 1);
1711        assert!(!job.is_retry());
1712    }
1713
1714    #[test]
1715    fn test_request_job_context_rejects_malformed_envelope() {
1716        let json = serde_json::to_string(&json!({
1717            "method": "POST",
1718            "handler": "handle_run_ai",
1719            "body": Value::Null,
1720            "tenant": "acme",
1721            "service": "asset",
1722            "_job": {
1723                "id": "33333333-3333-3333-3333-333333333333",
1724                "max_attempts": 2,
1725            },
1726        }))
1727        .unwrap();
1728        let req = Request::from_json(&json).unwrap();
1729        assert!(
1730            req.job().is_none(),
1731            "missing attempt should yield None, not a partial JobContext"
1732        );
1733    }
1734
1735    #[test]
1736    fn test_response_json() {
1737        let resp = Response::json(&json!({"status": "ok", "count": 42}));
1738        let data = resp.into_data();
1739        let parsed: Value = serde_json::from_str(&data).unwrap();
1740        assert_eq!(parsed["status"], "ok");
1741        assert_eq!(parsed["count"], 42);
1742    }
1743
1744    #[test]
1745    fn test_response_error() {
1746        let resp = Response::error("something went wrong");
1747        let data = resp.into_data();
1748        let parsed: Value = serde_json::from_str(&data).unwrap();
1749        // error() returns status 400, so into_data wraps with __status/__body
1750        assert_eq!(parsed["__status"], 400);
1751        assert_eq!(parsed["__body"]["error"], "something went wrong");
1752    }
1753
1754    #[test]
1755    fn test_response_not_found() {
1756        let resp = Response::not_found("item not found");
1757        let data = resp.into_data();
1758        let parsed: Value = serde_json::from_str(&data).unwrap();
1759        assert_eq!(parsed["__status"], 404);
1760        assert_eq!(parsed["__body"]["error"], "item not found");
1761    }
1762
1763    #[test]
1764    fn test_response_with_status() {
1765        let resp = Response::json(&serde_json::json!({"ok": true})).with_status(201);
1766        let data = resp.into_data();
1767        let parsed: Value = serde_json::from_str(&data).unwrap();
1768        assert_eq!(parsed["__status"], 201);
1769        assert_eq!(parsed["__body"]["ok"], true);
1770    }
1771
1772    fn migrate_request(diff: serde_json::Value) -> Request {
1773        let payload = serde_json::to_string(&json!({
1774            "method": "POST",
1775            "handler": "handle_on_migrate",
1776            "headers": {},
1777            "body": diff,
1778            "tenant": "default",
1779            "service": "logistics-service",
1780        }))
1781        .unwrap();
1782        Request::from_json(&payload).unwrap()
1783    }
1784
1785    #[test]
1786    fn test_migrate_run_success() {
1787        let req = migrate_request(json!({
1788            "added_columns": [["pickups", "min"]],
1789            "dropped_columns": [["pickups", "midpoint"]],
1790        }));
1791        let resp = migrate::run(req, |diff| {
1792            assert!(diff.added_column("pickups", "min"));
1793            assert!(diff.dropped_column("pickups", "midpoint"));
1794            Ok(())
1795        });
1796        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1797        assert_eq!(parsed["ok"], true);
1798    }
1799
1800    #[test]
1801    fn test_migrate_run_handler_error() {
1802        let req = migrate_request(json!({}));
1803        let resp = migrate::run(req, |_| Err("backfill failed".into()));
1804        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1805        assert_eq!(parsed["__status"], 400);
1806        assert_eq!(parsed["__body"]["error"], "backfill failed");
1807    }
1808
1809    #[test]
1810    fn test_migrate_run_invalid_payload() {
1811        // body is a string, not a SchemaDiff object
1812        let req = migrate_request(json!("not a diff"));
1813        let resp = migrate::run(req, |_| {
1814            panic!("closure should not be called for invalid payload")
1815        });
1816        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1817        assert_eq!(parsed["__status"], 400);
1818        assert!(parsed["__body"]["error"]
1819            .as_str()
1820            .unwrap()
1821            .contains("on_migrate: failed to parse SchemaDiff payload"));
1822    }
1823
1824    #[test]
1825    fn test_migrate_run_empty_diff() {
1826        let req = migrate_request(json!({}));
1827        let mut called = false;
1828        let resp = migrate::run(req, |diff| {
1829            assert!(diff.is_empty());
1830            called = true;
1831            Ok(())
1832        });
1833        assert!(called);
1834        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1835        assert_eq!(parsed["ok"], true);
1836    }
1837
1838    #[test]
1839    fn test_response_200_no_wrapper() {
1840        let resp = Response::json(&serde_json::json!({"data": "test"}));
1841        let data = resp.into_data();
1842        let parsed: Value = serde_json::from_str(&data).unwrap();
1843        // 200 responses should NOT be wrapped
1844        assert_eq!(parsed["data"], "test");
1845        assert!(parsed.get("__status").is_none());
1846    }
1847
1848    #[test]
1849    fn test_response_empty() {
1850        let resp = Response::empty();
1851        let data = resp.into_data();
1852        let parsed: Value = serde_json::from_str(&data).unwrap();
1853        assert_eq!(parsed["ok"], true);
1854    }
1855
1856    #[test]
1857    fn test_response_text() {
1858        let resp = Response::text("hello world");
1859        let data = resp.into_data();
1860        let parsed: Value = serde_json::from_str(&data).unwrap();
1861        assert_eq!(parsed, "hello world");
1862    }
1863
1864    #[test]
1865    fn test_db_query_noop_on_native() {
1866        // On native (non-wasm) targets, db functions are no-ops
1867        let rows = db::query("SELECT 1");
1868        assert!(rows.is_empty());
1869    }
1870
1871    #[test]
1872    fn test_db_query_one_noop_on_native() {
1873        let row = db::query_one("SELECT 1");
1874        assert!(row.is_none());
1875    }
1876
1877    #[test]
1878    fn test_db_execute_noop_on_native() {
1879        let affected = db::execute("INSERT INTO x VALUES (1)");
1880        assert_eq!(affected, 0);
1881    }
1882
1883    #[test]
1884    fn test_nats_publish_noop_on_native() {
1885        let ok = nats::publish("test.subject", "payload");
1886        assert!(ok);
1887    }
1888
1889    #[test]
1890    fn test_request_with_auth() {
1891        let json = serde_json::to_string(&json!({
1892            "method": "POST",
1893            "handler": "checkout",
1894            "headers": {},
1895            "body": {},
1896            "tenant": "acme",
1897            "service": "shop",
1898            "auth": {
1899                "sub": "user-123",
1900                "preferred_username": "john",
1901                "name": "John Doe",
1902                "email": "john@example.com",
1903                "realm_roles": ["admin", "manager"],
1904                "claims": {"department": "engineering"}
1905            }
1906        }))
1907        .unwrap();
1908
1909        let req = Request::from_json(&json).unwrap();
1910        let auth = req.auth().unwrap();
1911        assert_eq!(auth.sub, "user-123");
1912        assert_eq!(auth.preferred_username.as_deref(), Some("john"));
1913        assert_eq!(auth.name.as_deref(), Some("John Doe"));
1914        assert_eq!(auth.email.as_deref(), Some("john@example.com"));
1915        assert!(auth.has_role("admin"));
1916        assert!(auth.has_role("manager"));
1917        assert!(!auth.has_role("viewer"));
1918        assert_eq!(
1919            auth.claim("department").and_then(|v| v.as_str()),
1920            Some("engineering")
1921        );
1922    }
1923
1924    #[test]
1925    fn test_request_without_auth() {
1926        let json = r#"{"method": "GET"}"#;
1927        let req = Request::from_json(json).unwrap();
1928        assert!(req.auth().is_none());
1929    }
1930
1931    #[test]
1932    fn test_request_null_auth() {
1933        let json = serde_json::to_string(&json!({
1934            "method": "GET",
1935            "auth": null
1936        }))
1937        .unwrap();
1938        let req = Request::from_json(&json).unwrap();
1939        assert!(req.auth().is_none());
1940    }
1941
1942    #[test]
1943    fn test_require_auth_success() {
1944        let json = serde_json::to_string(&json!({
1945            "method": "GET",
1946            "auth": {"sub": "user-1", "realm_roles": [], "claims": {}}
1947        }))
1948        .unwrap();
1949        let req = Request::from_json(&json).unwrap();
1950        assert!(req.require_auth().is_ok());
1951        assert_eq!(req.require_auth().unwrap().sub, "user-1");
1952    }
1953
1954    #[test]
1955    fn test_require_auth_fails_when_unauthenticated() {
1956        let json = r#"{"method": "GET"}"#;
1957        let req = Request::from_json(json).unwrap();
1958        assert!(req.require_auth().is_err());
1959    }
1960
1961    #[test]
1962    fn test_http_fetch_noop_on_native() {
1963        let resp = http::fetch("GET", "https://example.com", &[], None);
1964        assert!(resp.is_none());
1965    }
1966
1967    #[test]
1968    fn test_http_get_noop_on_native() {
1969        let resp = http::get("https://example.com", &[]);
1970        assert!(resp.is_none());
1971    }
1972
1973    #[test]
1974    fn test_http_post_noop_on_native() {
1975        let resp = http::post("https://example.com", &[], "{}");
1976        assert!(resp.is_none());
1977    }
1978
1979    #[test]
1980    fn test_storage_download_noop_on_native() {
1981        let data = storage::download("my-bucket", "images/photo.jpg");
1982        assert!(data.is_none());
1983    }
1984
1985    #[test]
1986    fn test_image_transform_jpeg_noop_on_native() {
1987        let bytes = image::transform_jpeg("my-bucket", "in.jpg", "out.jpg", 1024, 80);
1988        assert!(bytes.is_none());
1989    }
1990
1991    #[test]
1992    fn test_auth_permissions() {
1993        let json = serde_json::to_string(&json!({
1994            "method": "POST",
1995            "handler": "test",
1996            "headers": {},
1997            "body": {},
1998            "tenant": "acme",
1999            "service": "shop",
2000            "auth": {
2001                "sub": "user-1",
2002                "realm_roles": ["admin"],
2003                "claims": {},
2004                "permissions": ["staff:create", "staff:view", "items:*"],
2005                "role_names": ["admin", "manager"]
2006            }
2007        }))
2008        .unwrap();
2009
2010        let req = Request::from_json(&json).unwrap();
2011        let auth = req.auth().unwrap();
2012
2013        // Exact permission match
2014        assert!(auth.can("staff", "create"));
2015        assert!(auth.can("staff", "view"));
2016        assert!(!auth.can("staff", "delete"));
2017
2018        // Wildcard match
2019        assert!(auth.can("items", "create"));
2020        assert!(auth.can("items", "view"));
2021        assert!(auth.can("items", "delete"));
2022
2023        // No match
2024        assert!(!auth.can("batches", "view"));
2025
2026        // Cufflink roles
2027        assert!(auth.has_cufflink_role("admin"));
2028        assert!(auth.has_cufflink_role("manager"));
2029        assert!(!auth.has_cufflink_role("viewer"));
2030    }
2031
2032    #[test]
2033    fn test_auth_super_wildcard() {
2034        let auth = Auth {
2035            sub: "user-1".to_string(),
2036            preferred_username: None,
2037            name: None,
2038            email: None,
2039            realm_roles: vec![],
2040            claims: HashMap::new(),
2041            permissions: vec!["*".to_string()],
2042            role_names: vec!["superadmin".to_string()],
2043            is_service_account: false,
2044        };
2045
2046        assert!(auth.can("anything", "everything"));
2047        assert!(auth.can("staff", "create"));
2048    }
2049
2050    #[test]
2051    fn test_auth_empty_permissions() {
2052        let auth = Auth {
2053            sub: "user-1".to_string(),
2054            preferred_username: None,
2055            name: None,
2056            email: None,
2057            realm_roles: vec![],
2058            claims: HashMap::new(),
2059            permissions: vec![],
2060            role_names: vec![],
2061            is_service_account: false,
2062        };
2063
2064        assert!(!auth.can("staff", "create"));
2065        assert!(!auth.has_cufflink_role("admin"));
2066    }
2067
2068    #[test]
2069    fn test_redis_get_noop_on_native() {
2070        let val = redis::get("some-key");
2071        assert!(val.is_none());
2072    }
2073
2074    #[test]
2075    fn test_redis_set_noop_on_native() {
2076        let ok = redis::set("key", "value", 3600);
2077        assert!(ok);
2078    }
2079
2080    #[test]
2081    fn test_redis_del_noop_on_native() {
2082        let ok = redis::del("key");
2083        assert!(ok);
2084    }
2085
2086    #[test]
2087    fn test_http_fetch_response_helpers() {
2088        let resp = http::FetchResponse {
2089            status: 200,
2090            body: r#"{"key": "value"}"#.to_string(),
2091            body_encoding: "utf8".to_string(),
2092            headers: HashMap::new(),
2093        };
2094        assert!(resp.is_success());
2095        assert!(!resp.is_base64());
2096        let json = resp.json().unwrap();
2097        assert_eq!(json["key"], "value");
2098
2099        let err_resp = http::FetchResponse {
2100            status: 404,
2101            body: "not found".to_string(),
2102            body_encoding: "utf8".to_string(),
2103            headers: HashMap::new(),
2104        };
2105        assert!(!err_resp.is_success());
2106
2107        let binary_resp = http::FetchResponse {
2108            status: 200,
2109            body: "aW1hZ2VkYXRh".to_string(),
2110            body_encoding: "base64".to_string(),
2111            headers: HashMap::new(),
2112        };
2113        assert!(binary_resp.is_base64());
2114    }
2115}