openzeppelin_relayer/services/plugins/
script_executor.rs1use serde::{Deserialize, Serialize};
7use std::process::Stdio;
8use tokio::process::Command;
9use utoipa::ToSchema;
10
11use super::PluginError;
12
13#[derive(Serialize, Deserialize, Debug, PartialEq, ToSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum LogLevel {
16 Log,
17 Info,
18 Error,
19 Warn,
20 Debug,
21 Result,
22}
23
24#[derive(Serialize, Deserialize, Debug, ToSchema)]
25pub struct LogEntry {
26 pub level: LogLevel,
27 pub message: String,
28}
29
30#[derive(Serialize, Deserialize, Debug, ToSchema)]
31pub struct ScriptResult {
32 pub logs: Vec<LogEntry>,
33 pub error: String,
34 pub trace: Vec<serde_json::Value>,
35 pub return_value: String,
36}
37
38pub struct ScriptExecutor;
39
40impl ScriptExecutor {
41 pub async fn execute_typescript(
42 script_path: String,
43 socket_path: String,
44 script_params: String,
45 ) -> Result<ScriptResult, PluginError> {
46 if Command::new("ts-node")
47 .arg("--version")
48 .output()
49 .await
50 .is_err()
51 {
52 return Err(PluginError::SocketError(
53 "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
54 ));
55 }
56
57 let output = Command::new("ts-node")
58 .arg(script_path)
59 .arg(socket_path)
60 .arg(script_params)
61 .stdin(Stdio::null())
62 .stdout(Stdio::piped())
63 .stderr(Stdio::piped())
64 .output()
65 .await
66 .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {}", e)))?;
67
68 let stdout = String::from_utf8_lossy(&output.stdout);
69 let stderr = String::from_utf8_lossy(&output.stderr);
70
71 let (logs, return_value) =
72 Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
73
74 Ok(ScriptResult {
75 logs,
76 return_value,
77 error: stderr.to_string(),
78 trace: Vec::new(),
79 })
80 }
81
82 fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
83 let mut result = Vec::new();
84 let mut return_value = String::new();
85
86 for log in logs {
87 let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
88 PluginError::PluginExecutionError(format!("Failed to parse log: {}", e))
89 })?;
90
91 if log.level == LogLevel::Result {
92 return_value = log.message;
93 } else {
94 result.push(log);
95 }
96 }
97
98 Ok((result, return_value))
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use std::fs;
105
106 use tempfile::tempdir;
107
108 use super::*;
109
110 static TS_CONFIG: &str = r#"
111 {
112 "compilerOptions": {
113 "target": "es2016",
114 "module": "commonjs",
115 "esModuleInterop": true,
116 "forceConsistentCasingInFileNames": true,
117 "strict": true,
118 "skipLibCheck": true
119 }
120 }
121"#;
122
123 #[tokio::test]
124 async fn test_execute_typescript() {
125 let temp_dir = tempdir().unwrap();
126 let ts_config = temp_dir.path().join("tsconfig.json");
127 let script_path = temp_dir.path().join("test_execute_typescript.ts");
128 let socket_path = temp_dir.path().join("test_execute_typescript.sock");
129
130 let content = r#"
131 console.log(JSON.stringify({ level: 'log', message: 'test' }));
132 console.log(JSON.stringify({ level: 'info', message: 'test-info' }));
133 console.log(JSON.stringify({ level: 'result', message: 'test-result' }));
134 "#;
135 fs::write(script_path.clone(), content).unwrap();
136 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
137
138 let result = ScriptExecutor::execute_typescript(
139 script_path.display().to_string(),
140 socket_path.display().to_string(),
141 "{}".to_string(),
142 )
143 .await;
144
145 assert!(result.is_ok());
146 let result = result.unwrap();
147 assert_eq!(result.logs[0].level, LogLevel::Log);
148 assert_eq!(result.logs[0].message, "test");
149 assert_eq!(result.logs[1].level, LogLevel::Info);
150 assert_eq!(result.logs[1].message, "test-info");
151 assert_eq!(result.return_value, "test-result");
152 }
153
154 #[tokio::test]
155 async fn test_execute_typescript_error() {
156 let temp_dir = tempdir().unwrap();
157 let ts_config = temp_dir.path().join("tsconfig.json");
158 let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
159 let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
160
161 let content = "console.logger('test');";
162 fs::write(script_path.clone(), content).unwrap();
163 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
164
165 let result = ScriptExecutor::execute_typescript(
166 script_path.display().to_string(),
167 socket_path.display().to_string(),
168 "{}".to_string(),
169 )
170 .await;
171
172 assert!(result.is_ok());
173
174 let result = result.unwrap();
175 assert!(result.error.contains("logger"));
176 }
177
178 #[tokio::test]
179 async fn test_parse_logs_error() {
180 let temp_dir = tempdir().unwrap();
181 let ts_config = temp_dir.path().join("tsconfig.json");
182 let script_path = temp_dir.path().join("test_execute_typescript.ts");
183 let socket_path = temp_dir.path().join("test_execute_typescript.sock");
184
185 let invalid_content = r#"
186 console.log({ level: 'log', message: 'test' });
187 console.log({ level: 'info', message: 'test-info' });
188 console.log({ level: 'result', message: 'test-result' });
189 "#;
190 fs::write(script_path.clone(), invalid_content).unwrap();
191 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
192
193 let result = ScriptExecutor::execute_typescript(
194 script_path.display().to_string(),
195 socket_path.display().to_string(),
196 "{}".to_string(),
197 )
198 .await;
199
200 assert!(result.is_err());
201 assert!(result
202 .err()
203 .unwrap()
204 .to_string()
205 .contains("Failed to parse log"));
206 }
207}