openzeppelin_relayer/models/
plugin.rs

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