use super::super::convert::throw;
use super::super::host::Registry;
use super::super::tsgen::declare;
use super::core::HostState;
use crate::engine::mock_server::{
self, BridgedRequest, MockRequest, MockResponse, MockServerInner, PathMatcher, Responder,
};
use indexmap::IndexMap;
use rquickjs::class::Trace;
use rquickjs::function::Opt;
use rquickjs::{
Class, Ctx as JsCtx, Function, IntoJs, JsLifetime, Object, Persistent, Result as JsResult,
Value,
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
#[allow(dead_code)] #[derive(ringo_flow_macros::TsInterface)]
struct MockResponseSpec {
status: Option<u16>,
body: Option<String>,
#[jsdoc(rename = "contentType")]
content_type: Option<String>,
headers: Option<IndexMap<String, String>>,
}
declare!(
r#"/** A static response, or a closure invoked per request (runs on the scenario
* thread, pumped from `until`, so it may close over scenario state). */
type MockResponder = MockResponseSpec | ((req: MockRequestInfo) => MockResponseSpec);"#
);
#[derive(rquickjs::IntoJs, ringo_flow_macros::TsInterface)]
struct MockRequestInfo {
method: String,
path: String,
query: HashMap<String, String>,
headers: HashMap<String, String>,
body: String,
}
#[derive(Trace, JsLifetime, Clone)]
#[rquickjs::class(rename = "MockServer")]
pub struct MockServer {
#[qjs(skip_trace)]
pub inner: Arc<MockServerInner>,
#[qjs(skip_trace)]
pub registry: Arc<Registry>,
}
#[ringo_flow_macros::ts_export]
#[rquickjs::methods]
impl MockServer {
#[qjs(constructor)]
fn new<'js>(
cx: JsCtx<'js>,
#[jsdoc(type = "{ port?: number }")] opts: Opt<Object<'js>>,
) -> JsResult<MockServer> {
let (eng, reg) = {
let h = cx
.userdata::<HostState>()
.expect("host state stored at install");
(h.eng.clone(), h.reg.clone())
};
let port = match &opts.0 {
Some(o) => {
super::super::bindings::reject_unknown_keys("MockServer", o, &["port"])
.map_err(|e| throw(&cx, &e))?;
o.get::<_, Option<u16>>("port").ok().flatten()
}
None => None,
};
let inner = mock_server::start(&eng, port).map_err(|e| throw(&cx, &e))?;
eng.register_mock(inner.clone());
Ok(MockServer {
inner,
registry: reg,
})
}
#[qjs(get)]
fn url(&self) -> String {
self.inner.url()
}
#[qjs(get)]
fn port(&self) -> i64 {
self.inner.port() as i64
}
#[jsdoc(
sig = "respond(method: string, path: string | PathMatch, response: MockResponder): void"
)]
#[jsdoc(sig = "respond(path: string | PathMatch, response: MockResponder): void")]
fn respond<'js>(
&self,
ctx: JsCtx<'js>,
a: Value<'js>,
b: Value<'js>,
c: Opt<Value<'js>>,
) -> JsResult<()> {
let (method, path_val, resp) = match c.0 {
Some(resp) => {
let method = a
.as_string()
.and_then(|s| s.to_string().ok())
.ok_or_else(|| {
throw(
&ctx,
"respond(method, path, response): method must be a string",
)
})?;
(Some(method), b, resp)
}
None => (None, a, b),
};
let matcher = path_matcher(&ctx, &path_val)?;
let responder = if let Some(f) = resp.as_function() {
let (tx, rx) = mpsc::channel::<BridgedRequest>(16);
let saved = Persistent::save(&ctx, f.clone());
self.registry.add_bridged(rx, saved);
Responder::Bridged(tx)
} else if let Some(obj) = resp.as_object() {
Responder::Static(mock_response(obj))
} else {
return Err(throw(
&ctx,
"respond: response must be an object or a function",
));
};
self.inner.set_route(method, matcher, responder);
Ok(())
}
#[qjs(rename = "requestCount")]
fn request_count<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "string | PathMatch")] path: Value<'js>,
) -> JsResult<i64> {
Ok(self.inner.request_count(&path_matcher(&ctx, &path)?))
}
#[qjs(rename = "lastRequest")]
#[jsdoc(type = "MockRequestInfo | undefined")]
fn last_request<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "string | PathMatch")] path: Value<'js>,
) -> JsResult<Value<'js>> {
match self.inner.last_request(&path_matcher(&ctx, &path)?) {
Some(req) => request_object(&ctx, &req),
None => Ok(Value::new_undefined(ctx.clone())),
}
}
#[jsdoc(type = "MockRequestInfo[]")]
fn requests<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "string | PathMatch")] path: Value<'js>,
) -> JsResult<Vec<Value<'js>>> {
self.inner
.requests(&path_matcher(&ctx, &path)?)
.iter()
.map(|req| request_object(&ctx, req))
.collect()
}
fn stop(&self) {
self.inner.shutdown();
}
}
#[derive(Trace, JsLifetime, Clone)]
#[rquickjs::class(rename = "PathMatch")]
pub struct PathMatch {
#[qjs(skip_trace)]
pub inner: PathMatcher,
}
declare!(
r#"/** A regex path matcher built with `regex(...)`, for the mock server's path args. */
interface PathMatch { readonly __pathMatch?: never; }"#
);
#[ringo_flow_macros::ts_global(name = "regex")]
pub(in crate::script::js) fn regex_global<'js>(
cx: JsCtx<'js>,
pattern: String,
) -> rquickjs::Result<Class<'js, PathMatch>> {
let inner = mock_server::PathMatcher::regex(&pattern).map_err(|e| throw(&cx, &e))?;
Class::instance(cx, PathMatch { inner })
}
#[ringo_flow_macros::ts_global(name = "jsonResponse")]
#[jsdoc(type = "MockResponseSpec")]
pub(in crate::script::js) fn json_response_global<'js>(
cx: JsCtx<'js>,
body: Value<'js>,
status: Opt<i64>,
) -> rquickjs::Result<Object<'js>> {
let json = cx
.json_stringify(body)?
.and_then(|s| s.to_string().ok())
.unwrap_or_else(|| "null".to_string());
let o = Object::new(cx.clone())?;
o.set("status", status.0.unwrap_or(200))?;
o.set("body", json)?;
o.set("contentType", "application/json")?;
Ok(o)
}
#[ringo_flow_macros::ts_global(name = "textResponse")]
#[jsdoc(type = "MockResponseSpec")]
pub(in crate::script::js) fn text_response_global<'js>(
cx: JsCtx<'js>,
body: String,
status: Opt<i64>,
) -> rquickjs::Result<Object<'js>> {
let o = Object::new(cx.clone())?;
o.set("status", status.0.unwrap_or(200))?;
o.set("body", body)?;
o.set("contentType", "text/plain")?;
Ok(o)
}
fn path_matcher<'js>(ctx: &JsCtx<'js>, v: &Value<'js>) -> JsResult<PathMatcher> {
if let Some(s) = v.as_string() {
return Ok(PathMatcher::Exact(s.to_string()?));
}
if let Some(obj) = v.as_object() {
if let Some(pm) = Class::<PathMatch>::from_object(obj) {
return Ok(pm.borrow().inner.clone());
}
}
Err(throw(ctx, "path must be a string or a regex(...) matcher"))
}
fn request_object<'js>(ctx: &JsCtx<'js>, req: &MockRequest) -> JsResult<Value<'js>> {
MockRequestInfo {
method: req.method.clone(),
path: req.path.clone(),
query: req.query.clone(),
headers: req.headers.clone(),
body: req.body.clone(),
}
.into_js(ctx)
}
fn mock_response(obj: &Object<'_>) -> MockResponse {
let headers = obj
.get::<_, Option<Object>>("headers")
.ok()
.flatten()
.map(|h| {
h.props::<String, String>()
.filter_map(|r| r.ok())
.collect::<Vec<_>>()
})
.unwrap_or_default();
MockResponse {
status: obj
.get::<_, Option<u16>>("status")
.ok()
.flatten()
.unwrap_or(200),
content_type: obj.get::<_, Option<String>>("contentType").ok().flatten(),
body: obj
.get::<_, Option<String>>("body")
.ok()
.flatten()
.unwrap_or_default(),
headers,
}
}
pub(in crate::script::js) fn pump_bridged<'js>(ctx: &JsCtx<'js>, registry: &Registry) {
let mut bridged = registry.bridged.lock().unwrap();
for (rx, closure) in bridged.iter_mut() {
while let Ok((req, resp_tx)) = rx.try_recv() {
let _ = resp_tx.send(call_bridged_responder(ctx, closure, req));
}
}
}
fn call_bridged_responder(
ctx: &JsCtx<'_>,
closure: &Persistent<Function<'static>>,
req: MockRequest,
) -> MockResponse {
let built = (|| -> JsResult<Option<MockResponse>> {
let f: Function = closure.clone().restore(ctx)?;
let obj = request_object(ctx, &req)?;
let ret: Value = f.call((obj,))?;
Ok(ret.as_object().map(mock_response))
})();
match built {
Ok(Some(resp)) => resp,
other => {
if other.is_err() {
let _ = ctx.catch();
}
MockResponse {
status: 500,
content_type: None,
headers: Vec::new(),
body: String::new(),
}
}
}
}