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