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