/haskell-pdk

Extism Plug-in development kit (PDK) for Haskell

Primary LanguageHaskellBSD 3-Clause "New" or "Revised" LicenseBSD-3-Clause

Extism Haskell PDK

This library can be used to write Extism Plug-ins in Haskell.

Docs are available on Hackage: https://hackage.haskell.org/package/extism-pdk

Install

Make sure you have wasm32-wasi-ghc installed, then generate an Executable project with cabal:

cabal init

Add the library from Hackage to your cabal file:

build-depends: extism-pdk

We will also need to add some additional ghc options to expose the correct functions:

ghc-options:
  -optl -Wl,--export=greet -optl -Wl,--export=hs_init -optl -Wl,--allow-undefined -no-hs-main -optl-mexec-model=reactor 

Getting Started

The goal of writing an Extism plug-in is to compile your Haskell 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.

{-# LANGUAGE DeriveDataTypeable #-}

module Hello where

import Data.Maybe
import Extism.PDK
import Extism.PDK.JSON

defaultGreeting = "Hello"

greet g n =
  output $ g ++ ", " ++ n

testing = do
  -- Get a name from the Extism runtime
  name <- inputString
  -- Get  configured greeting
  greeting <- getConfig "greeting"
  -- Greet the user, if no greeting is configured then "Hello" is used
  greet (fromMaybe defaultGreeting greeting) name

foreign export ccall "greet" testing :: IO ()

This example also shows how to use the getConfig function to load runtime configuration values set by the host.

Despite not needing any system access for this plugin, we will still compile it for wasm32-wasi, since there is no Haskell compiler targeting wasm32-unknown-unknown:

wasm32-wasi-cabal build

This will put your compiled wasm somewhere in the dist-newstyle directory:

cp `find dist-newstyle -name example.wasm` .

We can now test it using the Extism CLI's run command:

extism call ./example.wasm greet --input "Benjamin"
# => Hello, Benjamin!

Configure a new greeting we can update the greeting config key using the Extism CLI's --config option that lets you pass in key=value pairs:

extism call ./example.wasm greet --input "Benjamin" --config greeting="Hi there"
# => Hi there, Benjamin!

Note: We also have a web-based, plug-in tester called the Extism Playground

More About Exports

For a function to be available from your Wasm plug-in, you will need to add a foreign export:

foreign export ccall "greet" greet:: IO Int32

And there are some flags to make the function public on the linker side:

ghc-options:
    -optl -Wl,--export=greet -optl -Wl,--export=hs_init -optl -Wl,--allow-undefined -no-hs-main -optl-mexec-model=reactor 

This will export the greet function, the hs_init function and compile a reactor module instead of a command-style module.

Primitive Types

A common thing you may want to do is pass some primitive Haskell data back and forth.

-- Float
addPi = do
  -- Get float value
  value <- (input :: IO Float)
  output $ value + 3.14
  return 0

-- Integers
sum42 = do
  value <- (input :: IO Int)
  output $ value + 42
  return 0

-- ByteString
processBytes = do
  bytes <- inputByteString
  -- process bytes here
  output bytes
  return 0

-- String
processString = do
  s <- inputString
  output s
  return 0

Json

We provide a JSON class that allows you to pass JSON encoded values into and out of plug-in functions:

{-# LANGUAGE DeriveDataTypeable #-}

module Add where
import Extism.PDK
import Extism.PDK.JSON

data Add = Add
  { a :: Int,
    b :: Int
  } deriving (Data)

data Sum = Sum { sum :: Int } deriving (Data)

add = do
  value <- input
  output $ JSON $ Sum (a value + b value)
  return 0

foreign export ccall "add" add :: IO Int32

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 getVar and setVar to manipulate them.

count = do
  c <- fromMaybe 0 <$> getVar "count"
  setVar "count" (c + 1)
  output c
  return 0

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:

module Log where
import Extism.PDK
logStuff = do
  logInfo "Some info!"
  logWarn "A warning!"
  logError "An error!" 
  return 0
foreign export ccall "logStuff" logStuff:: IO Int32

From Extism CLI:

extism call my_plugin.wasm logStuff --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 call set_log_file to "stdout" or some file location.

HTTP

Sometimes it is useful to let a plug-in make HTTP calls.

Note: See Request docs for more info on the request and response types:

 module HTTPGet where

import Data.Int
import Extism.PDK
import Extism.PDK.HTTP
import Extism.PDK.Memory

httpGet = do
  -- Get JSON encoded request from host
  JSON req <- input
  -- Send the request, get a 'Response'
  res <- sendRequest req (Nothing :: Maybe String)
  -- Save response body to memory
  outputMemory (memory res)
  -- Return code
  return 0

foreign export ccall "httpGet" httpGet :: IO Int32

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 Haskell 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 in the Haskell PDK require C stubs to import a function from a particular namespace:

#define IMPORT(a, b) __attribute__((import_module(a), import_name(b)))
IMPORT("extism:host/user", "a_python_func")
uint64_t a_python_func_impl(uint64_t input);

uint64_t a_python_func(uint64_t input) {
  return a_python_func_impl(input);
}

This C file should be added to the extra-source-files and c-sources fields in your cabal file.

From there we can use foreign import ccall to call our stub:

import Extism.PDK.Memory
import Extism.PDK

foreign import ccall "a_python_func" aPythonFunc :: Word64 -> IO Word64

helloFromPython :: String -> IO String
helloFromPython = do
  s' <- allocString "Hello!"
  res <- aPythonFunc (memoryOffset s')
  logInfo <$> loadString res
  return 0

foreign export ccall "helloFromPython" helloFromPython :: IO Int32

To call this function, we write our input string into memory using allocString and call the function with the returned memory handle. We then have to load the result string from memory to access it from our Haskell program.

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('helloFromPython', b'').decode('utf-8')
print(result)
python3 app.py
# => Hello from Python!
# => An argument to send to Python!

Reach Out!

Have a question or just want to drop in and say hi? Hop on the Discord!