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