openzeppelin_relayer/models/
plugin.rs

1use std::{collections::HashMap, time::Duration};
2
3use serde::{Deserialize, Deserializer, Serialize};
4use serde_json::Map;
5use utoipa::ToSchema;
6
7/// Custom deserializer for `Option<Option<T>>` that distinguishes between:
8/// - JSON field absent → `None` (no change)
9/// - JSON field is `null` → `Some(None)` (clear the value)
10/// - JSON field has a value → `Some(Some(value))` (update)
11fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
12where
13    D: Deserializer<'de>,
14    T: Deserialize<'de>,
15{
16    Ok(Some(Option::deserialize(deserializer)?))
17}
18
19use crate::constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS;
20
21#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
22pub struct PluginModel {
23    /// Plugin ID
24    pub id: String,
25    /// Plugin path
26    pub path: String,
27    /// Plugin timeout
28    #[schema(value_type = u64)]
29    pub timeout: Duration,
30    /// Whether to include logs in the HTTP response
31    #[serde(default)]
32    pub emit_logs: bool,
33    /// Whether to include traces in the HTTP response
34    #[serde(default)]
35    pub emit_traces: bool,
36    /// Whether to return raw plugin response without ApiResponse wrapper
37    #[serde(default)]
38    pub raw_response: bool,
39    /// Whether to allow GET requests to invoke plugin logic
40    #[serde(default)]
41    pub allow_get_invocation: bool,
42    /// User-defined configuration accessible to the plugin (must be a JSON object)
43    #[serde(default)]
44    pub config: Option<Map<String, serde_json::Value>>,
45    /// Whether to forward plugin logs into the relayer's tracing output
46    #[serde(default)]
47    pub forward_logs: bool,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
51pub struct PluginCallRequest {
52    /// Plugin parameters. If not provided, the entire request body will be used as params.
53    #[serde(default)]
54    pub params: serde_json::Value,
55    /// HTTP headers from the incoming request (injected by the route handler)
56    #[serde(default, skip_deserializing)]
57    pub headers: Option<HashMap<String, Vec<String>>>,
58    /// Wildcard route from the endpoint (e.g., "" for /call, "/verify" for /call/verify)
59    #[serde(default, skip_deserializing)]
60    pub route: Option<String>,
61    /// HTTP method used for the request (e.g., "GET" or "POST")
62    #[serde(default, skip_deserializing)]
63    pub method: Option<String>,
64    /// Query parameters from the request URL
65    #[serde(default, skip_deserializing)]
66    pub query: Option<HashMap<String, Vec<String>>>,
67}
68
69/// Request model for updating an existing plugin.
70/// All fields are optional to allow partial updates.
71/// Note: `id` and `path` are not updateable after creation.
72#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)]
73#[serde(deny_unknown_fields)]
74pub struct UpdatePluginRequest {
75    /// Plugin timeout in seconds
76    #[schema(value_type = Option<u64>)]
77    pub timeout: Option<u64>,
78    /// Whether to include logs in the HTTP response
79    pub emit_logs: Option<bool>,
80    /// Whether to include traces in the HTTP response
81    pub emit_traces: Option<bool>,
82    /// Whether to return raw plugin response without ApiResponse wrapper
83    pub raw_response: Option<bool>,
84    /// Whether to allow GET requests to invoke plugin logic
85    pub allow_get_invocation: Option<bool>,
86    /// User-defined configuration accessible to the plugin (must be a JSON object)
87    /// Use `null` to clear the config
88    #[serde(
89        default,
90        skip_serializing_if = "Option::is_none",
91        deserialize_with = "deserialize_double_option"
92    )]
93    pub config: Option<Option<Map<String, serde_json::Value>>>,
94    /// Whether to forward plugin logs into the relayer's tracing output
95    pub forward_logs: Option<bool>,
96}
97
98/// Validation errors for plugin updates
99#[derive(Debug, thiserror::Error)]
100pub enum PluginValidationError {
101    #[error("Invalid timeout: {0}")]
102    InvalidTimeout(String),
103}
104
105impl PluginModel {
106    /// Apply an update request to this plugin model.
107    /// Returns the updated plugin model or a validation error.
108    pub fn apply_update(&self, update: UpdatePluginRequest) -> Result<Self, PluginValidationError> {
109        let mut updated = self.clone();
110
111        if let Some(timeout_secs) = update.timeout {
112            if timeout_secs == 0 {
113                return Err(PluginValidationError::InvalidTimeout(
114                    "Timeout must be greater than 0".to_string(),
115                ));
116            }
117            updated.timeout = Duration::from_secs(timeout_secs);
118        }
119
120        if let Some(emit_logs) = update.emit_logs {
121            updated.emit_logs = emit_logs;
122        }
123
124        if let Some(emit_traces) = update.emit_traces {
125            updated.emit_traces = emit_traces;
126        }
127
128        if let Some(raw_response) = update.raw_response {
129            updated.raw_response = raw_response;
130        }
131
132        if let Some(allow_get_invocation) = update.allow_get_invocation {
133            updated.allow_get_invocation = allow_get_invocation;
134        }
135
136        // config uses Option<Option<...>> to distinguish between:
137        // - None: field not provided, don't change
138        // - Some(None): explicitly set to null, clear the config
139        // - Some(Some(value)): update to new value
140        if let Some(config) = update.config {
141            updated.config = config;
142        }
143
144        if let Some(forward_logs) = update.forward_logs {
145            updated.forward_logs = forward_logs;
146        }
147
148        Ok(updated)
149    }
150}
151
152impl Default for PluginModel {
153    fn default() -> Self {
154        Self {
155            id: String::new(),
156            path: String::new(),
157            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
158            emit_logs: false,
159            emit_traces: false,
160            raw_response: false,
161            allow_get_invocation: false,
162            config: None,
163            forward_logs: false,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn create_test_plugin() -> PluginModel {
173        PluginModel {
174            id: "test-plugin".to_string(),
175            path: "plugins/test.ts".to_string(),
176            timeout: Duration::from_secs(30),
177            emit_logs: false,
178            emit_traces: false,
179            raw_response: false,
180            allow_get_invocation: false,
181            config: None,
182            forward_logs: false,
183        }
184    }
185
186    #[test]
187    fn test_apply_update_timeout() {
188        let plugin = create_test_plugin();
189        let update = UpdatePluginRequest {
190            timeout: Some(60),
191            ..Default::default()
192        };
193
194        let updated = plugin.apply_update(update).unwrap();
195        assert_eq!(updated.timeout, Duration::from_secs(60));
196        // Other fields unchanged
197        assert!(!updated.emit_logs);
198    }
199
200    #[test]
201    fn test_apply_update_timeout_zero_fails() {
202        let plugin = create_test_plugin();
203        let update = UpdatePluginRequest {
204            timeout: Some(0),
205            ..Default::default()
206        };
207
208        let result = plugin.apply_update(update);
209        assert!(result.is_err());
210    }
211
212    #[test]
213    fn test_apply_update_all_fields() {
214        let plugin = create_test_plugin();
215        let mut config_map = Map::new();
216        config_map.insert("key".to_string(), serde_json::json!("value"));
217
218        let update = UpdatePluginRequest {
219            timeout: Some(120),
220            emit_logs: Some(true),
221            emit_traces: Some(true),
222            raw_response: Some(true),
223            allow_get_invocation: Some(true),
224            config: Some(Some(config_map.clone())),
225            forward_logs: Some(true),
226        };
227
228        let updated = plugin.apply_update(update).unwrap();
229        assert_eq!(updated.timeout, Duration::from_secs(120));
230        assert!(updated.emit_logs);
231        assert!(updated.emit_traces);
232        assert!(updated.raw_response);
233        assert!(updated.allow_get_invocation);
234        assert_eq!(updated.config, Some(config_map));
235        assert!(updated.forward_logs);
236    }
237
238    #[test]
239    fn test_apply_update_clear_config() {
240        let mut plugin = create_test_plugin();
241        let mut config_map = Map::new();
242        config_map.insert("key".to_string(), serde_json::json!("value"));
243        plugin.config = Some(config_map);
244
245        // Clear config by setting to null
246        let update = UpdatePluginRequest {
247            config: Some(None),
248            ..Default::default()
249        };
250
251        let updated = plugin.apply_update(update).unwrap();
252        assert!(updated.config.is_none());
253    }
254
255    #[test]
256    fn test_apply_update_no_changes() {
257        let plugin = create_test_plugin();
258        let update = UpdatePluginRequest::default();
259
260        let updated = plugin.apply_update(update).unwrap();
261        assert_eq!(updated.id, plugin.id);
262        assert_eq!(updated.path, plugin.path);
263        assert_eq!(updated.timeout, plugin.timeout);
264    }
265
266    // Serde tri-state deserialization tests for the double-option config field
267
268    #[test]
269    fn test_deserialize_config_absent() {
270        let req: UpdatePluginRequest = serde_json::from_str("{}").unwrap();
271        assert_eq!(req.config, None, "Absent field should deserialize to None");
272    }
273
274    #[test]
275    fn test_deserialize_config_null() {
276        let req: UpdatePluginRequest = serde_json::from_str(r#"{"config":null}"#).unwrap();
277        assert_eq!(
278            req.config,
279            Some(None),
280            "Null field should deserialize to Some(None)"
281        );
282    }
283
284    #[test]
285    fn test_deserialize_config_object() {
286        let req: UpdatePluginRequest =
287            serde_json::from_str(r#"{"config":{"suite":"integration"}}"#).unwrap();
288        assert!(
289            matches!(req.config, Some(Some(_))),
290            "Object field should deserialize to Some(Some(...))"
291        );
292        let map = req.config.unwrap().unwrap();
293        assert_eq!(map.get("suite"), Some(&serde_json::json!("integration")));
294    }
295}