orbis_plugin_api/sdk/
context.rs

1//! Request context passed to plugin handlers.
2
3use super::error::{Error, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Context passed to plugin handlers.
8///
9/// Contains all information about the incoming request.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Context {
12    /// HTTP method (GET, POST, etc.)
13    pub method: String,
14
15    /// Request path
16    pub path: String,
17
18    /// Path parameters (extracted from route pattern)
19    #[serde(default)]
20    pub params: HashMap<String, String>,
21
22    /// Request headers
23    #[serde(default)]
24    pub headers: HashMap<String, String>,
25
26    /// Query string parameters
27    #[serde(default)]
28    pub query: HashMap<String, String>,
29
30    /// Request body (parsed as JSON)
31    #[serde(default)]
32    pub body: serde_json::Value,
33
34    /// Authenticated user ID (if any)
35    #[serde(default)]
36    pub user_id: Option<String>,
37
38    /// Whether the user is an admin
39    #[serde(default)]
40    pub is_admin: bool,
41
42    /// Request ID for tracing
43    #[serde(default)]
44    pub request_id: Option<String>,
45}
46
47impl Context {
48    /// Parse context from raw FFI pointer
49    ///
50    /// # Safety
51    /// This function reads from raw pointers passed from the host
52    #[cfg(target_arch = "wasm32")]
53    pub fn from_raw(ptr: i32, len: i32) -> Result<Self> {
54        let bytes = unsafe {
55            std::slice::from_raw_parts(ptr as *const u8, len as usize)
56        };
57        serde_json::from_slice(bytes).map_err(Error::from)
58    }
59
60    /// Parse context from raw FFI pointer (non-WASM stub)
61    #[cfg(not(target_arch = "wasm32"))]
62    pub fn from_raw(_ptr: i32, _len: i32) -> Result<Self> {
63        Err(Error::internal("from_raw only available in WASM"))
64    }
65
66    /// Get a path parameter by name
67    #[inline]
68    pub fn param(&self, name: &str) -> Option<&str> {
69        self.params.get(name).map(String::as_str)
70    }
71
72    /// Get a required path parameter, or return an error
73    #[inline]
74    pub fn param_required(&self, name: &str) -> Result<&str> {
75        self.params
76            .get(name)
77            .map(String::as_str)
78            .ok_or_else(|| Error::invalid_input(format!("Missing path parameter: {}", name)))
79    }
80
81    /// Get a query parameter by name
82    #[inline]
83    pub fn query_param(&self, name: &str) -> Option<&str> {
84        self.query.get(name).map(String::as_str)
85    }
86
87    /// Get a query parameter with a default value
88    #[inline]
89    pub fn query_param_or<'a>(&'a self, name: &str, default: &'a str) -> &'a str {
90        self.query.get(name).map(String::as_str).unwrap_or(default)
91    }
92
93    /// Get a query parameter parsed as a specific type
94    pub fn query_param_as<T: std::str::FromStr>(&self, name: &str) -> Option<T> {
95        self.query.get(name).and_then(|v| v.parse().ok())
96    }
97
98    /// Get a header by name (case-insensitive)
99    #[inline]
100    pub fn header(&self, name: &str) -> Option<&str> {
101        let name_lower = name.to_lowercase();
102        self.headers
103            .iter()
104            .find(|(k, _)| k.to_lowercase() == name_lower)
105            .map(|(_, v)| v.as_str())
106    }
107
108    /// Parse the request body as a specific type
109    #[inline]
110    pub fn body_as<T: for<'de> Deserialize<'de>>(&self) -> Result<T> {
111        serde_json::from_value(self.body.clone()).map_err(Error::from)
112    }
113
114    /// Get a field from the body
115    #[inline]
116    pub fn body_field(&self, name: &str) -> Option<&serde_json::Value> {
117        self.body.get(name)
118    }
119
120    /// Get a field from the body as a specific type
121    pub fn body_field_as<T: for<'de> Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
122        match self.body.get(name) {
123            Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(Error::from),
124            None => Ok(None),
125        }
126    }
127
128    /// Check if the request is authenticated
129    #[inline]
130    pub const fn is_authenticated(&self) -> bool {
131        self.user_id.is_some()
132    }
133
134    /// Require authentication, return error if not authenticated
135    #[inline]
136    pub fn require_auth(&self) -> Result<&str> {
137        self.user_id
138            .as_deref()
139            .ok_or_else(|| Error::permission_denied("Authentication required"))
140    }
141
142    /// Require admin access
143    #[inline]
144    pub fn require_admin(&self) -> Result<()> {
145        if self.is_admin {
146            Ok(())
147        } else {
148            Err(Error::permission_denied("Admin access required"))
149        }
150    }
151
152    /// Check if the request method matches
153    #[inline]
154    pub fn is_method(&self, method: &str) -> bool {
155        self.method.eq_ignore_ascii_case(method)
156    }
157
158    /// Get pagination parameters from query string
159    ///
160    /// Returns (page, per_page) with defaults of (1, 20)
161    pub fn pagination(&self) -> (u32, u32) {
162        let page = self.query_param_as("page").unwrap_or(1).max(1);
163        let per_page = self.query_param_as("per_page").unwrap_or(20).clamp(1, 100);
164        (page, per_page)
165    }
166
167    /// Get offset/limit for database queries from pagination
168    ///
169    /// Returns (offset, limit)
170    pub fn pagination_offset(&self) -> (u32, u32) {
171        let (page, per_page) = self.pagination();
172        let offset = (page - 1) * per_page;
173        (offset, per_page)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_context_parsing() {
183        let json = r#"{
184            "method": "GET",
185            "path": "/users/123",
186            "params": {"id": "123"},
187            "query": {"page": "2", "per_page": "10"},
188            "headers": {"Content-Type": "application/json"},
189            "body": {"name": "Test"},
190            "user_id": "user123",
191            "is_admin": false
192        }"#;
193
194        let ctx: Context = serde_json::from_str(json).unwrap();
195
196        assert_eq!(ctx.method, "GET");
197        assert_eq!(ctx.path, "/users/123");
198        assert_eq!(ctx.param("id"), Some("123"));
199        assert_eq!(ctx.query_param("page"), Some("2"));
200        assert_eq!(ctx.header("content-type"), Some("application/json"));
201        assert!(ctx.is_authenticated());
202        assert!(!ctx.is_admin);
203    }
204
205    #[test]
206    fn test_pagination() {
207        let ctx = Context {
208            method: "GET".into(),
209            path: "/".into(),
210            params: HashMap::new(),
211            headers: HashMap::new(),
212            query: [("page".into(), "3".into()), ("per_page".into(), "50".into())].into(),
213            body: serde_json::Value::Null,
214            user_id: None,
215            is_admin: false,
216            request_id: None,
217        };
218
219        assert_eq!(ctx.pagination(), (3, 50));
220        assert_eq!(ctx.pagination_offset(), (100, 50));
221    }
222}