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::sync::Arc;
13use tokio::sync::Semaphore;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ScriptResult {
18 pub return_value: Option<Value>,
20 pub modified_variables: HashMap<String, Value>,
22 pub errors: Vec<String>,
24 pub execution_time_ms: u64,
26}
27
28#[derive(Debug, Clone)]
30pub struct ScriptContext {
31 pub request: Option<crate::request_chaining::ChainRequest>,
33 pub response: Option<crate::request_chaining::ChainResponse>,
35 pub chain_context: HashMap<String, Value>,
37 pub variables: HashMap<String, Value>,
39 pub env_vars: HashMap<String, String>,
41}
42
43pub struct ScriptEngine {
45 semaphore: Arc<Semaphore>,
46}
47
48impl std::fmt::Debug for ScriptEngine {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("ScriptEngine")
51 .field("semaphore", &format!("Semaphore({})", self.semaphore.available_permits()))
52 .finish()
53 }
54}
55
56impl ScriptEngine {
61 pub fn new() -> Self {
63 let semaphore = Arc::new(Semaphore::new(10)); Self { semaphore }
66 }
67
68 pub async fn execute_script(
70 &self,
71 script: &str,
72 script_context: &ScriptContext,
73 timeout_ms: u64,
74 ) -> Result<ScriptResult> {
75 let _permit =
76 self.semaphore.acquire().await.map_err(|e| {
77 Error::generic(format!("Failed to acquire execution permit: {}", e))
78 })?;
79
80 let script = script.to_string();
81 let script_context = script_context.clone();
82
83 let start_time = std::time::Instant::now();
84
85 let script_clone = script.clone();
88 let script_context_clone = script_context.clone();
89
90 let timeout_duration = std::time::Duration::from_millis(timeout_ms);
91 let timeout_result = tokio::time::timeout(
92 timeout_duration,
93 tokio::task::spawn_blocking(move || {
94 execute_script_in_runtime(&script_clone, &script_context_clone)
95 }),
96 )
97 .await;
98
99 let execution_time_ms = start_time.elapsed().as_millis() as u64;
100
101 match timeout_result {
102 Ok(join_result) => match join_result {
103 Ok(Ok(mut script_result)) => {
104 script_result.execution_time_ms = execution_time_ms;
105 Ok(script_result)
106 }
107 Ok(Err(e)) => Err(e),
108 Err(e) => Err(Error::generic(format!("Script execution task failed: {}", e))),
109 },
110 Err(_) => {
111 Err(Error::generic(format!("Script execution timed out after {}ms", timeout_ms)))
112 }
113 }
114 }
115
116 fn execute_in_context_blocking(
118 &self,
119 script: &str,
120 script_context: &ScriptContext,
121 ) -> Result<ScriptResult> {
122 let runtime = Runtime::new()?;
124 let context = Context::full(&runtime)?;
125
126 context.with(|ctx| self.execute_in_context(ctx, script, script_context, 0))
127 }
128
129 fn execute_in_context<'js>(
131 &self,
132 ctx: Ctx<'js>,
133 script: &str,
134 script_context: &ScriptContext,
135 timeout_ms: u64,
136 ) -> Result<ScriptResult> {
137 let ctx_clone = ctx.clone();
139
140 let global = ctx.globals();
142 let mockforge_obj = Object::new(ctx_clone.clone())?;
143
144 self.expose_script_context(ctx.clone(), &mockforge_obj, script_context)?;
146
147 global.set("mockforge", mockforge_obj)?;
149
150 self.add_global_functions(ctx_clone, &global, script_context)?;
152
153 let result = eval_script_with_timeout(&ctx, script, timeout_ms)?;
155
156 let modified_vars = extract_modified_variables(&ctx, script_context)?;
158 let return_value = extract_return_value(&ctx, &result)?;
159
160 Ok(ScriptResult {
161 return_value,
162 modified_variables: modified_vars,
163 errors: vec![], execution_time_ms: 0, })
166 }
167
168 fn expose_script_context<'js>(
170 &self,
171 ctx: Ctx<'js>,
172 mockforge_obj: &Object<'js>,
173 script_context: &ScriptContext,
174 ) -> Result<()> {
175 expose_script_context_static(ctx, mockforge_obj, script_context)
176 }
177
178 fn add_global_functions<'js>(
180 &self,
181 ctx: Ctx<'js>,
182 global: &Object<'js>,
183 script_context: &ScriptContext,
184 ) -> Result<()> {
185 add_global_functions_static(ctx, global, script_context)
186 }
187}
188
189fn extract_return_value<'js>(
191 ctx: &Ctx<'js>,
192 result: &rquickjs::Value<'js>,
193) -> Result<Option<Value>> {
194 extract_return_value_static(ctx, result)
195}
196
197fn execute_script_in_runtime(
200 script: &str,
201 script_context: &ScriptContext,
202) -> Result<ScriptResult> {
203 let runtime = Runtime::new()
205 .map_err(|e| Error::generic(format!("Failed to create JavaScript runtime: {:?}", e)))?;
206
207 let context = Context::full(&runtime)
208 .map_err(|e| Error::generic(format!("Failed to create JavaScript context: {:?}", e)))?;
209
210 context.with(|ctx| {
211 let global = ctx.globals();
213 let mockforge_obj = Object::new(ctx.clone())
214 .map_err(|e| Error::generic(format!("Failed to create mockforge object: {:?}", e)))?;
215
216 expose_script_context_static(ctx.clone(), &mockforge_obj, script_context)
218 .map_err(|e| Error::generic(format!("Failed to expose script context: {:?}", e)))?;
219
220 global.set("mockforge", mockforge_obj)
222 .map_err(|e| Error::generic(format!("Failed to set global mockforge object: {:?}", e)))?;
223
224 add_global_functions_static(ctx.clone(), &global, script_context)
226 .map_err(|e| Error::generic(format!("Failed to add global functions: {:?}", e)))?;
227
228 let result = ctx.eval(script)
230 .map_err(|e| Error::generic(format!("Script execution failed: {:?}", e)))?;
231
232 let modified_vars = extract_modified_variables_static(&ctx, script_context)
234 .map_err(|e| Error::generic(format!("Failed to extract modified variables: {:?}", e)))?;
235
236 let return_value = extract_return_value_static(&ctx, &result)
237 .map_err(|e| Error::generic(format!("Failed to extract return value: {:?}", e)))?;
238
239 Ok(ScriptResult {
240 return_value,
241 modified_variables: modified_vars,
242 errors: vec![], execution_time_ms: 0, })
245 })
246}
247
248fn extract_return_value_static<'js>(
250 _ctx: &Ctx<'js>,
251 result: &rquickjs::Value<'js>,
252) -> Result<Option<Value>> {
253 match result.type_of() {
254 rquickjs::Type::String => {
255 if let Some(string_val) = result.as_string() {
257 Ok(Some(Value::String(string_val.to_string()?)))
258 } else {
259 Ok(None)
260 }
261 }
262 rquickjs::Type::Float => {
263 if let Some(num) = result.as_number() {
264 if let Some(f64_val) = serde_json::Number::from_f64(num) {
267 Ok(Some(Value::Number(f64_val)))
268 } else {
269 Ok(Some(Value::Number(serde_json::Number::from(result.as_int().unwrap_or(0)))))
271 }
272 } else {
273 Ok(Some(Value::Number(serde_json::Number::from(result.as_int().unwrap_or(0)))))
275 }
276 }
277 rquickjs::Type::Bool => {
278 if let Some(bool_val) = result.as_bool() {
280 Ok(Some(Value::Bool(bool_val)))
281 } else {
282 Ok(None)
283 }
284 }
285 rquickjs::Type::Object => {
286 if let Some(obj) = result.as_object() {
288 if let Some(string_val) = obj.as_string() {
289 let json_str = string_val.to_string()?;
290 Ok(Some(Value::String(json_str)))
291 } else {
292 Ok(None)
293 }
294 } else {
295 Ok(None)
296 }
297 }
298 _ => Ok(None),
299 }
300}
301
302fn extract_modified_variables<'js>(
304 ctx: &Ctx<'js>,
305 original_context: &ScriptContext,
306) -> Result<HashMap<String, Value>> {
307 extract_modified_variables_static(ctx, original_context)
308}
309
310fn extract_modified_variables_static<'js>(
312 ctx: &Ctx<'js>,
313 original_context: &ScriptContext,
314) -> Result<HashMap<String, Value>> {
315 let mut modified = HashMap::new();
316
317 let global = ctx.globals();
319 let mockforge_obj: Object = global.get("mockforge")?;
320
321 let vars_obj: Object = mockforge_obj.get("variables")?;
323
324 let keys = vars_obj.keys::<String>();
326
327 for key_result in keys {
328 let key = key_result?;
329 let js_value: rquickjs::Value = vars_obj.get(&key)?;
330
331 if let Some(value) = js_value_to_json_value(&js_value) {
333 let original_value = original_context.variables.get(&key);
335 if original_value != Some(&value) {
336 modified.insert(key, value);
337 }
338 }
339 }
340
341 Ok(modified)
342}
343
344fn js_value_to_json_value(js_value: &rquickjs::Value) -> Option<Value> {
346 match js_value.type_of() {
347 rquickjs::Type::String => {
348 js_value.as_string().and_then(|s| s.to_string().ok()).map(Value::String)
349 }
350 rquickjs::Type::Int => {
351 js_value.as_int().map(|i| Value::Number(serde_json::Number::from(i)))
352 }
353 rquickjs::Type::Float => {
354 js_value.as_number().and_then(serde_json::Number::from_f64).map(Value::Number)
355 }
356 rquickjs::Type::Bool => js_value.as_bool().map(Value::Bool),
357 rquickjs::Type::Object | rquickjs::Type::Array => {
358 if let Some(obj) = js_value.as_object() {
360 if let Some(str_val) = obj.as_string() {
361 str_val
362 .to_string()
363 .ok()
364 .and_then(|json_str| serde_json::from_str(&json_str).ok())
365 } else {
366 None
368 }
369 } else {
370 None
371 }
372 }
373 _ => None, }
375}
376
377fn eval_script_with_timeout<'js>(
379 ctx: &Ctx<'js>,
380 script: &str,
381 _timeout_ms: u64,
382) -> Result<rquickjs::Value<'js>> {
383 ctx.eval(script)
388 .map_err(|e| Error::generic(format!("JavaScript evaluation error: {:?}", e)))
389}
390
391impl Default for ScriptEngine {
392 fn default() -> Self {
393 Self::new()
394 }
395}
396
397fn expose_script_context_static<'js>(
399 ctx: Ctx<'js>,
400 mockforge_obj: &Object<'js>,
401 script_context: &ScriptContext,
402) -> Result<()> {
403 if let Some(request) = &script_context.request {
405 let request_obj = Object::new(ctx.clone())?;
406 request_obj.set("id", &request.id)?;
407 request_obj.set("method", &request.method)?;
408 request_obj.set("url", &request.url)?;
409
410 let headers_obj = Object::new(ctx.clone())?;
412 for (key, value) in &request.headers {
413 headers_obj.set(key.as_str(), value.as_str())?;
414 }
415 request_obj.set("headers", headers_obj)?;
416
417 if let Some(body) = &request.body {
419 let body_json = serde_json::to_string(body)
420 .map_err(|e| Error::generic(format!("Failed to serialize request body: {}", e)))?;
421 request_obj.set("body", body_json)?;
422 }
423
424 mockforge_obj.set("request", request_obj)?;
425 }
426
427 if let Some(response) = &script_context.response {
429 let response_obj = Object::new(ctx.clone())?;
430 response_obj.set("status", response.status as i32)?;
431 response_obj.set("duration_ms", response.duration_ms as i32)?;
432
433 let headers_obj = Object::new(ctx.clone())?;
435 for (key, value) in &response.headers {
436 headers_obj.set(key.as_str(), value.as_str())?;
437 }
438 response_obj.set("headers", headers_obj)?;
439
440 if let Some(body) = &response.body {
442 let body_json = serde_json::to_string(body)
443 .map_err(|e| Error::generic(format!("Failed to serialize response body: {}", e)))?;
444 response_obj.set("body", body_json)?;
445 }
446
447 mockforge_obj.set("response", response_obj)?;
448 }
449
450 let chain_obj = Object::new(ctx.clone())?;
452 for (key, value) in &script_context.chain_context {
453 match value {
454 Value::String(s) => chain_obj.set(key.as_str(), s.as_str())?,
455 Value::Number(n) => {
456 if let Some(i) = n.as_i64() {
457 chain_obj.set(key.as_str(), i as i32)?;
458 } else if let Some(f) = n.as_f64() {
459 chain_obj.set(key.as_str(), f)?;
460 }
461 }
462 Value::Bool(b) => chain_obj.set(key.as_str(), *b)?,
463 Value::Object(obj) => {
464 let json_str = serde_json::to_string(&obj)
465 .map_err(|e| Error::generic(format!("Failed to serialize object: {}", e)))?;
466 chain_obj.set(key.as_str(), json_str)?;
467 }
468 Value::Array(arr) => {
469 let json_str = serde_json::to_string(&arr)
470 .map_err(|e| Error::generic(format!("Failed to serialize array: {}", e)))?;
471 chain_obj.set(key.as_str(), json_str)?;
472 }
473 _ => {} }
475 }
476 mockforge_obj.set("chain", chain_obj)?;
477
478 let vars_obj = Object::new(ctx.clone())?;
480 for (key, value) in &script_context.variables {
481 match value {
482 Value::String(s) => vars_obj.set(key.as_str(), s.as_str())?,
483 Value::Number(n) => {
484 if let Some(i) = n.as_i64() {
485 vars_obj.set(key.as_str(), i as i32)?;
486 } else if let Some(f) = n.as_f64() {
487 vars_obj.set(key.as_str(), f)?;
488 }
489 }
490 Value::Bool(b) => vars_obj.set(key.as_str(), *b)?,
491 _ => {
492 let json_str = serde_json::to_string(&value).map_err(|e| {
493 Error::generic(format!("Failed to serialize variable {}: {}", key, e))
494 })?;
495 vars_obj.set(key.as_str(), json_str)?;
496 }
497 }
498 }
499 mockforge_obj.set("variables", vars_obj)?;
500
501 let env_obj = Object::new(ctx.clone())?;
503 for (key, value) in &script_context.env_vars {
504 env_obj.set(key.as_str(), value.as_str())?;
505 }
506 mockforge_obj.set("env", env_obj)?;
507
508 Ok(())
509}
510
511fn add_global_functions_static<'js>(
513 ctx: Ctx<'js>,
514 global: &Object<'js>,
515 _script_context: &ScriptContext,
516) -> Result<()> {
517 let console_obj = Object::new(ctx.clone())?;
519 let log_func = Function::new(ctx.clone(), || {
520 println!("Script log called");
521 })?;
522 console_obj.set("log", log_func)?;
523 global.set("console", console_obj)?;
524
525 let log_func = Function::new(ctx.clone(), |msg: String| {
527 println!("Script log: {}", msg);
528 })?;
529 global.set("log", log_func)?;
530
531 let stringify_func = Function::new(ctx.clone(), |value: rquickjs::Value| {
532 if let Some(obj) = value.as_object() {
533 if let Some(str_val) = obj.as_string() {
534 str_val.to_string().unwrap_or_else(|_| "undefined".to_string())
535 } else {
536 "object".to_string()
537 }
538 } else if value.is_string() {
539 value
540 .as_string()
541 .unwrap()
542 .to_string()
543 .unwrap_or_else(|_| "undefined".to_string())
544 } else {
545 format!("{:?}", value)
546 }
547 })?;
548 global.set("stringify", stringify_func)?;
549
550 let crypto_obj = Object::new(ctx.clone())?;
552
553 let base64_encode_func = Function::new(ctx.clone(), |input: String| -> String {
554 use base64::{engine::general_purpose, Engine as _};
555 general_purpose::STANDARD.encode(input)
556 })?;
557 crypto_obj.set("base64Encode", base64_encode_func)?;
558
559 let base64_decode_func = Function::new(ctx.clone(), |input: String| -> String {
560 use base64::{engine::general_purpose, Engine as _};
561 general_purpose::STANDARD
562 .decode(input)
563 .map(|bytes| String::from_utf8_lossy(&bytes).to_string())
564 .unwrap_or_else(|_| "".to_string())
565 })?;
566 crypto_obj.set("base64Decode", base64_decode_func)?;
567
568 let sha256_func = Function::new(ctx.clone(), |input: String| -> String {
569 use sha2::{Digest, Sha256};
570 let mut hasher = Sha256::new();
571 hasher.update(input);
572 hex::encode(hasher.finalize())
573 })?;
574 crypto_obj.set("sha256", sha256_func)?;
575
576 let random_bytes_func = Function::new(ctx.clone(), |length: usize| -> String {
577 use rand::Rng;
578 let mut rng = rand::rng();
579 let bytes: Vec<u8> = (0..length).map(|_| rng.random()).collect();
580 hex::encode(bytes)
581 })?;
582 crypto_obj.set("randomBytes", random_bytes_func)?;
583
584 global.set("crypto", crypto_obj)?;
585
586 let date_obj = Object::new(ctx.clone())?;
588
589 let now_func = Function::new(ctx.clone(), || -> String { chrono::Utc::now().to_rfc3339() })?;
590 date_obj.set("now", now_func)?;
591
592 let format_func = Function::new(ctx.clone(), |timestamp: String, format: String| -> String {
593 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(×tamp) {
594 dt.format(&format).to_string()
595 } else {
596 "".to_string()
597 }
598 })?;
599 date_obj.set("format", format_func)?;
600
601 let parse_func = Function::new(ctx.clone(), |date_str: String, format: String| -> String {
602 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&date_str, &format) {
603 dt.and_utc().to_rfc3339()
604 } else {
605 "".to_string()
606 }
607 })?;
608 date_obj.set("parse", parse_func)?;
609
610 let add_days_func = Function::new(ctx.clone(), |timestamp: String, days: i64| -> String {
611 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(×tamp) {
612 (dt + chrono::Duration::days(days)).to_rfc3339()
613 } else {
614 "".to_string()
615 }
616 })?;
617 date_obj.set("addDays", add_days_func)?;
618
619 global.set("date", date_obj)?;
620
621 let validate_obj = Object::new(ctx.clone())?;
623
624 let email_func = Function::new(ctx.clone(), |email: String| -> bool {
625 regex::Regex::new(r"^[^@]+@[^@]+\.[^@]+$")
629 .map(|re| re.is_match(&email))
630 .unwrap_or_else(|_| {
631 email.contains('@') && email.contains('.') && email.len() > 5
633 })
634 })?;
635 validate_obj.set("email", email_func)?;
636
637 let url_func = Function::new(ctx.clone(), |url_str: String| -> bool {
638 url::Url::parse(&url_str).is_ok()
639 })?;
640 validate_obj.set("url", url_func)?;
641
642 let regex_func = Function::new(ctx.clone(), |pattern: String, text: String| -> bool {
643 regex::Regex::new(&pattern).map(|re| re.is_match(&text)).unwrap_or(false)
644 })?;
645 validate_obj.set("regex", regex_func)?;
646
647 global.set("validate", validate_obj)?;
648
649 let json_obj = Object::new(ctx.clone())?;
651
652 let json_parse_func = Function::new(ctx.clone(), |json_str: String| -> String {
653 match serde_json::from_str::<serde_json::Value>(&json_str) {
654 Ok(value) => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
655 Err(_) => "null".to_string(),
656 }
657 })?;
658 json_obj.set("parse", json_parse_func)?;
659
660 let json_stringify_func = Function::new(ctx.clone(), |value: String| -> String {
661 value
663 })?;
664 json_obj.set("stringify", json_stringify_func)?;
665
666 let json_validate_func = Function::new(ctx.clone(), |json_str: String| -> bool {
667 serde_json::from_str::<serde_json::Value>(&json_str).is_ok()
668 })?;
669 json_obj.set("validate", json_validate_func)?;
670
671 global.set("JSON", json_obj)?;
672
673 let http_obj = Object::new(ctx.clone())?;
675
676 let http_get_func = Function::new(ctx.clone(), |url: String| -> String {
677 tokio::task::block_in_place(|| {
682 reqwest::blocking::get(&url)
683 .and_then(|resp| resp.text())
684 .unwrap_or_else(|_| "".to_string())
685 })
686 })?;
687 http_obj.set("get", http_get_func)?;
688
689 let http_post_func = Function::new(ctx.clone(), |url: String, body: String| -> String {
690 tokio::task::block_in_place(|| {
695 reqwest::blocking::Client::new()
696 .post(&url)
697 .body(body)
698 .send()
699 .and_then(|resp| resp.text())
700 .unwrap_or_else(|_| "".to_string())
701 })
702 })?;
703 http_obj.set("post", http_post_func)?;
704
705 let url_encode_func = Function::new(ctx.clone(), |input: String| -> String {
706 urlencoding::encode(&input).to_string()
707 })?;
708 http_obj.set("urlEncode", url_encode_func)?;
709
710 let url_decode_func = Function::new(ctx.clone(), |input: String| -> String {
711 urlencoding::decode(&input)
712 .unwrap_or(std::borrow::Cow::Borrowed(""))
713 .to_string()
714 })?;
715 http_obj.set("urlDecode", url_decode_func)?;
716
717 global.set("http", http_obj)?;
718
719 Ok(())
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use serde_json::json;
726
727 #[tokio::test]
728 async fn test_script_execution() {
729 let engine = ScriptEngine::new();
730
731 let script_context = ScriptContext {
732 request: Some(crate::request_chaining::ChainRequest {
733 id: "test-request".to_string(),
734 method: "GET".to_string(),
735 url: "https://api.example.com/test".to_string(),
736 headers: [("Content-Type".to_string(), "application/json".to_string())].into(),
737 body: None,
738 depends_on: vec![],
739 timeout_secs: None,
740 expected_status: None,
741 scripting: None,
742 }),
743 response: None,
744 chain_context: {
745 let mut ctx = HashMap::new();
746 ctx.insert("login_token".to_string(), json!("abc123"));
747 ctx
748 },
749 variables: HashMap::new(),
750 env_vars: [("NODE_ENV".to_string(), "test".to_string())].into(),
751 };
752
753 let script = r#"
754 for (let i = 0; i < 1000000; i++) {
755 // Loop to ensure measurable execution time
756 }
757 "script executed successfully";
758 "#;
759
760 let result = engine.execute_script(script, &script_context, 5000).await;
761 assert!(result.is_ok(), "Script execution should succeed");
762
763 let script_result = result.unwrap();
764 assert_eq!(script_result.return_value, Some(json!("script executed successfully")));
765 assert!(script_result.execution_time_ms > 0);
766 assert!(script_result.errors.is_empty());
767 }
768
769 #[tokio::test]
770 async fn test_script_with_error() {
771 let engine = ScriptEngine::new();
772
773 let script_context = ScriptContext {
774 request: None,
775 response: None,
776 chain_context: HashMap::new(),
777 variables: HashMap::new(),
778 env_vars: HashMap::new(),
779 };
780
781 let script = r#"throw new Error("Intentional test error");"#;
782
783 let result = engine.execute_script(script, &script_context, 1000).await;
784 assert!(result.is_err() || result.is_ok()); }
788}