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 http_fetch(
47        method_ptr: i32,
48        method_len: i32,
49        url_ptr: i32,
50        url_len: i32,
51        headers_ptr: i32,
52        headers_len: i32,
53        body_ptr: i32,
54        body_len: i32,
55    ) -> i32;
56    fn get_config(key_ptr: i32, key_len: i32) -> i32;
57}
58
59// ─── Auth ────────────────────────────────────────────────────────────────────
60
61/// Authenticated user context, validated by the Cufflink platform.
62///
63/// The platform validates the JWT token (via Keycloak) and extracts claims
64/// before passing them to your handler. You never need to validate tokens
65/// yourself — the `auth` field is only present when the token is valid.
66///
67/// ```rust,ignore
68/// handler!(protected, |req: Request| {
69///     let auth = match req.require_auth() {
70///         Ok(auth) => auth,
71///         Err(resp) => return resp,
72///     };
73///     if !auth.has_role("admin") {
74///         return Response::error("Forbidden");
75///     }
76///     Response::json(&json!({"user": auth.sub}))
77/// });
78/// ```
79#[derive(Debug, Clone)]
80pub struct Auth {
81    /// Keycloak subject ID (unique user identifier).
82    pub sub: String,
83    /// Preferred username from Keycloak.
84    pub preferred_username: Option<String>,
85    /// Display name.
86    pub name: Option<String>,
87    /// Email address.
88    pub email: Option<String>,
89    /// Realm roles assigned to the user in Keycloak.
90    pub realm_roles: Vec<String>,
91    /// All other JWT claims (custom Keycloak mappers, resource_access, etc.).
92    pub claims: HashMap<String, Value>,
93}
94
95impl Auth {
96    /// Check if the user has a specific realm role.
97    pub fn has_role(&self, role: &str) -> bool {
98        self.realm_roles.iter().any(|r| r == role)
99    }
100
101    /// Get a specific claim value by key.
102    pub fn claim(&self, key: &str) -> Option<&Value> {
103        self.claims.get(key)
104    }
105}
106
107// ─── Request ─────────────────────────────────────────────────────────────────
108
109/// An incoming HTTP request from the Cufflink platform.
110///
111/// The platform serializes the full request context (method, headers, body,
112/// tenant, service name, auth) into JSON and passes it to your handler.
113#[derive(Debug, Clone)]
114pub struct Request {
115    method: String,
116    handler: String,
117    headers: HashMap<String, String>,
118    body: Value,
119    tenant: String,
120    service: String,
121    auth: Option<Auth>,
122}
123
124impl Request {
125    /// Parse a `Request` from the JSON the platform provides.
126    pub fn from_json(json: &str) -> Option<Self> {
127        let v: Value = serde_json::from_str(json).ok()?;
128        let headers = v["headers"]
129            .as_object()
130            .map(|m| {
131                m.iter()
132                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
133                    .collect()
134            })
135            .unwrap_or_default();
136
137        let auth = v["auth"].as_object().map(|auth_obj| {
138            let a = Value::Object(auth_obj.clone());
139            Auth {
140                sub: a["sub"].as_str().unwrap_or("").to_string(),
141                preferred_username: a["preferred_username"].as_str().map(|s| s.to_string()),
142                name: a["name"].as_str().map(|s| s.to_string()),
143                email: a["email"].as_str().map(|s| s.to_string()),
144                realm_roles: a["realm_roles"]
145                    .as_array()
146                    .map(|arr| {
147                        arr.iter()
148                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
149                            .collect()
150                    })
151                    .unwrap_or_default(),
152                claims: a["claims"]
153                    .as_object()
154                    .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
155                    .unwrap_or_default(),
156            }
157        });
158
159        Some(Self {
160            method: v["method"].as_str().unwrap_or("GET").to_string(),
161            handler: v["handler"].as_str().unwrap_or("").to_string(),
162            headers,
163            body: v["body"].clone(),
164            tenant: v["tenant"].as_str().unwrap_or("").to_string(),
165            service: v["service"].as_str().unwrap_or("").to_string(),
166            auth,
167        })
168    }
169
170    /// The HTTP method (GET, POST, PUT, DELETE).
171    pub fn method(&self) -> &str {
172        &self.method
173    }
174
175    /// The handler name from the URL path.
176    pub fn handler(&self) -> &str {
177        &self.handler
178    }
179
180    /// All HTTP headers as a map.
181    pub fn headers(&self) -> &HashMap<String, String> {
182        &self.headers
183    }
184
185    /// Get a specific header value.
186    pub fn header(&self, name: &str) -> Option<&str> {
187        self.headers.get(name).map(|s| s.as_str())
188    }
189
190    /// The parsed JSON body. Returns `Value::Null` if no body was sent.
191    pub fn body(&self) -> &Value {
192        &self.body
193    }
194
195    /// The tenant slug from the URL.
196    pub fn tenant(&self) -> &str {
197        &self.tenant
198    }
199
200    /// The service name from the URL.
201    pub fn service(&self) -> &str {
202        &self.service
203    }
204
205    /// Get the authenticated user context, if present.
206    ///
207    /// Returns `None` if no valid JWT or API key was provided with the request.
208    pub fn auth(&self) -> Option<&Auth> {
209        self.auth.as_ref()
210    }
211
212    /// Require authentication. Returns the auth context or an error response.
213    ///
214    /// ```rust,ignore
215    /// handler!(protected, |req: Request| {
216    ///     let auth = match req.require_auth() {
217    ///         Ok(auth) => auth,
218    ///         Err(resp) => return resp,
219    ///     };
220    ///     Response::json(&json!({"user": auth.sub}))
221    /// });
222    /// ```
223    pub fn require_auth(&self) -> Result<&Auth, Response> {
224        self.auth.as_ref().ok_or_else(|| {
225            Response::json(&serde_json::json!({
226                "error": "Authentication required",
227                "status": 401
228            }))
229        })
230    }
231}
232
233// ─── Response ────────────────────────────────────────────────────────────────
234
235/// An HTTP response to return from your handler.
236#[derive(Debug, Clone)]
237pub struct Response {
238    data: String,
239}
240
241impl Response {
242    /// Return a JSON response.
243    pub fn json(value: &Value) -> Self {
244        Self {
245            data: serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
246        }
247    }
248
249    /// Return a plain text response (wrapped in a JSON string).
250    pub fn text(s: &str) -> Self {
251        Self::json(&Value::String(s.to_string()))
252    }
253
254    /// Return an error response with a message.
255    pub fn error(message: &str) -> Self {
256        Self::json(&serde_json::json!({"error": message}))
257    }
258
259    /// Return an empty success response.
260    pub fn empty() -> Self {
261        Self::json(&serde_json::json!({"ok": true}))
262    }
263
264    /// Get the raw response string.
265    pub fn into_data(self) -> String {
266        self.data
267    }
268}
269
270// ─── db module ───────────────────────────────────────────────────────────────
271
272/// Database access — run SQL queries against your service's tables.
273///
274/// All queries run in the tenant's schema automatically. You don't need
275/// to qualify table names with a schema prefix.
276pub mod db {
277    use super::*;
278
279    /// Run a SELECT query and return all rows as a `Vec<Value>`.
280    ///
281    /// Each row is a JSON object with column names as keys.
282    ///
283    /// ```rust,ignore
284    /// let users = db::query("SELECT id, name, email FROM users WHERE active = true");
285    /// for user in &users {
286    ///     log::info(&format!("User: {}", user["name"]));
287    /// }
288    /// ```
289    pub fn query(sql: &str) -> Vec<Value> {
290        #[cfg(target_arch = "wasm32")]
291        {
292            let bytes = sql.as_bytes();
293            let result = unsafe { db_query(bytes.as_ptr() as i32, bytes.len() as i32) };
294            if result < 0 {
295                return vec![];
296            }
297            read_host_response()
298        }
299        #[cfg(not(target_arch = "wasm32"))]
300        {
301            let _ = sql;
302            vec![]
303        }
304    }
305
306    /// Run a SELECT query and return the first row, or `None` if empty.
307    ///
308    /// ```rust,ignore
309    /// if let Some(user) = db::query_one("SELECT * FROM users WHERE id = 'abc'") {
310    ///     log::info(&format!("Found user: {}", user["name"]));
311    /// }
312    /// ```
313    pub fn query_one(sql: &str) -> Option<Value> {
314        query(sql).into_iter().next()
315    }
316
317    /// Run an INSERT, UPDATE, or DELETE statement.
318    ///
319    /// Returns the number of affected rows, or -1 on error.
320    ///
321    /// ```rust,ignore
322    /// let affected = db::execute("UPDATE orders SET status = 'shipped' WHERE id = 'abc'");
323    /// log::info(&format!("Updated {} rows", affected));
324    /// ```
325    pub fn execute(sql: &str) -> i32 {
326        #[cfg(target_arch = "wasm32")]
327        {
328            let bytes = sql.as_bytes();
329            unsafe { db_execute(bytes.as_ptr() as i32, bytes.len() as i32) }
330        }
331        #[cfg(not(target_arch = "wasm32"))]
332        {
333            let _ = sql;
334            0
335        }
336    }
337
338    /// Read the host response buffer (used internally after db_query).
339    #[cfg(target_arch = "wasm32")]
340    fn read_host_response() -> Vec<Value> {
341        let len = unsafe { get_host_response_len() };
342        if len <= 0 {
343            return vec![];
344        }
345        let mut buf = vec![0u8; len as usize];
346        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
347        if read <= 0 {
348            return vec![];
349        }
350        buf.truncate(read as usize);
351        let json_str = String::from_utf8_lossy(&buf);
352        serde_json::from_str(&json_str).unwrap_or_default()
353    }
354}
355
356// ─── nats module ─────────────────────────────────────────────────────────────
357
358/// Publish messages to NATS for event-driven communication.
359///
360/// Use this to notify other services, trigger subscriptions, or emit
361/// domain events.
362pub mod nats {
363    #[allow(unused_imports)]
364    use super::*;
365
366    /// Publish a message to a NATS subject.
367    ///
368    /// Returns `true` on success, `false` on failure.
369    ///
370    /// ```rust,ignore
371    /// nats::publish(
372    ///     "dw.acme.order-service.orders.created",
373    ///     &serde_json::json!({"order_id": "abc", "total": 4500}).to_string(),
374    /// );
375    /// ```
376    pub fn publish(subject: &str, payload: &str) -> bool {
377        #[cfg(target_arch = "wasm32")]
378        {
379            let subj_bytes = subject.as_bytes();
380            let payload_bytes = payload.as_bytes();
381            let result = unsafe {
382                nats_publish(
383                    subj_bytes.as_ptr() as i32,
384                    subj_bytes.len() as i32,
385                    payload_bytes.as_ptr() as i32,
386                    payload_bytes.len() as i32,
387                )
388            };
389            result == 0
390        }
391        #[cfg(not(target_arch = "wasm32"))]
392        {
393            let _ = (subject, payload);
394            true
395        }
396    }
397}
398
399// ─── log module ──────────────────────────────────────────────────────────────
400
401/// Structured logging from inside your WASM handler.
402///
403/// Messages appear in the platform's log output prefixed with `[wasm]`.
404pub mod log {
405    #[allow(unused_imports)]
406    use super::*;
407
408    /// Log an error message (level 0).
409    pub fn error(msg: &str) {
410        write(0, msg);
411    }
412
413    /// Log a warning message (level 1).
414    pub fn warn(msg: &str) {
415        write(1, msg);
416    }
417
418    /// Log an info message (level 2).
419    pub fn info(msg: &str) {
420        write(2, msg);
421    }
422
423    /// Log a debug message (level 3).
424    pub fn debug(msg: &str) {
425        write(3, msg);
426    }
427
428    fn write(level: i32, msg: &str) {
429        #[cfg(target_arch = "wasm32")]
430        {
431            let bytes = msg.as_bytes();
432            unsafe {
433                super::cufflink_log(level, bytes.as_ptr() as i32, bytes.len() as i32);
434            }
435        }
436        #[cfg(not(target_arch = "wasm32"))]
437        {
438            let _ = (level, msg);
439        }
440    }
441}
442
443// ─── http module ────────────────────────────────────────────────────────────
444
445/// Make HTTP requests from inside your WASM handler.
446///
447/// Use this to call external APIs (Keycloak admin, third-party services, etc.)
448/// from your handler code.
449pub mod http {
450    #[allow(unused_imports)]
451    use super::*;
452
453    /// Response from an HTTP request.
454    #[derive(Debug, Clone)]
455    pub struct FetchResponse {
456        /// HTTP status code (e.g., 200, 404, 500).
457        pub status: i32,
458        /// Response body as a string (may be base64-encoded for binary content).
459        pub body: String,
460        /// Body encoding: "utf8" for text, "base64" for binary content.
461        pub body_encoding: String,
462        /// Response headers.
463        pub headers: HashMap<String, String>,
464    }
465
466    impl FetchResponse {
467        /// Parse the response body as JSON.
468        pub fn json(&self) -> Option<Value> {
469            serde_json::from_str(&self.body).ok()
470        }
471
472        /// Check if the response status indicates success (2xx).
473        pub fn is_success(&self) -> bool {
474            (200..300).contains(&self.status)
475        }
476
477        /// Check if the body is base64-encoded (binary content).
478        pub fn is_base64(&self) -> bool {
479            self.body_encoding == "base64"
480        }
481    }
482
483    /// Make an HTTP request.
484    ///
485    /// ```rust,ignore
486    /// let resp = http::fetch("GET", "https://api.example.com/data", &[], None);
487    /// if let Some(resp) = resp {
488    ///     if resp.is_success() {
489    ///         log::info(&format!("Got: {}", resp.body));
490    ///     }
491    /// }
492    /// ```
493    pub fn fetch(
494        method: &str,
495        url: &str,
496        headers: &[(&str, &str)],
497        body: Option<&str>,
498    ) -> Option<FetchResponse> {
499        #[cfg(target_arch = "wasm32")]
500        {
501            let method_bytes = method.as_bytes();
502            let url_bytes = url.as_bytes();
503            let headers_map: HashMap<&str, &str> = headers.iter().copied().collect();
504            let headers_json = serde_json::to_string(&headers_map).unwrap_or_default();
505            let headers_bytes = headers_json.as_bytes();
506            let body_bytes = body.unwrap_or("").as_bytes();
507            let body_len = body.map(|b| b.len()).unwrap_or(0);
508
509            let result = unsafe {
510                http_fetch(
511                    method_bytes.as_ptr() as i32,
512                    method_bytes.len() as i32,
513                    url_bytes.as_ptr() as i32,
514                    url_bytes.len() as i32,
515                    headers_bytes.as_ptr() as i32,
516                    headers_bytes.len() as i32,
517                    body_bytes.as_ptr() as i32,
518                    body_len as i32,
519                )
520            };
521
522            if result < 0 {
523                return None;
524            }
525
526            read_fetch_response()
527        }
528        #[cfg(not(target_arch = "wasm32"))]
529        {
530            let _ = (method, url, headers, body);
531            None
532        }
533    }
534
535    /// Make a GET request.
536    pub fn get(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
537        fetch("GET", url, headers, None)
538    }
539
540    /// Make a POST request with a body.
541    pub fn post(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
542        fetch("POST", url, headers, Some(body))
543    }
544
545    /// Make a PUT request with a body.
546    pub fn put(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
547        fetch("PUT", url, headers, Some(body))
548    }
549
550    /// Make a DELETE request.
551    pub fn delete(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
552        fetch("DELETE", url, headers, None)
553    }
554
555    /// Read the host response buffer after http_fetch.
556    #[cfg(target_arch = "wasm32")]
557    fn read_fetch_response() -> Option<FetchResponse> {
558        let len = unsafe { get_host_response_len() };
559        if len <= 0 {
560            return None;
561        }
562        let mut buf = vec![0u8; len as usize];
563        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
564        if read <= 0 {
565            return None;
566        }
567        buf.truncate(read as usize);
568        let json_str = String::from_utf8_lossy(&buf);
569        let v: Value = serde_json::from_str(&json_str).ok()?;
570        Some(FetchResponse {
571            status: v["status"].as_i64().unwrap_or(0) as i32,
572            body: v["body"].as_str().unwrap_or("").to_string(),
573            body_encoding: v["body_encoding"].as_str().unwrap_or("utf8").to_string(),
574            headers: v["headers"]
575                .as_object()
576                .map(|m| {
577                    m.iter()
578                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
579                        .collect()
580                })
581                .unwrap_or_default(),
582        })
583    }
584}
585
586// ─── config module ──────────────────────────────────────────────────────
587
588/// Read service configuration values set via `cufflink config set`.
589///
590/// Config values are stored in the platform's `service_configs` table,
591/// scoped to your service. Use `cufflink config set KEY VALUE [--secret]`
592/// to set values via the CLI.
593pub mod config {
594    #[allow(unused_imports)]
595    use super::*;
596
597    /// Get a config value by key. Returns `None` if the key doesn't exist.
598    ///
599    /// ```rust,ignore
600    /// let api_key = config::get("ANTHROPIC_API_KEY");
601    /// if let Some(key) = api_key {
602    ///     log::info(&format!("API key loaded ({} chars)", key.len()));
603    /// }
604    /// ```
605    pub fn get(key: &str) -> Option<String> {
606        #[cfg(target_arch = "wasm32")]
607        {
608            let bytes = key.as_bytes();
609            let result = unsafe { get_config(bytes.as_ptr() as i32, bytes.len() as i32) };
610            if result < 0 {
611                return None;
612            }
613            read_config_response()
614        }
615        #[cfg(not(target_arch = "wasm32"))]
616        {
617            let _ = key;
618            None
619        }
620    }
621
622    /// Read the host response buffer after get_config.
623    #[cfg(target_arch = "wasm32")]
624    fn read_config_response() -> Option<String> {
625        let len = unsafe { get_host_response_len() };
626        if len <= 0 {
627            return None;
628        }
629        let mut buf = vec![0u8; len as usize];
630        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
631        if read <= 0 {
632            return None;
633        }
634        buf.truncate(read as usize);
635        String::from_utf8(buf).ok()
636    }
637}
638
639// ─── Handler runtime ─────────────────────────────────────────────────────────
640
641/// Internal function used by the `handler!` macro. Do not call directly.
642#[doc(hidden)]
643pub fn __run_handler<F>(ptr: i32, len: i32, f: F) -> i32
644where
645    F: FnOnce(Request) -> Response,
646{
647    // Read the request JSON from guest memory
648    let request_json = unsafe {
649        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
650        String::from_utf8_lossy(slice).into_owned()
651    };
652
653    // Parse the request
654    let request = Request::from_json(&request_json).unwrap_or_else(|| Request {
655        method: "GET".to_string(),
656        handler: String::new(),
657        headers: HashMap::new(),
658        body: Value::Null,
659        tenant: String::new(),
660        service: String::new(),
661        auth: None,
662    });
663
664    // Call the user's handler
665    let response = f(request);
666    let response_bytes = response.into_data().into_bytes();
667
668    // Write response to guest memory: [4-byte LE length][data]
669    let total = 4 + response_bytes.len();
670    let layout = std::alloc::Layout::from_size_align(total, 1).expect("invalid layout");
671    let out_ptr = unsafe { std::alloc::alloc(layout) };
672
673    unsafe {
674        let len_bytes = (response_bytes.len() as u32).to_le_bytes();
675        std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out_ptr, 4);
676        std::ptr::copy_nonoverlapping(
677            response_bytes.as_ptr(),
678            out_ptr.add(4),
679            response_bytes.len(),
680        );
681    }
682
683    out_ptr as i32
684}
685
686// ─── Macros ──────────────────────────────────────────────────────────────────
687
688/// Initialize the cufflink-fn runtime. Call this once at the top of your `lib.rs`.
689///
690/// Exports the `alloc` function that the platform needs to pass data into
691/// your WASM module.
692///
693/// ```rust,ignore
694/// use cufflink_fn::prelude::*;
695///
696/// cufflink_fn::init!();
697/// ```
698#[macro_export]
699macro_rules! init {
700    () => {
701        #[no_mangle]
702        pub extern "C" fn alloc(size: i32) -> i32 {
703            let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
704            unsafe { std::alloc::alloc(layout) as i32 }
705        }
706    };
707}
708
709/// Define a handler function.
710///
711/// This macro generates the `#[no_mangle] extern "C"` boilerplate so your
712/// handler is a plain Rust closure that receives a [`Request`] and returns
713/// a [`Response`].
714///
715/// ```rust,ignore
716/// use cufflink_fn::prelude::*;
717///
718/// cufflink_fn::init!();
719///
720/// handler!(get_stats, |req: Request| {
721///     let rows = db::query("SELECT COUNT(*) as total FROM orders");
722///     Response::json(&json!({"total": rows[0]["total"]}))
723/// });
724///
725/// handler!(create_order, |req: Request| {
726///     let body = req.body();
727///     let customer = body["customer_id"].as_str().unwrap_or("unknown");
728///     db::execute(&format!(
729///         "INSERT INTO orders (customer_id, status) VALUES ('{}', 'pending')",
730///         customer
731///     ));
732///     Response::json(&json!({"status": "created"}))
733/// });
734/// ```
735#[macro_export]
736macro_rules! handler {
737    ($name:ident, |$req:ident : Request| $body:expr) => {
738        #[no_mangle]
739        pub extern "C" fn $name(ptr: i32, len: i32) -> i32 {
740            $crate::__run_handler(ptr, len, |$req: $crate::Request| $body)
741        }
742    };
743}
744
745// ─── Prelude ─────────────────────────────────────────────────────────────────
746
747/// Import everything you need to write handlers.
748///
749/// ```rust,ignore
750/// use cufflink_fn::prelude::*;
751/// ```
752pub mod prelude {
753    pub use crate::config;
754    pub use crate::db;
755    pub use crate::http;
756    pub use crate::log;
757    pub use crate::nats;
758    pub use crate::Auth;
759    pub use crate::Request;
760    pub use crate::Response;
761    pub use serde_json::{json, Value};
762}
763
764// ─── Tests ───────────────────────────────────────────────────────────────────
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use serde_json::json;
770
771    #[test]
772    fn test_request_parsing() {
773        let json = serde_json::to_string(&json!({
774            "method": "POST",
775            "handler": "checkout",
776            "headers": {"content-type": "application/json"},
777            "body": {"item": "widget", "qty": 3},
778            "tenant": "acme",
779            "service": "shop"
780        }))
781        .unwrap();
782
783        let req = Request::from_json(&json).unwrap();
784        assert_eq!(req.method(), "POST");
785        assert_eq!(req.handler(), "checkout");
786        assert_eq!(req.tenant(), "acme");
787        assert_eq!(req.service(), "shop");
788        assert_eq!(req.body()["item"], "widget");
789        assert_eq!(req.body()["qty"], 3);
790        assert_eq!(req.header("content-type"), Some("application/json"));
791    }
792
793    #[test]
794    fn test_request_missing_fields() {
795        let json = r#"{"method": "GET"}"#;
796        let req = Request::from_json(json).unwrap();
797        assert_eq!(req.method(), "GET");
798        assert_eq!(req.handler(), "");
799        assert_eq!(req.tenant(), "");
800        assert_eq!(req.body(), &Value::Null);
801    }
802
803    #[test]
804    fn test_response_json() {
805        let resp = Response::json(&json!({"status": "ok", "count": 42}));
806        let data = resp.into_data();
807        let parsed: Value = serde_json::from_str(&data).unwrap();
808        assert_eq!(parsed["status"], "ok");
809        assert_eq!(parsed["count"], 42);
810    }
811
812    #[test]
813    fn test_response_error() {
814        let resp = Response::error("something went wrong");
815        let data = resp.into_data();
816        let parsed: Value = serde_json::from_str(&data).unwrap();
817        assert_eq!(parsed["error"], "something went wrong");
818    }
819
820    #[test]
821    fn test_response_empty() {
822        let resp = Response::empty();
823        let data = resp.into_data();
824        let parsed: Value = serde_json::from_str(&data).unwrap();
825        assert_eq!(parsed["ok"], true);
826    }
827
828    #[test]
829    fn test_response_text() {
830        let resp = Response::text("hello world");
831        let data = resp.into_data();
832        let parsed: Value = serde_json::from_str(&data).unwrap();
833        assert_eq!(parsed, "hello world");
834    }
835
836    #[test]
837    fn test_db_query_noop_on_native() {
838        // On native (non-wasm) targets, db functions are no-ops
839        let rows = db::query("SELECT 1");
840        assert!(rows.is_empty());
841    }
842
843    #[test]
844    fn test_db_query_one_noop_on_native() {
845        let row = db::query_one("SELECT 1");
846        assert!(row.is_none());
847    }
848
849    #[test]
850    fn test_db_execute_noop_on_native() {
851        let affected = db::execute("INSERT INTO x VALUES (1)");
852        assert_eq!(affected, 0);
853    }
854
855    #[test]
856    fn test_nats_publish_noop_on_native() {
857        let ok = nats::publish("test.subject", "payload");
858        assert!(ok);
859    }
860
861    #[test]
862    fn test_request_with_auth() {
863        let json = serde_json::to_string(&json!({
864            "method": "POST",
865            "handler": "checkout",
866            "headers": {},
867            "body": {},
868            "tenant": "acme",
869            "service": "shop",
870            "auth": {
871                "sub": "user-123",
872                "preferred_username": "john",
873                "name": "John Doe",
874                "email": "john@example.com",
875                "realm_roles": ["admin", "manager"],
876                "claims": {"department": "engineering"}
877            }
878        }))
879        .unwrap();
880
881        let req = Request::from_json(&json).unwrap();
882        let auth = req.auth().unwrap();
883        assert_eq!(auth.sub, "user-123");
884        assert_eq!(auth.preferred_username.as_deref(), Some("john"));
885        assert_eq!(auth.name.as_deref(), Some("John Doe"));
886        assert_eq!(auth.email.as_deref(), Some("john@example.com"));
887        assert!(auth.has_role("admin"));
888        assert!(auth.has_role("manager"));
889        assert!(!auth.has_role("viewer"));
890        assert_eq!(
891            auth.claim("department").and_then(|v| v.as_str()),
892            Some("engineering")
893        );
894    }
895
896    #[test]
897    fn test_request_without_auth() {
898        let json = r#"{"method": "GET"}"#;
899        let req = Request::from_json(json).unwrap();
900        assert!(req.auth().is_none());
901    }
902
903    #[test]
904    fn test_request_null_auth() {
905        let json = serde_json::to_string(&json!({
906            "method": "GET",
907            "auth": null
908        }))
909        .unwrap();
910        let req = Request::from_json(&json).unwrap();
911        assert!(req.auth().is_none());
912    }
913
914    #[test]
915    fn test_require_auth_success() {
916        let json = serde_json::to_string(&json!({
917            "method": "GET",
918            "auth": {"sub": "user-1", "realm_roles": [], "claims": {}}
919        }))
920        .unwrap();
921        let req = Request::from_json(&json).unwrap();
922        assert!(req.require_auth().is_ok());
923        assert_eq!(req.require_auth().unwrap().sub, "user-1");
924    }
925
926    #[test]
927    fn test_require_auth_fails_when_unauthenticated() {
928        let json = r#"{"method": "GET"}"#;
929        let req = Request::from_json(json).unwrap();
930        assert!(req.require_auth().is_err());
931    }
932
933    #[test]
934    fn test_http_fetch_noop_on_native() {
935        let resp = http::fetch("GET", "https://example.com", &[], None);
936        assert!(resp.is_none());
937    }
938
939    #[test]
940    fn test_http_get_noop_on_native() {
941        let resp = http::get("https://example.com", &[]);
942        assert!(resp.is_none());
943    }
944
945    #[test]
946    fn test_http_post_noop_on_native() {
947        let resp = http::post("https://example.com", &[], "{}");
948        assert!(resp.is_none());
949    }
950
951    #[test]
952    fn test_http_fetch_response_helpers() {
953        let resp = http::FetchResponse {
954            status: 200,
955            body: r#"{"key": "value"}"#.to_string(),
956            body_encoding: "utf8".to_string(),
957            headers: HashMap::new(),
958        };
959        assert!(resp.is_success());
960        assert!(!resp.is_base64());
961        let json = resp.json().unwrap();
962        assert_eq!(json["key"], "value");
963
964        let err_resp = http::FetchResponse {
965            status: 404,
966            body: "not found".to_string(),
967            body_encoding: "utf8".to_string(),
968            headers: HashMap::new(),
969        };
970        assert!(!err_resp.is_success());
971
972        let binary_resp = http::FetchResponse {
973            status: 200,
974            body: "aW1hZ2VkYXRh".to_string(),
975            body_encoding: "base64".to_string(),
976            headers: HashMap::new(),
977        };
978        assert!(binary_resp.is_base64());
979    }
980}