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