mechanics-core 0.1.0

mechanics automation framework (core)
Documentation
use super::{MechanicsState, buffer_like};
use crate::{
    executor::CustomModuleLoader,
    http::{
        EndpointCallBody, EndpointCallOptions, EndpointResponse, EndpointResponseBody,
        into_io_error,
    },
};
use boa_engine::{
    Context, JsArgs, JsError, JsResult, JsString, JsValue, Module, NativeFunction, js_string,
    module::SyntheticModuleInitializer,
    object::{FunctionObjectBuilder, JsObject},
};
use serde_json::Value;
use std::{collections::HashMap, rc::Rc};

mod base32;
mod base64;
mod endpoint;
mod form_urlencoded;
mod hex;
mod rand;
mod uuid;

struct CodecModuleSpec {
    module_name: &'static str,
    encode_name: &'static str,
    encode_fn: NativeFunction,
    encode_length: usize,
    decode_name: &'static str,
    decode_fn: NativeFunction,
    decode_length: usize,
}

fn register_codec_module(
    loader: &Rc<CustomModuleLoader>,
    context: &mut Context,
    spec: CodecModuleSpec,
) {
    let CodecModuleSpec {
        module_name,
        encode_name,
        encode_fn,
        encode_length,
        decode_name,
        decode_fn,
        decode_length,
    } = spec;

    let encode = FunctionObjectBuilder::new(context.realm(), encode_fn)
        .length(encode_length)
        .name(encode_name)
        .build();
    let decode = FunctionObjectBuilder::new(context.realm(), decode_fn)
        .length(decode_length)
        .name(decode_name)
        .build();

    let module = Module::synthetic(
        &[js_string!(encode_name), js_string!(decode_name)],
        SyntheticModuleInitializer::from_copy_closure_with_captures(
            move |module, funcs, _ctx| {
                module.set_export(&js_string!(encode_name), funcs.0.clone().into())?;
                module.set_export(&js_string!(decode_name), funcs.1.clone().into())
            },
            (encode, decode),
        ),
        None,
        None,
        context,
    );
    loader.define_module(js_string!(module_name), module);
}

fn parse_string_map_field(
    options: &JsObject,
    key: JsString,
    field_name: &'static str,
    context: &mut Context,
) -> JsResult<HashMap<String, String>> {
    let value = options.get(key, context)?;
    if value.is_undefined() || value.is_null() {
        return Ok(HashMap::new());
    }
    let json = value
        .to_json(context)?
        .ok_or_else(|| buffer_like::js_type_error(format!("{field_name} must be an object")))?;
    let Value::Object(_) = json else {
        return Err(buffer_like::js_type_error(format!(
            "{field_name} must be an object"
        )));
    };
    serde_json::from_value(json)
        .map_err(into_io_error)
        .map_err(JsError::from_rust)
}

fn required_string_arg(args: &[JsValue], index: usize, name: &str) -> JsResult<String> {
    args.get_or_undefined(index)
        .as_string()
        .map(|s| s.to_std_string_lossy())
        .ok_or_else(|| buffer_like::js_type_error(format!("{name} must be a string")))
}

fn required_buffer_like_arg(
    args: &[JsValue],
    index: usize,
    name: &str,
    context: &mut Context,
) -> JsResult<Vec<u8>> {
    buffer_like::try_extract_buffer_like_bytes(args.get_or_undefined(index), context)?.ok_or_else(
        || {
            buffer_like::js_type_error(format!(
                "{name} must be a TypedArray, ArrayBuffer, or DataView"
            ))
        },
    )
}

fn parse_endpoint_call_options_js(
    value: JsValue,
    context: &mut Context,
) -> JsResult<EndpointCallOptions> {
    if value.is_undefined() || value.is_null() {
        return Ok(EndpointCallOptions::default());
    }

    let Some(options) = value.as_object() else {
        return Err(buffer_like::js_type_error(
            "endpoint options must be an object or null/undefined",
        ));
    };

    let url_params =
        parse_string_map_field(&options, js_string!("urlParams"), "urlParams", context)?;
    let queries = parse_string_map_field(&options, js_string!("queries"), "queries", context)?;
    let headers = parse_string_map_field(&options, js_string!("headers"), "headers", context)?;
    let body_value = options.get(js_string!("body"), context)?;
    let body = if body_value.is_undefined() {
        EndpointCallBody::Absent
    } else if body_value.is_null() {
        EndpointCallBody::Json(Value::Null)
    } else if let Some(string) = body_value.as_string() {
        EndpointCallBody::Utf8(string.to_std_string_lossy())
    } else if let Some(bytes) = buffer_like::try_extract_buffer_like_bytes(&body_value, context)? {
        EndpointCallBody::Bytes(bytes)
    } else {
        let body_json = body_value
            .to_json(context)?
            .ok_or_else(|| buffer_like::js_type_error("body is not JSON-convertible"))?;
        EndpointCallBody::Json(body_json)
    };

    Ok(EndpointCallOptions {
        url_params,
        queries,
        headers,
        body,
    })
}

fn endpoint_response_to_js_value(
    response: EndpointResponse,
    context: &mut Context,
) -> JsResult<JsValue> {
    let body = match response.body {
        EndpointResponseBody::Json(v) => JsValue::from_json(&v, context),
        EndpointResponseBody::Utf8(s) => Ok(buffer_like::js_string_value(&s)),
        EndpointResponseBody::Bytes(bytes) => {
            buffer_like::bytes_to_uint8_array_value(&bytes, context)
        }
        EndpointResponseBody::Empty => Ok(JsValue::null()),
    }?;

    let headers_value = Value::Object(
        response
            .headers
            .into_iter()
            .map(|(k, v)| (k, Value::String(v)))
            .collect(),
    );
    let headers = JsValue::from_json(&headers_value, context)?;

    let object = JsObject::default(context.intrinsics());
    object.set(js_string!("body"), body, true, context)?;
    object.set(js_string!("headers"), headers, true, context)?;
    object.set(
        js_string!("status"),
        JsValue::new(i32::from(response.status)),
        true,
        context,
    )?;
    object.set(js_string!("ok"), JsValue::new(response.ok), true, context)?;
    Ok(object.into())
}

pub(super) fn bundle_builtin_modules(loader: &Rc<CustomModuleLoader>, context: &mut Context) {
    endpoint::register(loader, context);
    form_urlencoded::register(loader, context);
    base64::register(loader, context);
    hex::register(loader, context);
    base32::register(loader, context);
    rand::register(loader, context);
    uuid::register(loader, context);
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn endpoint_response_to_js_value_includes_status_and_ok() {
        let mut context = Context::default();
        let mut headers = HashMap::new();
        headers.insert("x-trace-id".to_owned(), "abc".to_owned());
        let response = EndpointResponse {
            body: EndpointResponseBody::Json(json!({"n": 1})),
            headers,
            status: 202,
            ok: false,
        };

        let value =
            endpoint_response_to_js_value(response, &mut context).expect("convert response");
        let as_json = value
            .to_json(&mut context)
            .expect("json conversion should succeed")
            .expect("converted response should be JSON object");
        assert_eq!(as_json["status"], json!(202));
        assert_eq!(as_json["ok"], json!(false));
        assert_eq!(as_json["body"]["n"], json!(1));
        assert_eq!(as_json["headers"]["x-trace-id"], json!("abc"));
    }
}