An open API service indexing awesome lists of open source software.

https://github.com/rhaiscript/rhai-loco

Rhai integration for Loco.
https://github.com/rhaiscript/rhai-loco

loco rhai scripting-engine scripting-language

Last synced: 8 months ago
JSON representation

Rhai integration for Loco.

Awesome Lists containing this project

README

          

Rhai Engine Integration for Loco
================================

![GitHub last commit](https://img.shields.io/github/last-commit/rhaiscript/rhai-loco?logo=github)
[![Stars](https://img.shields.io/github/stars/rhaiscript/rhai-loco?style=flat&logo=github)](https://github.com/rhaiscript/rhai-loco)
[![License](https://img.shields.io/crates/l/rhai-loco)](https://github.com/license/rhaiscript/rhai-loco)
[![crates.io](https://img.shields.io/crates/v/rhai-loco?logo=rust)](https://crates.io/crates/rhai-loco/)
[![crates.io](https://img.shields.io/crates/d/rhai-loco?logo=rust)](https://crates.io/crates/rhai-loco/)
[![API Docs](https://docs.rs/rhai-loco/badge.svg?logo=docs-rs)](https://docs.rs/rhai-loco/)

This crate adds [Rhai](https://rhai.rs) script support to [Loco](https://loco.rs).

Why Include a Scripting Engine
------------------------------

Although a system based upon [Loco](https://loco.rs) is usually compiled for maximum performance, there are times where user requirements are dynamic and need to be adapted to, preferably without recompilation.

Scripts are tremendously useful in the following cases:

* Complex custom configuration or custom business logic per installation at different sites without recompilation. In a different programming language, DLL's or dynamically-linked libraries may be used.

* Rapidly adapt to changing environments (e.g. handle new data formats, input changes, or novel user errors etc.) without hard-coding the rules (which may soon change again).

* Trial testing new features or business logic with fast iteration (without recompilation). The final version, once stable, can be converted into native Rust code for performance.

* Develop [Tera](https://crates.io/crates/tera) filters in script so they can be iterated quickly. Useful ones can then be converted into Rust native filters. This can normally be achieved via [Tera](https://crates.io/crates/tera) macros, but the Rhai scripting language is more powerful and expressive than [Tera](https://crates.io/crates/tera) expressions, allowing more complex logic to be implemented.

Usage
-----

Import `rhai-loco` inside `Cargo.toml`:

```toml
[dependencies]
rhai-loco = "0.15.0"
```

Configuration
-------------

The Loco `config` section of `initializers` can be used to set options for the Rhai engine.

```yaml
# Initializers configuration
initializers:
# Scripting engine configuration
scripting:
# Directory holding scripts
scripts_path: assets/scripts
# Directory holding Tera filter scripts
filters_path: assets/scripts/tera/filters
```

Enable Scripted Tera Filters
----------------------------

Modify the `ViewEngineInitializer` under `src/initializers/view_engine.rs`:

```rust
┌─────────────────────────────────┐
│ src/initializers/view_engine.rs │
└─────────────────────────────────┘

// Within this method...
async fn after_routes(&self, router: AxumRouter, ctx: &AppContext) -> Result {
let mut tera_engine = engines::TeraView::build()?;

:
:

///////////////////////////////////////////////////////////////////////////////////
// Add the following to enable scripted Tera filters

// Get scripting engine configuration object
let config = ctx.config.initializers.as_ref()
.and_then(|m| m.get(rhai_loco::ScriptingEngineInitializer::NAME))
.cloned().unwrap_or_default();

let config: rhai_loco::ScriptingEngineInitializerConfig = serde_json::from_value(config)?;

if config.filters_path.is_dir() {
// This code is duplicated from the original code
// to expose the i18n `t` function to Rhai scripts
let i18n = if Path::new(I18N_DIR).is_dir() {
let arc = ArcLoader::builder(I18N_DIR, unic_langid::langid!("de-DE"))
.shared_resources(Some(&[I18N_SHARED.into()]))
.customize(|bundle| bundle.set_use_isolating(false))
.build()
.map_err(|e| Error::string(&e.to_string()))?;
Some(FluentLoader::new(arc))
} else {
None
};
rhai_loco::RhaiScript::register_tera_filters(
&mut tera_engine,
config.filters_path,
|_engine| {}, // custom configuration of the Rhai Engine, if any
i18n,
)?;
info!("Filter scripts loaded");
}

// End addition
///////////////////////////////////////////////////////////////////////////////////

Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
}
```

Each Rhai script file (extension `.rhai`) can contain multiple filters. Sub-directories are ignored.

Each function inside the Rhai script file constitutes one filter, unless marked as `private`.
The name of the function is the name of the filter.

### Function Signature

Each filter function must take exactly _one_ parameter, which is an object-map containing all the
variables in the filter call.

In addition, variables in the filter call can also be accessed as stand-alone variables.

The original data value is mapped to `this`.

### Example

For a filter call:

```tera
┌───────────────┐
│ Tera template │
└───────────────┘

{{ "hello" | super_duper(a = "world", b = 42, c = true) }}
```

The filter function `super_duper` can be defined as follows in a Rhai script file:

```js
┌─────────────┐
│ Rhai script │
└─────────────┘

// This private function is ignored
private fn do_something(x) {
...
}

// This function has the wrong number of parameters and is ignored
fn do_other_things(x, y, z) {
...
}

// Filter 'super_duper'
fn super_duper(vars) {
// 'this' maps to "hello"
// 'vars' contains 'a', 'b' and 'c'
// The stand-alone variables 'a', 'b' and 'c' can also be accessed

let name = if vars.b > 0 { // access 'b' under 'vars'
...
} else if c { // access 'c'
...
} else !a.is_empty() { // access 'a'
...
} else {
...
}

// 'this' can be modified
this[0].to_upper();

// Return new value
`${this}, ${name}!`
}
```

### Scripted filters as conversion/formatting tool

Scripted filters can be very flexible for ad-hoc conversion/formatting purposes because they enable
rapid iterations and changes without recompiling.

```rust
┌────────────────────┐
│ Rhai filter script │
└────────────────────┘

/// Say we have in-house status codes that we need to convert into text
/// for display with i18n support...
fn status(vars) {
switch this {
case "P" => t("Pending", lang),
case "A" => t("Active", lang),
case "C" => t("Cancelled", lang),
case "X" => t("Deleted", lang),
}
}

/// Use script to inject HTML also!
/// The input value is used to select from the list of options
fn all_status(vars) {`
t("Pending", lang)
t("Active", lang)
t("Cancelled", lang)
t("Deleted", lang)
`}

/// Say we have CSS classes that we need to add based on certain data values
fn count_css(vars) {
if this.count > 1 {
"error more-than-one"
} else if this.count == 0 {
"error missing-value"
} else {
"success"
}
}
```

```html
┌───────────────┐
│ Tera template │
└───────────────┘



{{ value.status | status(lang="de-DE") }} : {{ value.count }}

t("All", "de-DE")

{{ "A" | all_status(lang="de-DE") | safe }}

```

The above is equivalent to the following Tera template.

Technically speaking, you either maintain such ad-hoc behavior in script or inside the Tera template
itself, but doing so in script allows for reuse and a cleaner template.

```html
┌───────────────┐
│ Tera template │
└───────────────┘


{% if value.status == "P" %}
t(key = "Pending", lang = "de-DE")
{% elif value.status == "A" %}
t(key = "Active", lang = "de-DE")
{% elif value.status == "C" %}
t(key = "Cancelled", lang = "de-DE")
{% elif value.status == "D" %}
t(key = "Deleted", lang = "de-DE")
{% endif %}
: {{ value.count }}


```

Run a Rhai script in Loco Request
---------------------------------

The scripting engine is first injected into Loco via the `ScriptingEngineInitializer`:

```rust
┌────────────┐
│ src/app.rs │
└────────────┘

async fn initializers(_ctx: &AppContext) -> Result>> {
Ok(vec![
// Add the scripting engine initializer
Box::new(rhai_loco::ScriptingEngineInitializer),
Box::new(initializers::view_engine::ViewEngineInitializer),
])
}
```

The scripting engine can then be extracted in requests using `ScriptingEngine`.

For example, the following adds custom scripting support to the login authentication process:

```rust
┌─────────────────────────┐
│ src/controllers/auth.rs │
└─────────────────────────┘

// Import the scripting engine types
use rhai_loco::{RhaiScript, ScriptingEngine};

pub async fn login(
State(ctx): State,
// Extract the scripting engine
ScriptingEngine(script): ScriptingEngine,
Json(mut params): Json,
) -> Result> {
// Use `run_script_if_exists` to run a function `login` from a script
// `on_login.rhai` if it exists under `assets/scripts/`.
//
// Use `run_script` if the script is required to exist or an error is returned.
let result = script
.run_script_if_exists("on_login", &mut params, "login", ())
// ^ script file ^ function name
// ^ data mapped to `this` in script
// ^^ function arguments
.or_else(|err| script.convert_runtime_error(err, |msg| unauthorized(&msg)))?;
// ^^^^^^^^^^^^^^^^^^^^^^^^
// turn any runtime error into an unauthorized response

:
:
}
```

This calls a function named `login` within the script file `on_login.rhai` if it exists:

```rust
┌──────────────────────────────┐
│ assets/scripts/on_login.rhai │
└──────────────────────────────┘

// Function for custom login logic
fn login() {
// Can import other Rhai modules!
import "super/secure/vault" as vault;

debug(`Trying to login with user = ${this.user} and password = ${this.password}`);

let security_context = vault.extensive_checking(this.user, this.password);

if security_context.passed {
// Data values can be changed!
this.user = security_context.masked_user;
this.password = security_context.masked_password;
return security_context.id;
} else {
vault::black_list(this.user);
throw `The user ${this.user} has been black-listed!`;
}
}
```

Custom Engine Setup
-------------------

In order to customize the Rhai scripting engine, for example to add custom functions or custom types
support, it is easy to perform custom setup on the Rhai engine via `ScriptingEngineInitializerWithSetup`:

```rust
┌────────────┐
│ src/app.rs │
└────────────┘

async fn initializers(_ctx: &AppContext) -> Result>> {
Ok(vec![
// Add the scripting engine initializer
Box::new(rhai_loco::ScriptingEngineInitializerWithSetup::new_with_setup(|engine| {
:
// ... do custom setup of Rhai engine here ...
:
})),
Box::new(initializers::view_engine::ViewEngineInitializer),
])
}
```