20 releases (6 stable)
1.3.0 | Oct 24, 2024 |
---|---|
1.2.0 | May 21, 2024 |
1.1.0 | Feb 22, 2024 |
1.0.0-rc1 | Nov 15, 2023 |
0.1.0 | Nov 30, 2022 |
#87 in Rust patterns
7,957 downloads per month
Used in 11 crates
(10 directly)
340KB
522 lines
Extism Rust PDK
This library can be used to write Extism Plug-ins in Rust.
Install
Generate a lib
project with Cargo:
cargo new --lib my-plugin
Add the library from crates.io.
cargo add extism-pdk
Change your Cargo.toml
to set the crate-type to cdylib
(this instructs the compiler to produce a dynamic library, which for our target will be a Wasm binary):
[lib]
crate_type = ["cdylib"]
Rustup and wasm32-unknown-unknown installation
Our example below will use the wasm32-unknown-unknown
target. If this is not installed you will need to do so before this example will build. The easiest way to do this is use rustup
.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Once rustup
is installed, add the wasm32-unknown-unknown
target:
rustup target add wasm32-unknown-unknown
Getting Started
The goal of writing an Extism plug-in is to compile your Rust code to a Wasm module with exported functions that the host application can invoke. The first thing you should understand is creating an export. Let's write a simple program that exports a greet
function which will take a name as a string and return a greeting string. For this, we use the #[plugin_fn]
macro on our exported function:
use extism_pdk::*;
#[plugin_fn]
pub fn greet(name: String) -> FnResult<String> {
Ok(format!("Hello, {}!", name))
}
Since we don't need any system access for this, we can compile this to the lightweight wasm32-unknown-unknown
target instead of using the wasm32-wasi
target:
cargo build --target wasm32-unknown-unknown
Note: You can also put a default target in
.cargo/config.toml
:
[build]
target = "wasm32-unknown-unknown"
This will put your compiled wasm in target/wasm32-unknown-unknown/debug
.
We can now test it using the Extism CLI's run
command:
extism call target/wasm32-unknown-unknown/debug/my_plugin.wasm greet --input "Benjamin"
# => Hello, Benjamin!
Note: We also have a web-based, plug-in tester called the Extism Playground
More About Exports
Adding the plugin_fn macro to your function does a couple things. It exposes your function as an export and it handles some of the lower level ABI details that allow you to declare your Wasm function as if it were a normal Rust function. Here are a few examples of exports you can define.
Primitive Types
A common thing you may want to do is pass some primitive Rust data back and forth. The plugin_fn macro can map these types for you:
Note: The plugin_fn macro uses the convert crate to automatically convert and pass types across the guest / host boundary.
// f32 and f64
#[plugin_fn]
pub fn add_pi(input: f32) -> FnResult<f64> {
Ok(input as f64 + 3.14f64)
}
// i32, i64, u32, u64
#[plugin_fn]
pub fn sum_42(input: i32) -> FnResult<i64> {
Ok(input as i64 + 42i64)
}
// u8 vec
#[plugin_fn]
pub fn process_bytes(input: Vec<u8>) -> FnResult<Vec<u8>> {
// process bytes here
Ok(input)
}
// Strings
#[plugin_fn]
pub fn process_string(input: String) -> FnResult<String> {
// process string here
Ok(input)
}
Json
We provide a Json type that allows you to pass structs that implement serde::Deserialize as parameters and serde::Serialize as returns:
#[derive(serde::Deserialize)]
struct Add {
a: u32,
b: u32,
}
#[derive(serde::Serialize)]
struct Sum {
sum: u32,
}
#[plugin_fn]
pub fn add(Json(add): Json<Add>) -> FnResult<Json<Sum>> {
let sum = Sum { sum: add.a + add.b };
Ok(Json(sum))
}
The same thing can be accomplished using the extism-convert
derive macros:
#[derive(serde::Deserialize, FromBytes)]
#[encoding(Json)]
struct Add {
a: u32,
b: u32,
}
#[derive(serde::Serialize, ToBytes)]
#[encoding(Json)]
struct Sum {
sum: u32,
}
#[plugin_fn]
pub fn add(add: Add) -> FnResult<Sum> {
let sum = Sum { sum: add.a + add.b };
Ok(sum)
}
Raw Export Interface
plugin_fn is a nice macro abstraction but there may be times where you want more control. You can code directly to the raw ABI interface of export functions.
#[no_mangle]
pub unsafe extern "C" fn greet() -> i32 {
let name = unwrap!(input::<String>());
let result = format!("Hello, {}!", name);
unwrap!(output(result));
0i32
}
Configs
Configs are key-value pairs that can be passed in by the host when creating a plug-in. These can be useful to statically configure the plug-in with some data that exists across every function call. Here is a trivial example:
#[plugin_fn]
pub fn greet() -> FnResult<String> {
let user = config::get("user").expect("'user' key set in config");
Ok(format!("Hello, {}!", user))
}
To test it, the Extism CLI has a --config
option that lets you pass in key=value
pairs:
extism call my_plugin.wasm greet --config user=Benjamin
# => Hello, Benjamin!
Variables
Variables are another key-value mechanism but it's a mutable data store that will persist across function calls. These variables will persist as long as the host has loaded and not freed the plug-in. You can use var::get and var::set to manipulate them.
#[plugin_fn]
pub fn count() -> FnResult<i64> {
let mut c = var::get("count")?.unwrap_or(0);
c = c + 1;
var::set("count", c)?;
Ok(c)
}
Logging
Because Wasm modules by default do not have access to the system, printing to stdout won't work (unless you use WASI). Extism provides some simple logging macros that allow you to use the host application to log without having to give the plug-in permission to make syscalls. The primary one is log! but we also have some convenience macros named by log level:
#[plugin_fn]
pub fn log_stuff() -> FnResult<()> {
log!(LogLevel::Info, "Some info!");
log!(LogLevel::Warn, "A warning!");
log!(LogLevel::Error, "An error!");
// optionally you can use the leveled macros:
info!("Some info!");
warn!("A warning!");
error!("An error!");
Ok(())
}
From Extism CLI:
extism call my_plugin.wasm log_stuff --log-level=info
2023/09/30 11:52:17 Some info!
2023/09/30 11:52:17 A warning!
2023/09/30 11:52:17 An error!
Note: From the CLI you need to pass a level with
--log-level
. If you are running the plug-in in your own host using one of our SDKs, you need to make sure that you callset_log_file
to"stdout"
or some file location.
HTTP
Sometimes it is useful to let a plug-in make HTTP calls.
Note: See HttpRequest docs for more info on the request and response types:
#[plugin_fn]
pub fn http_get(Json(req): Json<HttpRequest>) -> FnResult<Vec<u8>> {
let res = http::request::<()>(&req, None)?;
Ok(res.body())
}
Imports (Host Functions)
Like any other code module, Wasm not only let's you export functions to the outside world, you can import them too. Host Functions allow a plug-in to import functions defined in the host. For example, if you host application is written in Python, it can pass a Python function down to your Rust plug-in where you can invoke it.
This topic can get fairly complicated and we have not yet fully abstracted the Wasm knowledge you need to do this correctly. So we recommend reading out concept doc on Host Functions before you get started.
A Simple Example
Host functions have a similar interface as exports. You just need to declare them as extern
on the top of your lib.rs
. You only declare the interface as it is the host's responsibility to provide the implementation:
#[host_fn]
extern "ExtismHost" {
fn a_python_func(input: String) -> String;
}
Note: Under the hood this macro turns this into an interface that passes a pointer as an argument and a pointer as a return. If you want to pass raw, dereferenced wasm values see the raw interface documentation below.
To declare a host function in a specific namespace, pass the module name to the host_fn
macro:
#[host_fn("extism:host/user")]
Note: The types we accept here are the same as the exports as the interface also uses the convert crate.
To call this function, we must use the unsafe
keyword. Also note that it automatically wraps the
function return with a Result in case the call fails.
#[plugin_fn]
pub fn hello_from_python() -> FnResult<String> {
let output = unsafe { a_python_func("An argument to send to Python".into())? };
Ok(output)
}
Testing it out
We can't really test this from the Extism CLI as something must provide the implementation. So let's write out the Python side here. Check out the docs for Host SDKs to implement a host function in a language of your choice.
from extism import host_fn, Plugin
@host_fn()
def a_python_func(input: str) -> str:
# just printing this out to prove we're in Python land
print("Hello from Python!")
# let's just add "!" to the input string
# but you could imagine here we could add some
# applicaiton code like query or manipulate the database
# or our application APIs
return input + "!"
Now when we load the plug-in we pass the host function:
manifest = {"wasm": [{"path": "/path/to/plugin.wasm"}]}
plugin = Plugin(manifest, functions=[a_python_func], wasi=True)
result = plugin.call('hello_from_python', b'').decode('utf-8')
print(result)
python3 app.py
# => Hello from Python!
# => An argument to send to Python!
Raw Import Interface
Like exports, with imports we do some magic to turn the parameters and returns into pointers for you. In some rare situations, you might wish to pass raw wasm values to the host (not pointers). If you do, you need to drop down into a raw interface. E.g, imagine an interface that sums two i64s
extern "C" {
fn sum(a: i64, b: i64) -> i64;
}
Reach Out!
Have a question or just want to drop in and say hi? Hop on the Discord!
Dependencies
~2.5–4MB
~76K SLoC