1use crate::{Error, Result};
7use rquickjs::{Context, Ctx, Function, Object, Runtime};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::rc::Rc;
13use std::sync::Arc;
14use tokio::sync::Semaphore;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ScriptResult {
19 pub return_value: Option<Value>,
21 pub modified_variables: HashMap<String, Value>,
23 pub errors: Vec<String>,
25 pub execution_time_ms: u64,
27}
28
29#[derive(Debug, Clone)]
31pub struct ScriptContext {
32 pub request: Option<crate::request_chaining::ChainRequest>,
34 pub response: Option<crate::request_chaining::ChainResponse>,
36 pub chain_context: HashMap<String, Value>,
38 pub variables: HashMap<String, Value>,
40 pub env_vars: HashMap<String, String>,
42}
43
44pub struct ScriptEngine {
46 _runtime: Rc<Runtime>,
47 semaphore: Arc<Semaphore>,
48}
49
50#[allow(dead_code)]
51impl ScriptEngine {
52 pub fn new() -> Self {
54 let runtime = Rc::new(Runtime::new().expect("Failed to create JavaScript runtime"));
55 let semaphore = Arc::new(Semaphore::new(10)); Self {
58 _runtime: runtime,
59 semaphore,
60 }
61 }
62
63 pub async fn execute_script(
65 &self,
66 script: &str,
67 script_context: &ScriptContext,
68 timeout_ms: u64,
69 ) -> Result<ScriptResult> {
70 let _permit =
71 self.semaphore.acquire().await.map_err(|e| {
72 Error::generic(format!("Failed to acquire execution permit: {}", e))
73 })?;
74
75 let script = script.to_string();
76 let script_context = script_context.clone();
77
78 let start_time = std::time::Instant::now();
79
80 let timeout_duration = std::time::Duration::from_millis(timeout_ms);
82 let timeout_result = tokio::time::timeout(
83 timeout_duration,
84 tokio::task::spawn_blocking(move || {
85 let runtime = Runtime::new().expect("Failed to create JavaScript runtime");
86 let context = Context::full(&runtime).expect("Failed to create JavaScript context");
87
88 context.with(|ctx| {
89 let global = ctx.globals();
91 let mockforge_obj = Object::new(ctx.clone()).expect("Failed to create object");
92
93 expose_script_context_static(ctx.clone(), &mockforge_obj, &script_context)
95 .expect("Failed to expose context");
96
97 global.set("mockforge", mockforge_obj).expect("Failed to set global");
99
100 add_global_functions_static(ctx.clone(), &global, &script_context)
102 .expect("Failed to add functions");
103
104 let result = ctx.eval(script.as_str()).expect("Script execution failed");
106
107 let modified_vars = extract_modified_variables_static(&ctx, &script_context)
109 .expect("Failed to extract variables");
110 let return_value = extract_return_value_static(&ctx, &result)
111 .expect("Failed to extract return value");
112
113 ScriptResult {
114 return_value,
115 modified_variables: modified_vars,
116 errors: vec![], execution_time_ms: 0, }
119 })
120 }),
121 )
122 .await;
123
124 let execution_time_ms = start_time.elapsed().as_millis() as u64;
125
126 match timeout_result {
127 Ok(join_result) => match join_result {
128 Ok(mut script_result) => {
129 script_result.execution_time_ms = execution_time_ms;
130 Ok(script_result)
131 }
132 Err(e) => Err(Error::generic(format!("Script execution failed: {}", e))),
133 },
134 Err(_) => {
135 Err(Error::generic(format!("Script execution timed out after {}ms", timeout_ms)))
136 }
137 }
138 }
139
140 fn execute_in_context_blocking(
142 &self,
143 script: &str,
144 script_context: &ScriptContext,
145 ) -> Result<ScriptResult> {
146 let runtime = &*self._runtime;
147 let context = Context::full(runtime)?;
148
149 context.with(|ctx| self.execute_in_context(ctx, script, script_context, 0))
150 }
151
152 fn execute_in_context<'js>(
154 &self,
155 ctx: Ctx<'js>,
156 script: &str,
157 script_context: &ScriptContext,
158 timeout_ms: u64,
159 ) -> Result<ScriptResult> {
160 let ctx_clone = ctx.clone();
162
163 let global = ctx.globals();
165 let mockforge_obj = Object::new(ctx_clone.clone())?;
166
167 self.expose_script_context(ctx.clone(), &mockforge_obj, script_context)?;
169
170 global.set("mockforge", mockforge_obj)?;
172
173 self.add_global_functions(ctx_clone, &global, script_context)?;
175
176 let result = eval_script_with_timeout(&ctx, script, timeout_ms)?;
178
179 let modified_vars = extract_modified_variables(&ctx, script_context)?;
181 let return_value = extract_return_value(&ctx, &result)?;
182
183 Ok(ScriptResult {
184 return_value,
185 modified_variables: modified_vars,
186 errors: vec![], execution_time_ms: 0, })
189 }
190
191 fn expose_script_context<'js>(
193 &self,
194 ctx: Ctx<'js>,
195 mockforge_obj: &Object<'js>,
196 script_context: &ScriptContext,
197 ) -> Result<()> {
198 expose_script_context_static(ctx, mockforge_obj, script_context)
199 }
200
201 fn add_global_functions<'js>(
203 &self,
204 ctx: Ctx<'js>,
205 global: &Object<'js>,
206 script_context: &ScriptContext,
207 ) -> Result<()> {
208 add_global_functions_static(ctx, global, script_context)
209 }
210}
211
212#[allow(dead_code)]
213fn extract_return_value<'js>(
215 ctx: &Ctx<'js>,
216 result: &rquickjs::Value<'js>,
217) -> Result<Option<Value>> {
218 extract_return_value_static(ctx, result)
219}
220
221fn extract_return_value_static<'js>(
223 _ctx: &Ctx<'js>,
224 result: &rquickjs::Value<'js>,
225) -> Result<Option<Value>> {
226 match result.type_of() {
227 rquickjs::Type::String => Ok(Some(Value::String(result.as_string().unwrap().to_string()?))),
228 rquickjs::Type::Float => {
229 if let Some(num) = result.as_number() {
230 Ok(Some(Value::Number(serde_json::Number::from_f64(num).unwrap())))
231 } else {
232 Ok(Some(Value::Number(serde_json::Number::from(result.as_int().unwrap_or(0)))))
233 }
234 }
235 rquickjs::Type::Bool => Ok(Some(Value::Bool(result.as_bool().unwrap()))),
236 rquickjs::Type::Object => {
237 if let Some(obj) = result.as_object() {
239 if let Some(string_val) = obj.as_string() {
240 let json_str = string_val.to_string()?;
241 Ok(Some(Value::String(json_str)))
242 } else {
243 Ok(None)
244 }
245 } else {
246 Ok(None)
247 }
248 }
249 _ => Ok(None),
250 }
251}
252
253#[allow(dead_code)]
254fn extract_modified_variables<'js>(
256 ctx: &Ctx<'js>,
257 original_context: &ScriptContext,
258) -> Result<HashMap<String, Value>> {
259 extract_modified_variables_static(ctx, original_context)
260}
261
262fn extract_modified_variables_static<'js>(
264 ctx: &Ctx<'js>,
265 original_context: &ScriptContext,
266) -> Result<HashMap<String, Value>> {
267 let mut modified = HashMap::new();
268
269 let global = ctx.globals();
271 let mockforge_obj: Object = global.get("mockforge")?;
272
273 let vars_obj: Object = mockforge_obj.get("variables")?;
275
276 let keys = vars_obj.keys::<String>();
278
279 for key_result in keys {
280 let key = key_result?;
281 let js_value: rquickjs::Value = vars_obj.get(&key)?;
282
283 if let Some(value) = js_value_to_json_value(&js_value) {
285 let original_value = original_context.variables.get(&key);
287 if original_value != Some(&value) {
288 modified.insert(key, value);
289 }
290 }
291 }
292
293 Ok(modified)
294}
295
296fn js_value_to_json_value(js_value: &rquickjs::Value) -> Option<Value> {
298 match js_value.type_of() {
299 rquickjs::Type::String => {
300 js_value.as_string().and_then(|s| s.to_string().ok()).map(Value::String)
301 }
302 rquickjs::Type::Int => {
303 js_value.as_int().map(|i| Value::Number(serde_json::Number::from(i)))
304 }
305 rquickjs::Type::Float => {
306 js_value.as_number().and_then(serde_json::Number::from_f64).map(Value::Number)
307 }
308 rquickjs::Type::Bool => js_value.as_bool().map(Value::Bool),
309 rquickjs::Type::Object | rquickjs::Type::Array => {
310 if let Some(obj) = js_value.as_object() {
312 if let Some(str_val) = obj.as_string() {
313 str_val
314 .to_string()
315 .ok()
316 .and_then(|json_str| serde_json::from_str(&json_str).ok())
317 } else {
318 None
320 }
321 } else {
322 None
323 }
324 }
325 _ => None, }
327}
328
329#[allow(dead_code)]
330fn eval_script_with_timeout<'js>(
332 ctx: &Ctx<'js>,
333 script: &str,
334 _timeout_ms: u64,
335) -> Result<rquickjs::Value<'js>> {
336 ctx.eval(script)
341 .map_err(|e| Error::generic(format!("JavaScript evaluation error: {:?}", e)))
342}
343
344impl Default for ScriptEngine {
345 fn default() -> Self {
346 Self::new()
347 }
348}
349
350fn expose_script_context_static<'js>(
352 ctx: Ctx<'js>,
353 mockforge_obj: &Object<'js>,
354 script_context: &ScriptContext,
355) -> Result<()> {
356 if let Some(request) = &script_context.request {
358 let request_obj = Object::new(ctx.clone())?;
359 request_obj.set("id", &request.id)?;
360 request_obj.set("method", &request.method)?;
361 request_obj.set("url", &request.url)?;
362
363 let headers_obj = Object::new(ctx.clone())?;
365 for (key, value) in &request.headers {
366 headers_obj.set(key.as_str(), value.as_str())?;
367 }
368 request_obj.set("headers", headers_obj)?;
369
370 if let Some(body) = &request.body {
372 let body_json = serde_json::to_string(body)
373 .map_err(|e| Error::generic(format!("Failed to serialize request body: {}", e)))?;
374 request_obj.set("body", body_json)?;
375 }
376
377 mockforge_obj.set("request", request_obj)?;
378 }
379
380 if let Some(response) = &script_context.response {
382 let response_obj = Object::new(ctx.clone())?;
383 response_obj.set("status", response.status as i32)?;
384 response_obj.set("duration_ms", response.duration_ms as i32)?;
385
386 let headers_obj = Object::new(ctx.clone())?;
388 for (key, value) in &response.headers {
389 headers_obj.set(key.as_str(), value.as_str())?;
390 }
391 response_obj.set("headers", headers_obj)?;
392
393 if let Some(body) = &response.body {
395 let body_json = serde_json::to_string(body)
396 .map_err(|e| Error::generic(format!("Failed to serialize response body: {}", e)))?;
397 response_obj.set("body", body_json)?;
398 }
399
400 mockforge_obj.set("response", response_obj)?;
401 }
402
403 let chain_obj = Object::new(ctx.clone())?;
405 for (key, value) in &script_context.chain_context {
406 match value {
407 Value::String(s) => chain_obj.set(key.as_str(), s.as_str())?,
408 Value::Number(n) => {
409 if let Some(i) = n.as_i64() {
410 chain_obj.set(key.as_str(), i as i32)?;
411 } else if let Some(f) = n.as_f64() {
412 chain_obj.set(key.as_str(), f)?;
413 }
414 }
415 Value::Bool(b) => chain_obj.set(key.as_str(), *b)?,
416 Value::Object(obj) => {
417 let json_str = serde_json::to_string(&obj)
418 .map_err(|e| Error::generic(format!("Failed to serialize object: {}", e)))?;
419 chain_obj.set(key.as_str(), json_str)?;
420 }
421 Value::Array(arr) => {
422 let json_str = serde_json::to_string(&arr)
423 .map_err(|e| Error::generic(format!("Failed to serialize array: {}", e)))?;
424 chain_obj.set(key.as_str(), json_str)?;
425 }
426 _ => {} }
428 }
429 mockforge_obj.set("chain", chain_obj)?;
430
431 let vars_obj = Object::new(ctx.clone())?;
433 for (key, value) in &script_context.variables {
434 match value {
435 Value::String(s) => vars_obj.set(key.as_str(), s.as_str())?,
436 Value::Number(n) => {
437 if let Some(i) = n.as_i64() {
438 vars_obj.set(key.as_str(), i as i32)?;
439 } else if let Some(f) = n.as_f64() {
440 vars_obj.set(key.as_str(), f)?;
441 }
442 }
443 Value::Bool(b) => vars_obj.set(key.as_str(), *b)?,
444 _ => {
445 let json_str = serde_json::to_string(&value).map_err(|e| {
446 Error::generic(format!("Failed to serialize variable {}: {}", key, e))
447 })?;
448 vars_obj.set(key.as_str(), json_str)?;
449 }
450 }
451 }
452 mockforge_obj.set("variables", vars_obj)?;
453
454 let env_obj = Object::new(ctx.clone())?;
456 for (key, value) in &script_context.env_vars {
457 env_obj.set(key.as_str(), value.as_str())?;
458 }
459 mockforge_obj.set("env", env_obj)?;
460
461 Ok(())
462}
463
464fn add_global_functions_static<'js>(
466 ctx: Ctx<'js>,
467 global: &Object<'js>,
468 _script_context: &ScriptContext,
469) -> Result<()> {
470 let console_obj = Object::new(ctx.clone())?;
472 let log_func = Function::new(ctx.clone(), || {
473 println!("Script log called");
474 })?;
475 console_obj.set("log", log_func)?;
476 global.set("console", console_obj)?;
477
478 let log_func = Function::new(ctx.clone(), |msg: String| {
480 println!("Script log: {}", msg);
481 })?;
482 global.set("log", log_func)?;
483
484 let stringify_func = Function::new(ctx.clone(), |value: rquickjs::Value| {
485 if let Some(obj) = value.as_object() {
486 if let Some(str_val) = obj.as_string() {
487 str_val.to_string().unwrap_or_else(|_| "undefined".to_string())
488 } else {
489 "object".to_string()
490 }
491 } else if value.is_string() {
492 value
493 .as_string()
494 .unwrap()
495 .to_string()
496 .unwrap_or_else(|_| "undefined".to_string())
497 } else {
498 format!("{:?}", value)
499 }
500 })?;
501 global.set("stringify", stringify_func)?;
502
503 let crypto_obj = Object::new(ctx.clone())?;
505
506 let base64_encode_func = Function::new(ctx.clone(), |input: String| -> String {
507 use base64::{engine::general_purpose, Engine as _};
508 general_purpose::STANDARD.encode(input)
509 })?;
510 crypto_obj.set("base64Encode", base64_encode_func)?;
511
512 let base64_decode_func = Function::new(ctx.clone(), |input: String| -> String {
513 use base64::{engine::general_purpose, Engine as _};
514 general_purpose::STANDARD
515 .decode(input)
516 .map(|bytes| String::from_utf8_lossy(&bytes).to_string())
517 .unwrap_or_else(|_| "".to_string())
518 })?;
519 crypto_obj.set("base64Decode", base64_decode_func)?;
520
521 let sha256_func = Function::new(ctx.clone(), |input: String| -> String {
522 use sha2::{Digest, Sha256};
523 let mut hasher = Sha256::new();
524 hasher.update(input);
525 hex::encode(hasher.finalize())
526 })?;
527 crypto_obj.set("sha256", sha256_func)?;
528
529 let random_bytes_func = Function::new(ctx.clone(), |length: usize| -> String {
530 use rand::Rng;
531 let mut rng = rand::rng();
532 let bytes: Vec<u8> = (0..length).map(|_| rng.random()).collect();
533 hex::encode(bytes)
534 })?;
535 crypto_obj.set("randomBytes", random_bytes_func)?;
536
537 global.set("crypto", crypto_obj)?;
538
539 let date_obj = Object::new(ctx.clone())?;
541
542 let now_func = Function::new(ctx.clone(), || -> String { chrono::Utc::now().to_rfc3339() })?;
543 date_obj.set("now", now_func)?;
544
545 let format_func = Function::new(ctx.clone(), |timestamp: String, format: String| -> String {
546 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(×tamp) {
547 dt.format(&format).to_string()
548 } else {
549 "".to_string()
550 }
551 })?;
552 date_obj.set("format", format_func)?;
553
554 let parse_func = Function::new(ctx.clone(), |date_str: String, format: String| -> String {
555 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&date_str, &format) {
556 dt.and_utc().to_rfc3339()
557 } else {
558 "".to_string()
559 }
560 })?;
561 date_obj.set("parse", parse_func)?;
562
563 let add_days_func = Function::new(ctx.clone(), |timestamp: String, days: i64| -> String {
564 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(×tamp) {
565 (dt + chrono::Duration::days(days)).to_rfc3339()
566 } else {
567 "".to_string()
568 }
569 })?;
570 date_obj.set("addDays", add_days_func)?;
571
572 global.set("date", date_obj)?;
573
574 let validate_obj = Object::new(ctx.clone())?;
576
577 let email_func = Function::new(ctx.clone(), |email: String| -> bool {
578 let email_regex = regex::Regex::new(r"^[^@]+@[^@]+\.[^@]+$").unwrap();
580 email_regex.is_match(&email)
581 })?;
582 validate_obj.set("email", email_func)?;
583
584 let url_func = Function::new(ctx.clone(), |url_str: String| -> bool {
585 url::Url::parse(&url_str).is_ok()
586 })?;
587 validate_obj.set("url", url_func)?;
588
589 let regex_func = Function::new(ctx.clone(), |pattern: String, text: String| -> bool {
590 regex::Regex::new(&pattern).map(|re| re.is_match(&text)).unwrap_or(false)
591 })?;
592 validate_obj.set("regex", regex_func)?;
593
594 global.set("validate", validate_obj)?;
595
596 let json_obj = Object::new(ctx.clone())?;
598
599 let json_parse_func = Function::new(ctx.clone(), |json_str: String| -> String {
600 match serde_json::from_str::<serde_json::Value>(&json_str) {
601 Ok(value) => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
602 Err(_) => "null".to_string(),
603 }
604 })?;
605 json_obj.set("parse", json_parse_func)?;
606
607 let json_stringify_func = Function::new(ctx.clone(), |value: String| -> String {
608 value
610 })?;
611 json_obj.set("stringify", json_stringify_func)?;
612
613 let json_validate_func = Function::new(ctx.clone(), |json_str: String| -> bool {
614 serde_json::from_str::<serde_json::Value>(&json_str).is_ok()
615 })?;
616 json_obj.set("validate", json_validate_func)?;
617
618 global.set("JSON", json_obj)?;
619
620 let http_obj = Object::new(ctx.clone())?;
622
623 let http_get_func = Function::new(ctx.clone(), |url: String| -> String {
624 tokio::task::block_in_place(|| {
629 reqwest::blocking::get(&url)
630 .and_then(|resp| resp.text())
631 .unwrap_or_else(|_| "".to_string())
632 })
633 })?;
634 http_obj.set("get", http_get_func)?;
635
636 let http_post_func = Function::new(ctx.clone(), |url: String, body: String| -> String {
637 tokio::task::block_in_place(|| {
642 reqwest::blocking::Client::new()
643 .post(&url)
644 .body(body)
645 .send()
646 .and_then(|resp| resp.text())
647 .unwrap_or_else(|_| "".to_string())
648 })
649 })?;
650 http_obj.set("post", http_post_func)?;
651
652 let url_encode_func = Function::new(ctx.clone(), |input: String| -> String {
653 urlencoding::encode(&input).to_string()
654 })?;
655 http_obj.set("urlEncode", url_encode_func)?;
656
657 let url_decode_func = Function::new(ctx.clone(), |input: String| -> String {
658 urlencoding::decode(&input)
659 .unwrap_or(std::borrow::Cow::Borrowed(""))
660 .to_string()
661 })?;
662 http_obj.set("urlDecode", url_decode_func)?;
663
664 global.set("http", http_obj)?;
665
666 Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use serde_json::json;
673
674 #[tokio::test]
675 async fn test_script_execution() {
676 let engine = ScriptEngine::new();
677
678 let script_context = ScriptContext {
679 request: Some(crate::request_chaining::ChainRequest {
680 id: "test-request".to_string(),
681 method: "GET".to_string(),
682 url: "https://api.example.com/test".to_string(),
683 headers: [("Content-Type".to_string(), "application/json".to_string())].into(),
684 body: None,
685 depends_on: vec![],
686 timeout_secs: None,
687 expected_status: None,
688 scripting: None,
689 }),
690 response: None,
691 chain_context: {
692 let mut ctx = HashMap::new();
693 ctx.insert("login_token".to_string(), json!("abc123"));
694 ctx
695 },
696 variables: HashMap::new(),
697 env_vars: [("NODE_ENV".to_string(), "test".to_string())].into(),
698 };
699
700 let script = r#"
701 for (let i = 0; i < 1000000; i++) {
702 // Loop to ensure measurable execution time
703 }
704 "script executed successfully";
705 "#;
706
707 let result = engine.execute_script(script, &script_context, 5000).await;
708 assert!(result.is_ok(), "Script execution should succeed");
709
710 let script_result = result.unwrap();
711 assert_eq!(script_result.return_value, Some(json!("script executed successfully")));
712 assert!(script_result.execution_time_ms > 0);
713 assert!(script_result.errors.is_empty());
714 }
715
716 #[tokio::test]
717 async fn test_script_with_error() {
718 let engine = ScriptEngine::new();
719
720 let script_context = ScriptContext {
721 request: None,
722 response: None,
723 chain_context: HashMap::new(),
724 variables: HashMap::new(),
725 env_vars: HashMap::new(),
726 };
727
728 let script = r#"throw new Error("Intentional test error");"#;
729
730 let result = engine.execute_script(script, &script_context, 1000).await;
731 assert!(result.is_err() || result.is_ok()); }
735}