CRUD with WASI Key-Value
Build and deploy a CRUDdy Wasm component
This tutorial will show you how to create and deploy a distributed WebAssembly application with wasmCloud, using Python, Rust, Go, or TypeScript. Building on what you've learned in the Quickstart, we'll explore the fundamentals of wasmCloud development by making a simple guestbook that performs CRUD operations using several capabilities—abstracted requirements like HTTP handling or key-value storage, fulfilled by WebAssembly components.
By the end, you'll understand how you can use wasmCloud to orchestrate components and build a distributed application. Along the way, you'll learn how to create a component from scratch and how to use WebAssembly Standard Interface (WASI) APIs.
Let's get going!
A component is portable Wasm code that is interoperable with any other component, regardless of the language in which each component was written. The interoperability of WebAssembly components drives wasmCloud's "building blocks" approach—you can write original code in any language that compiles to WebAssembly and then plug in the capabilities you need (e.g. HTTP) without worrying about how that functionality is implemented. Learn more about WebAssembly components in the Concepts section.
Before you get started
In this tutorial, we'll be using two core tools at the heart of wasmCloud: the wasmCloud Shell (wash
) and wasmCloud Application Deployment Manager (wadm
).
wash
provides a command-line interface for wasmCloud, helping you run hosts, build and deploy components, and manage your installation.wadm
orchestrates wasmCloud workloads according to declarative manifests.
If you haven't installed wasmCloud already, head on over to the installation instructions and then return here.
This tutorial uses wasmCloud v0.82 and first-party providers designed for WASI HTTP 0.2.0 and WASI Key-Value Store 0.1.0. You shouldn't have to worry about WASI API versions, since the API definitions come packaged with the wasmCloud templates used here.
- Rust
- TinyGo
- TypeScript
- Python
You will also need both the Rust toolchain and the wasm32-wasi
target installed on the same machine. wash
depends on the Rust toolchain to compile Rust code to Wasm.
rustup target add wasm32-wasi
Creating a new component
In wasmCloud, an application component is a WebAssembly component dedicated to an application's creative logic. Typically, we will simply refer to this as a "component." Historically, wasmCloud referred to this piece of an application as an "actor," but that term is deprecated in favor of "component" and CLI commands will change as of v1.0.
With wash
installed, you can run the following command in your shell to create a new component from a simple template:
wash new actor cruddy-rust --git wasmcloud/wasmcloud --subfolder examples/rust/actors/http-hello-world --branch 0.82-examples
This command instructs wash
to create a new component called "cruddy-rust
". For our template, we're using the http-hello-world
template from the 0.82 branch of the wasmCloud project repo on GitHub. The new actor
command always creates new components from templates—if you don't specify one from the outset, wash
will provide you with a set of options.
The repo we're using gives us the skeleton for a new component in Rust. Let's take a look at the cruddy-rust
directory:
├── Cargo.lock
├── Cargo.toml
├── README.md
├── src
│ └── lib.rs
├── wadm.yaml
├── wasmcloud.toml
└── wit
Here we have the standard Rust project files as well as three pieces that make up a wasmCloud application component:
wadm.yaml
is a declarative deployment manifest used bywadm
.wasmcloud.toml
is a metadata and permissions configuration file for the application component.- The
wit
directory holds WebAssembly Interface Type (WIT) definitions for standard APIs used across the Wasm ecosystem—we'll discuss these at greater length in a moment.
Let's open wasmcloud.toml
and have a look around:
name = "http-hello-world"
language = "rust"
type = "actor"
version = "0.1.0"
[actor]
wit_world = "hello"
wasm_target = "wasm32-wasi-preview2"
Here we have metadata for details like naming and versioning. For now, we're most interested in the [actor]
fields:
wit_world
points the application to a set of interfaces (known as a "world") defined in thewit
directory.wasm_target
specifies the Wasm compilation target.
Now let's take a look at the wit
directory.
Understanding WIT dependencies
We will interact with the httpserver
and keyvalue
capabilities via WebAssembly System Interface (WASI) APIs—ecosystem-standard APIs used for communication with and between components.
WASI is especially useful for building and maintaining standard and popular libraries in an efficient and language-agnostic way—so developers can focus on writing simple, portable, idiomatic code.
Take a look at the contents of wit
:
├── deps
├── deps.toml
└── world.wit
deps
is a directory that holds WIT definitions.deps.toml
is a TOML configuration file used to manage WIT dependencies.world.wit
defines the interfaces that comprise your WIT "world."
Our template has pre-populated the wit
directory with relevant definitions. However, if you wish to select your WIT dependencies and populate the directory yourself, you can use the wit-deps
tool.
Open deps.toml
. Here we have specified interfaces that we wish to utilize in our app. WASI interfaces like wasi-http
or wasi-keyvalue
are available to browse in the WebAssembly project's GitHub repo.
WIT definitions contain documentation as comments within the WIT files themselves. It is very useful to read through the WIT comprehensively before using a new interface.
Take a look at /deps/http/types.wit
. Here you'll find a detailed explanation of the interface's types. Note the outgoing-request
type—it will come up again in a moment.
/// Represents an outgoing HTTP Request.
resource outgoing-request
With our WIT dependencies in place, we can specify interface imports and exports in world.wit
. A WIT world is a set of standard interfaces that you can use to interact with components via defined functions and types. The WIT interfaces don't provide functionality in themselves—instead, they provide a common language for the contracts between components.
Remember that we targeted the hello
world in our wasmcloud.toml
file; this is where that world is defined. Let's update the hello
world in world.wit
:
package wasmcloud:hello;
world hello {
import wasi:keyvalue/eventual@0.1.0;
export wasi:http/incoming-handler@0.2.0;
}
The wasmcloud.toml
file points to the hello
world in world.wit
, and the hello
world connects to our WIT definitions.
Now that our dependencies are defined, we're ready to get coding.
Working with WASI APIs
Open src/lib.rs
.
wit_bindgen::generate!({
world: "hello",
exports: {
"wasi:http/incoming-handler": HttpServer,
},
});
use exports::wasi::http::incoming_handler::Guest;
use wasi::http::types::*;
struct HttpServer;
impl Guest for HttpServer {
fn handle(_request: IncomingRequest, response_out: ResponseOutparam) {
let response = OutgoingResponse::new(Fields::new());
response.set_status_code(200).unwrap();
let response_body = response.body().unwrap();
response_body
.write()
.unwrap()
.blocking_write_and_flush(b"Hello from Rust!\n")
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
}
}
Most of this file is simple boilerplate. We've made WASI APIs available, but how do we use those APIs in Rust?
The answer is in the first lines. When we build the application with the wash build
command, the builder will use these instructions to run the built-in wit-bindgen
tool and generate bindings between the functions we saw in the WIT dependencies and Rust.
Let's try it out. In the root of the project directory:
wash build
This will create a build
directory with a compiled .wasm
artifact for the app. It will also create a target
directory including dependencies such as the generated bindings.
In Rust, using WASI APIs is fairly intuitive, especially with IDE suggestions and autofills. If we've read through the keyvalue
WIT, we know that we will need an instance of the OutgoingValue
type later. We can create it this way:
// In the language of WASI, here we're creating a new outgoing-value resource
let value: OutgoingValue =
wasi::keyvalue::types::OutgoingValue::new_outgoing_value();
At each stage from wasi::
to keyvalue::
to types::
and beyond, our IDE can provide us with the available options as documented in the WIT. Taken in conjunction, our WIT files and code completion gives us the tools we need to use the API and put the pieces together. For now, put this at the top of our handler function.
So what else do we have in lib.rs
?
Most of the code is in a handle
function responding to requests:
impl Guest for HttpServer {
fn handle(_request: IncomingRequest, response_out: ResponseOutparam) {
let response = OutgoingResponse::new(Fields::new());
response.set_status_code(200).unwrap();
let response_body = response.body().unwrap();
response_body
.write()
.unwrap()
.blocking_write_and_flush(b"Hello from Rust!\n")
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
}
}
Now, at the top of the handle
function, we'll use the WASI HTTP API's path_with_query
method on our incoming request—much like in the Quickstart. This will extract and return a name provided in incoming HTTP queries. (While we're at it, we'll delete the response body, as we'll be replacing that soon.)
impl Guest for HttpServer {
fn handle(_request: IncomingRequest, response_out: ResponseOutparam) {
fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
let response = OutgoingResponse::new(Fields::new());
response.set_status_code(200).unwrap();
let response_body = response.body().unwrap();
let name = match request
.path_with_query()
.unwrap()
.split("=")
.collect::<Vec<&str>>()[..]
{
// query string is "/?name=<name>" e.g. localhost:8080?name=Bob
["/?name", name] => name.to_string(),
// query string is anything else or empty e.g. localhost:8080
_ => "Anonymous".to_string(),
};
response_body
.write()
.unwrap()
.blocking_write_and_flush(b"Hello from Rust!\n")
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
This is the first time we're actually implementing a new WASI method in this component, so we should pause to consider exactly what we're doing:
- When we look at our WIT bindings, we can see that
IncomingRequest
has a methodpath_with_query()
. The original WIT tell us thatpath_with_query()
returns a string of the request path, including any query. However, the string is contained within a return object and needs to be "unwrapped." - To get at the actual value, we use the
unwrap()
method. - To isolate the name, we split the value at the
=
and collect it as a<Vec<&str>>
that we can use elsewhere.
In order to guide our CRUD operations for the guestbook, we'd also like to know the method of the incoming request. We can detect that with the API as well. Try finding the method of the request with what you've learned so far.
If you guessed that you could use a method on request
, well done! Below the previous line add:
// Detect method
let method = request.method();
This will return a method of the Method
type, which we will be able to use directly.
We've implemented our basic HTTP operations. Now let's turn to key-value storage.
Adding CRUD
We can follow the same procedure we used for wasi-http
to explore the wasi-keyvalue API and start adding CRUD operations.
In /wit/deps/keyvalue/types.wit
, we see that collections of key-value pairs are referenced as buckets
in the API:
/// A bucket is a collection of key-value pairs. Each key-value pair is stored
/// as a entry in the bucket, and the bucket itself acts as a collection of all
/// these entries.
///
/// It is worth noting that the exact terminology for bucket in key-value stores
/// can very depending on the specific implementation. For example,
/// 1. Amazon DynamoDB calls a collection of key-value pairs a table
/// 2. Redis has hashes, sets, and sorted sets as different types of collections
/// 3. Cassandra calls a collection of key-value pairs a column family
/// 4. MongoDB calls a collection of key-value pairs a collection
/// 5. Riak calls a collection of key-value pairs a bucket
/// 6. Memcached calls a collection of key-value pairs a slab
/// 7. Azure Cosmos DB calls a collection of key-value pairs a container
///
/// In this interface, we use the term `bucket` to refer to a collection of key-value
We'd like to open a bucket to store the names of our guests, so we'll add the following to our handler function between the method
and value
assignments:
// Open keyvaluestore bucket
// In the language of WASI, here we're creating a new bucket resource
let bucket =
wasi::keyvalue::types::Bucket::open_bucket("").expect("failed to open empty bucket");
Next we'll create a variable called value
of the OutgoingValue
type that will hold the new values assigned to keys in PUT/set operations. This is a wasi-keyvalue
pattern that we would use in any language: the new_outgoing_value()
method is creating a new resource, which we can use to perform synchronous write operations.
// Create a variable for outgoing values - Put/sets will use this
// In the language of WASI, here we're creating a new outgoing-value resource
let value: OutgoingValue =
wasi::keyvalue::types::OutgoingValue::new_outgoing_value();
Now we'll add a switch case that creates/updates, reads, or destroys guestbook records based on the HTTP method of the incoming request. For this rudimentary app, each case will send an appropriate HTTP response:
match method {
wasi::http::types::Method::Get => {
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
let none = wasi::keyvalue::eventual::get(&bucket, &name).unwrap().is_none();
if none {
// Send error message if the value is none
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("{name} not found").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
} else {
let result = wasi::keyvalue::eventual::get(&bucket, &name).unwrap().unwrap();
// Take this result (of the IncomingValue type) and consume that value to get a uint8 byte array, which we will then send as part of http response
let in_val = wasi::keyvalue::types::IncomingValue::incoming_value_consume_sync(result).unwrap();
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(&in_val).unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
};
},
wasi::http::types::Method::Put => {
// Assign value as byte array
let writval = format!("attending").as_bytes().to_vec();
// Use outgoingvaluewritebodysync to actually write the value
let _ = value.outgoing_value_write_body_sync(&writval);
wasi::keyvalue::eventual::set(&bucket, &name, &value)
.expect("failed to set");
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("Added {name}").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
},
wasi::http::types::Method::Delete => {
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
let none = wasi::keyvalue::eventual::get(&bucket, &name).unwrap().is_none();
if none {
// Send error message if the value is none
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("{name} not found").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
} else {
// Delete key
let _ = wasi::keyvalue::eventual::delete(&bucket, &name);
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("It's like {name} was never there").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
};
}
_ => {
// Handle other cases
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("Try sending a GET, PUT, or DELETE").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
}
};
Most of our application logic is happening in this block, so let's pause to consider a few important elements.
The condition for each case is the request method returned to the method
variable. Within the conditionals, each CRUD operation runs against bucket
and name
. In the case of wasi::keyvalue::eventual::delete
, that's extremely straightforward, since no data is being passed. The set
and get
operations are just a touch more complicated.
For the set
, we use the outgoing_value_write_body_sync
method to write the body of the value (defined as a Vec of bytes in writval
) to value
. With our value written to value
, we can pass it into wasi::keyvalue::eventual::set
.
The get
logic is similar but runs in reverse. wasi::keyvalue::eventual::get
gives us a result that must be unwrapped and then consumed with wasi::keyvalue::types::IncomingValue::incoming_value_consume_sync
, which gives us a byte array. Byte arrays are a common unit of data in WASI interfaces—note how we use them in HTTP responses as well. Since we have a byte array from our incoming value now, we can pass it directly to the HTTP response.
At this point, lib.rs
should look like this:
wit_bindgen::generate!({
world: "hello",
exports: {
"wasi:http/incoming-handler": HttpServer,
},
});
use exports::wasi::http::incoming_handler::Guest;
use wasi::{http::types::*, keyvalue::types::OutgoingValue};
struct HttpServer;
impl Guest for HttpServer {
fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
let response = OutgoingResponse::new(Fields::new());
response.set_status_code(200).unwrap();
let response_body = response.body().unwrap();
// Get any name appended to the path via query
let name = match request
.path_with_query()
.unwrap()
.split("=")
.collect::<Vec<&str>>()[..]
{
// query string is "/?name=<name>" e.g. localhost:8080?name=Bob
["/?name", name] => name.to_string(),
// query string is anything else or empty e.g. localhost:8080
_ => "Anonymous".to_string(),
};
// Detect method
let method = request.method();
// Open keyvaluestore bucket
// In the language of WASI, here we're creating a new bucket resource
let bucket =
wasi::keyvalue::types::Bucket::open_bucket("").expect("failed to open empty bucket");
// Create a variable for outgoing values - Put/sets will use this
// In the language of WASI, here we're creating a new outgoing-value resource
let value: OutgoingValue =
wasi::keyvalue::types::OutgoingValue::new_outgoing_value();
// Switch case on method
match method {
wasi::http::types::Method::Get => {
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
let none = wasi::keyvalue::eventual::get(&bucket, &name).unwrap().is_none();
if none {
// Send error message if the value is none
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("{name} not found").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
} else {
let result = wasi::keyvalue::eventual::get(&bucket, &name).unwrap().unwrap();
// Take this result (of the IncomingValue type) and consume that value to get a uint8 byte array, which we will then send as part of http response
let in_val = wasi::keyvalue::types::IncomingValue::incoming_value_consume_sync(result).unwrap();
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(&in_val).unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
};
},
wasi::http::types::Method::Put => {
// Assign value as byte array
let writval = format!("attending").as_bytes().to_vec();
// Use outgoingvaluewritebodysync to actually write the value
let _ = value.outgoing_value_write_body_sync(&writval);
wasi::keyvalue::eventual::set(&bucket, &name, &value)
.expect("failed to set");
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("Added {name}").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
},
wasi::http::types::Method::Delete => {
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
let none = wasi::keyvalue::eventual::get(&bucket, &name).unwrap().is_none();
if none {
// Send error message if the value is none
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("{name} not found").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
} else {
// Delete key
let _ = wasi::keyvalue::eventual::delete(&bucket, &name);
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("It's like {name} was never there").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
};
}
_ => {
// Handle other cases
// Send HTTP response
response_body
.write()
.unwrap()
.blocking_write_and_flush(format!("Try sending a GET, PUT, or DELETE").as_bytes())
.unwrap();
OutgoingBody::finish(response_body, None).expect("failed to finish response body");
ResponseOutparam::set(response_out, Ok(response));
}
};
}
}
Since we've made changes to lib.rs
, we'll build again to update the .wasm
artifact:
wash build
Start Redis
Up front, we said that we don't have to worry about the underlying software that provides a capability until we deploy. Now it's time to start thinking about which capability providers we want to use. You can explore the list of available capability providers in the wasmCloud GitHub repo. For key-value storage, we'll use Redis.
Start a Redis server with either redis-server
or Docker:
redis-server &
docker run -d --name redis -p 6379:6379 redis
Prepare for deployment
The last step to deploy our component is preparing the declarative manifest in wadm.yaml
, which defines the desired state for our application when it is running on wasmCloud. This will look familiar if you've used container orchestrators like Kubernetes. The manifest included with the hello-world-rust
template looks like this:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: rust-http-hello-world
annotations:
version: v0.0.1
description: 'HTTP hello world demo in Rust, using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)'
experimental: 'true'
spec:
components:
- name: http-hello-world
type: actor
properties:
image: file://./build/http_hello_world_s.wasm
traits:
# Govern the spread/scheduling of the actor
- type: spreadscaler
properties:
replicas: 1
# Link the HTTP server, and inform it to listen on port 8080
# on the local machine
- type: linkdef
properties:
target: httpserver
values:
ADDRESS: 127.0.0.1:8080
# Add a capability provider that mediates HTTP access
- name: httpserver
type: capability
properties:
image: wasmcloud.azurecr.io/httpserver:0.19.1
contract: wasmcloud:httpserver
The metadata fields provide naming and versioning for our application. We'll update the name to describe what we've built:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: rust-http-hello-world
name: cruddy
annotations:
version: v0.0.1
description: "HTTP hello world demo in Rust, using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)"
description: "CRUD demo"
experimental: "true"
You will also need both Go and TinyGo installed on the same machine. wash
depends on the TinyGo toolchain to compile Go code to Wasm.
Creating a new component
In wasmCloud, an application component is a WebAssembly component dedicated to an application's creative logic. Typically, we will simply refer to this as a "component." Historically, wasmCloud referred to this piece of an application as an "actor," but that term is deprecated in favor of "component" and CLI commands will change as of v1.0.
With wash
installed, you can run the following command in your shell to create a new component from a simple template:
wash new actor cruddy --git wasmcloud/wasmcloud --subfolder examples/golang/actors/http-hello-world --branch 0.82-examples
This command instructs wash
to create a new component called "cruddy
". For our template, we're using the http-hello-world
template from the 0.82 branch of the wasmCloud project repo on GitHub. The new actor
command always creates new components from templates—if you don't specify one from the outset, wash
will provide you with a set of options.
The repo we're using gives us the skeleton for a new actor in Go. Let's take a look at the cruddy
directory:
├── go.mod
├── hello.go
├── wadm.yaml
├── wasmcloud.toml
└── wit
Here we have the standard go.mod
file for a Go project and a .go
file for the project. We also have three pieces that make up a wasmCloud actor:
wadm.yaml
is a declarative deployment manifest used bywadm
.wasmcloud.toml
is a metadata and permissions configuration file for the actor.- The
wit
directory holds WebAssembly Interface Type (WIT) definitions for standard APIs used across the Wasm ecosystem—we'll discuss these at greater length in a moment.
Let's open wasmcloud.toml
and have a look around:
name = "http-hello-world"
language = "tinygo"
type = "actor"
version = "0.1.0"
[actor]
wit_world = "hello"
wasm_target = "wasm32-wasi-preview2"
destination = "build/http_hello_world_s.wasm"
Here we have metadata for details like naming and versioning. For now, we're most interested in the three [actor]
fields:
wit_world
points the actor to a set of interfaces (known as a "world") defined in thewit
directory.wasm_target
specifies the Wasm compilation target.destination
defines the name and location of the final, signed Wasm artifact for the component.
Now let's take a look at the wit
directory.
Understanding WIT dependencies
We will interact with the httpserver
and keyvalue
capabilities via WebAssembly System Interface (WASI) APIs—ecosystem-standard APIs used for communication with and between components.
WASI is especially useful for building and maintaining standard and popular libraries in an efficient and language-agnostic way—so developers can focus on writing simple, portable, idiomatic code.
Take a look at the contents of wit
:
├── deps
├── deps.toml
└── world.wit
deps
is a directory that holds WIT definitions.deps.toml
is a TOML configuration file used to manage WIT dependencies.world.wit
defines the interfaces that comprise your WIT "world."
Our template has pre-populated the wit
directory with relevant definitions. However, if you wish to select your WIT dependencies and populate the directory yourself, you can use the wit-deps
tool.
Open deps.toml
. Here we have specified interfaces that we wish to utilize in our app. WASI interfaces like wasi-http
or wasi-keyvalue
are available to browse in the WebAssembly project's GitHub repo.
WIT definitions contain documentation as comments within the WIT files themselves. It is very useful to read through the WIT comprehensively before using a new interface.
Take a look at /deps/http/types.wit
. Here you'll find a detailed explanation of the interface's types. Note the outgoing-request
type—it will come up again in a moment.
/// Represents an outgoing HTTP Request.
resource outgoing-request
With our WIT dependencies in place, we can specify interface imports and exports in world.wit
. A WIT world is a set of standard interfaces that you can use to interact with components via defined functions and types. The WIT interfaces don't provide functionality in themselves—instead, they provide a common language for the contracts between components.
Remember that we targeted the hello
world in our wasmcloud.toml
file; this is where that world is defined. Let's update the hello
world in world.wit
:
package wasmcloud:hello;
world hello {
import wasi:keyvalue/eventual@0.1.0;
export wasi:http/incoming-handler@0.2.0;
}
The wasmcloud.toml
file points to the hello
world in world.wit
, and the hello
world connects to our WIT definitions.
Now that our dependencies are defined, we're ready to get coding.
Working with WASI APIs in your Go code
Open hello.go
.
package main
import (
http "github.com/wasmcloud/wasmcloud/examples/golang/actors/http-hello-world/gen"
)
// Helper type aliases to make code more readable
type HttpRequest = http.ExportsWasiHttp0_2_0_IncomingHandlerIncomingRequest
type HttpResponseWriter = http.ExportsWasiHttp0_2_0_IncomingHandlerResponseOutparam
type HttpOutgoingResponse = http.WasiHttp0_2_0_TypesOutgoingResponse
type HttpError = http.WasiHttp0_2_0_TypesErrorCode
type HttpServer struct{}
func init() {
httpserver := HttpServer{}
// Set the incoming handler struct to HttpServer
http.SetExportsWasiHttp0_2_0_IncomingHandler(httpserver)
}
func (h HttpServer) Handle(request HttpRequest, responseWriter HttpResponseWriter) {
// Construct HttpResponse to send back
headers := http.NewFields()
httpResponse := http.NewOutgoingResponse(headers)
httpResponse.SetStatusCode(200)
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8("Hello from Go!\n")).Unwrap()
// Send HTTP response
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
//go:generate wit-bindgen tiny-go wit --out-dir=gen --gofmt
func main() {}
Most of this file is simple boilerplate. We'd like to use the WASI APIs that we've made available, but where can we learn how to use those APIs in Go?
The answer is in the penultimate line. When we build the application with the wash build
command, the builder will use these instructions to run the built-in wit-bindgen
tool and generate bindings between the functions we saw in the WIT dependencies and Go (specifically TinyGo).
Let's try it out. In the root of the project directory:
wash build
This will create a build
directory with a compiled .wasm
artifact for the app. It will also create a gen
directory for bindings.
Open the new hello.go
file in the gen
directory. You will find a lengthy file defining the bindings between the functions and types in your WIT world and their implementations in Go. Take, for example, the outgoing-response
type that we observed in types.wit
. We can find the binding in gen/hello.go
(Try searching the file for "outgoing-response"):
// WasiHttp0_2_0_TypesOutgoingResponse is a handle to imported resource outgoing-response
type WasiHttp0_2_0_TypesOutgoingResponse int32
Taken in conjunction, our WIT files and our generated bindings give us the tools we need to use the API and put the pieces together. If you return to our root hello.go
project file, you'll notice that we're importing <project-name>/gen
as http
, so the Go file can access the WIT bindings for the WIT world. We've already used WasiHttp0_2_0_TypesOutgoingResponse
, giving it the simpler shorthand of HttpOutgoingResponse
.
What else do we have in our hello.go
file?
First, we have three more helper types and a struct for the server. While we're here, let's add a helper type for OutgoingValue
—if we've read through the wasi:keyvalue
WIT, we know this will simplify key-value operations later on.
type HttpRequest = http.ExportsWasiHttp0_2_0_IncomingHandlerIncomingRequest
type HttpResponseWriter = http.ExportsWasiHttp0_2_0_IncomingHandlerResponseOutparam
type HttpOutgoingResponse = http.WasiHttp0_2_0_TypesOutgoingResponse
type HttpError = http.WasiHttp0_2_0_TypesErrorCode
type OutgoingValue = http.WasiKeyvalue0_1_0_TypesOutgoingValue
type HttpServer struct{}
In the init
function, we have the HttpServer
struct set as the handler for incoming requests:
func init() {
httpserver := HttpServer{}
// Set the incoming handler struct to HttpServer
http.SetExportsWasiHttp0_2_0_IncomingHandler(httpserver)
}
Next we have a function responding to requests:
func (h HttpServer) Handle(request HttpRequest, responseWriter HttpResponseWriter) {
// Construct HttpResponse to send back
headers := http.NewFields()
httpResponse := http.NewOutgoingResponse(headers)
httpResponse.SetStatusCode(200)
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8("Hello from Go!\n")).Unwrap()
// Send HTTP response
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
Finally, we have commented directions for wasmCloud's wash
builder and the main function call.
Now, below the handler function, add a new function called getName
—much like in the Quickstart. This will extract and return a name provided in incoming HTTP queries:
func getName(path string) string {
parts := strings.Split(path, "=")
if len(parts) == 2 {
return parts[1]
}
return "Anonymous"
}
Call getName
at the top of the handler function. We'll use the PathWithQuery
method from the WASI HTTP API to grab the path and query from the incoming request:
// Get any name appended to the path via query
name := getName(request.PathWithQuery().Unwrap())
This is the first time we're actually implementing a new WASI method in this component, so we should pause to consider exactly what we're doing:
- We've defined
request
as theHttpRequest
type at the top of the function, andHttpRequest
is a helper alias forExportsWasiHttp0_2_0_IncomingHandlerIncomingRequest
. - When we look at our WIT bindings, we can see that
ExportsWasiHttp0_2_0_IncomingHandlerIncomingRequest
has a methodPathWithQuery
. Both the bindings file and the original WIT tell us thatPathWithQuery
returns a string of the request path, including any query. However, the string is contained within a return object and needs to be "unwrapped." - To get at the actual value, we use the
Unwrap()
method.
In order to guide our CRUD operations for the guestbook, we'd also like to know the method of the incoming request. We can detect that with the API as well. Try finding the method of the request with what you've learned so far.
If you guessed that you could use a method on request
again, well done! Below the previous line add:
// Detect request method
method := request.Method()
This will return a method of the WasiHttp0_2_0_TypesMethod
type, which we will be able to use directly.
We've implemented our basic HTTP operations. Now let's turn to key-value storage.
Adding CRUD
We can follow the same procedure we used for wasi-http
to explore the wasi-keyvalue API and start adding CRUD operations.
In /wit/deps/keyvalue/types.wit
, we see that collections of key-value pairs are referenced as buckets
in the API:
/// A bucket is a collection of key-value pairs. Each key-value pair is stored
/// as a entry in the bucket, and the bucket itself acts as a collection of all
/// these entries.
///
/// It is worth noting that the exact terminology for bucket in key-value stores
/// can very depending on the specific implementation. For example,
/// 1. Amazon DynamoDB calls a collection of key-value pairs a table
/// 2. Redis has hashes, sets, and sorted sets as different types of collections
/// 3. Cassandra calls a collection of key-value pairs a column family
/// 4. MongoDB calls a collection of key-value pairs a collection
/// 5. Riak calls a collection of key-value pairs a bucket
/// 6. Memcached calls a collection of key-value pairs a slab
/// 7. Azure Cosmos DB calls a collection of key-value pairs a container
///
/// In this interface, we use the term `bucket` to refer to a collection of key-value
Peruse the /gen/hello.go
file to explore the Go bindings. (Searching bucket
in the file is a quick way to find the relevant details.) We'd like to open a bucket to store the names of our guests, so we'll add the following to our handler function below the getName
call:
// Open keyvaluestore bucket
// In the language of WASI, here we're creating a new bucket resource
bucket := http.StaticBucketOpenBucket("").Unwrap()
This is another case where we need to use the Unwrap()
method, because on its own StaticBucketOpenBucket("")
returns an object containing both the bucket and an error (if applicable).
Next we'll create a variable called outVal
that will hold the new values assigned to keys in PUT/set operations. This is a wasi-keyvalue
pattern that we would use in any language: the StaticOutgoingValueNewOutgoingValue()
method is creating a new resource, which we can use to perform synchronous write operations.
// Create a variable for outgoing values - Put/sets will use this
// In the language of WASI, here we're creating a new outgoing-value resource
outVal := OutgoingValue(http.StaticOutgoingValueNewOutgoingValue())
Now we'll replace our existing response with a switch case that creates/updates, reads, or destroys guestbook records based on the HTTP method of the incoming request. For this rudimentary app, each case will send an appropriate HTTP response:
// Build the HttpResponse to send back
headers := http.NewFields()
httpResponse := http.NewOutgoingResponse(headers)
httpResponse.SetStatusCode(200)
switch method {
case http.WasiHttp0_2_0_TypesMethodGet():
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
none := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().IsNone()
if none {
// Send error message if value is none
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(name + " not found\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
} else {
result := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().Unwrap()
// Take this result (of the IncomingValue type) and consume that value to get a uint8 byte array, which we will then send as part of http response
incomingValue := http.StaticIncomingValueIncomingValueConsumeSync(result).Unwrap()
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush(incomingValue).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
case http.WasiHttp0_2_0_TypesMethodPut():
// Write the desired value (as byte array) in our variable
outVal.OutgoingValueWriteBodySync([]uint8(fmt.Sprintf("attending")))
// Set the value
http.WasiKeyvalue0_1_0_EventualSet(bucket, name, outVal)
// Send HTTP response
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(fmt.Sprintf("Added " + name))).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
case http.WasiHttp0_2_0_TypesMethodDelete():
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
none := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().IsNone()
if none {
// Send error message if value is none
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(name + " not found\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
} else {
result := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().Unwrap()
// Delete the key value pair
http.WasiKeyvalue0_1_0_EventualDelete(bucket, name)
// Send HTTP response
message := "It's like " + name + " was never there"
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(message + "\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
default:
message := "Try sending a GET, PUT, or DELETE"
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(message + "\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
Most of our application logic is happening in this block, so let's pause to consider a few important elements.
The condition for each case is the request method returned to the method
variable. Within the conditionals, each CRUD operation runs against bucket
and name
. In the case of WasiKeyvalue0_1_0_EventualDelete
, that's pretty straightforward, since no data is being passed. The set
and get
operations are just a touch more complicated.
For the set
, we use the OutgoingValueWriteBodySync
method to write the body of the value to outVal
. The value must be written as a []uint8
byte array. With our value written to outVal
, we can pass it into WasiKeyvalue0_1_0_EventualSet
.
The get
logic is similar but runs in reverse. WasiKeyvalue0_1_0_EventualGet
gives us a result that must be unwrapped and then consumed with StaticIncomingValueIncomingValueConsumeSync
, which gives us a byte array. Byte arrays are a common unit of data in WASI interfaces—note how we use them in HTTP responses as well. Since we have a byte array from our incoming value now, we can pass it directly to the HTTP response.
At this point, hello.go
should look like this:
package main
import (
"fmt"
"strings"
http "github.com/wasmcloud/wasmcloud/examples/golang/actors/http-hello-world/gen"
)
// Helper type aliases to make code more readable
type HttpRequest = http.ExportsWasiHttp0_2_0_IncomingHandlerIncomingRequest
type HttpResponseWriter = http.ExportsWasiHttp0_2_0_IncomingHandlerResponseOutparam
type HttpOutgoingResponse = http.WasiHttp0_2_0_TypesOutgoingResponse
type HttpError = http.WasiHttp0_2_0_TypesErrorCode
type OutgoingValue = http.WasiKeyvalue0_1_0_TypesOutgoingValue
type HttpServer struct{}
func init() {
httpserver := HttpServer{}
// Set the incoming handler struct to HttpServer
http.SetExportsWasiHttp0_2_0_IncomingHandler(httpserver)
}
func (h HttpServer) Handle(request HttpRequest, responseWriter HttpResponseWriter) {
// Get any name appended to the path via query
name := getName(request.PathWithQuery().Unwrap())
// Detect request method
method := request.Method()
// Open keyvaluestore bucket
// In the language of WASI, here we're creating a new bucket resource
bucket := http.StaticBucketOpenBucket("").Unwrap()
// Create a variable for outgoing values - Put/sets will use this
// In the language of WASI, here we're creating a new outgoing-value resource
outVal := OutgoingValue(http.StaticOutgoingValueNewOutgoingValue())
// Build the HttpResponse to send back
headers := http.NewFields()
httpResponse := http.NewOutgoingResponse(headers)
httpResponse.SetStatusCode(200)
switch method {
case http.WasiHttp0_2_0_TypesMethodGet():
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
none := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().IsNone()
if none {
// Send error message if value is none
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(name + " not found\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
} else {
result := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().Unwrap()
// Take this result (of the IncomingValue type) and consume that value to get a uint8 byte array, which we will then send as part of http response
incomingValue := http.StaticIncomingValueIncomingValueConsumeSync(result).Unwrap()
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush(incomingValue).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
case http.WasiHttp0_2_0_TypesMethodPut():
// Write the desired value (as byte array) in our variable
outVal.OutgoingValueWriteBodySync([]uint8(fmt.Sprintf("attending")))
// Set the value
http.WasiKeyvalue0_1_0_EventualSet(bucket, name, outVal)
// Send HTTP response
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(fmt.Sprintf("Added " + name))).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
case http.WasiHttp0_2_0_TypesMethodDelete():
// If the key doesn't exist, then the value will be returned as none, so we're checking for noneness to handle errors
none := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().IsNone()
if none {
// Send error message if value is none
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(name + " not found\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
} else {
result := http.WasiKeyvalue0_1_0_EventualGet(bucket, name).Unwrap().Unwrap()
// Delete the key value pair
http.WasiKeyvalue0_1_0_EventualDelete(bucket, name)
// Send HTTP response
message := "It's like " + name + " was never there"
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(message + "\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
default:
message := "Try sending a GET, PUT, or DELETE"
httpResponse.Body().Unwrap().Write().Unwrap().BlockingWriteAndFlush([]uint8(message + "\n")).Unwrap()
okResponse := http.Ok[HttpOutgoingResponse, HttpError](httpResponse)
http.StaticResponseOutparamSet(responseWriter, okResponse)
}
}
func getName(path string) string {
parts := strings.Split(path, "=")
if len(parts) == 2 {
return parts[1]
}
return "Anonymous"
}
//go:generate wit-bindgen tiny-go wit --out-dir=gen --gofmt
func main() {}
Since we've made changes to hello.go
, we'll build again to update the .wasm
artifact:
wash build
Start Redis
Up front, we said that we don't have to worry about the underlying software that provides a capability until we deploy. Now it's time to start thinking about which capability providers we want to use. You can explore the list of available capability providers in the wasmCloud GitHub repo. For key-value storage, we'll use Redis.
Start a Redis server with either redis-server
or Docker:
redis-server &
docker run -d --name redis -p 6379:6379 redis
Prepare for deployment
The last step to deploy our component is preparing the declarative manifest in wadm.yaml
, which defines the desired state for our application when it is running on wasmCloud. This will look familiar if you've used container orchestrators like Kubernetes. The manifest included with the hello-world-tinygo
template looks like this:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: tinygo-http-hello-world
annotations:
version: v0.0.1
description: 'HTTP hello world demo in Golang (TinyGo), using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)'
experimental: 'true'
spec:
components:
- name: http-hello-world
type: actor
properties:
image: file://./build/http_hello_world_s.wasm
traits:
# Govern the spread/scheduling of the actor
- type: spreadscaler
properties:
replicas: 1
# Link the HTTP server, and inform it to listen on port 8080
# on the local machine
- type: linkdef
properties:
target: httpserver
values:
ADDRESS: 127.0.0.1:8080
# Add a capability provider that mediates HTTP access
- name: httpserver
type: capability
properties:
image: wasmcloud.azurecr.io/httpserver:0.19.1
contract: wasmcloud:httpserver
The metadata fields provide naming and versioning for our application. We'll update the name to describe what we've built:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: tinygo-http-hello-world
name: cruddy
annotations:
version: v0.0.1
description: "HTTP hello world demo in Golang (TinyGo), using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)"
description: "CRUD demo"
experimental: "true"
You will also need to install npm and run npm install
in the project directory, since wash
depends on npm to compile TypeScript/JavaScript code to Wasm.
Creating a new component
In wasmCloud, an application component is a WebAssembly component dedicated to an application's creative logic. Typically, we will simply refer to this as a "component." Historically, wasmCloud referred to this piece of an application as an "actor," but that term is deprecated in favor of "component" and CLI commands will change as of v1.0.
With wash
installed, you can run the following command in your shell to create a new component from a simple template:
wash new actor cruddy --git wasmcloud/wasmcloud --subfolder examples/typescript/actors/http-hello-world --branch 0.82-examples
This command instructs wash
to create a new component called "cruddy
". For our template, we're using the http-hello-world
template from the 0.82 branch of the wasmCloud project repo on GitHub. The new actor
command always creates new components from templates—if you don't specify one from the outset, wash
will provide you with a set of options.
The repo we're using gives us the skeleton for a new component in TypeScript. Let's take a look at the cruddy
directory:
.
├── README.md
├── build
├── http-hello-world.ts
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── types
├── wadm.yaml
├── wasmcloud.toml
└── wit
Here we have an http-hello-world.ts
file for the project, a types
directory that we'll touch on more in a moment, and an empty build
directory, along with standard files for a TypeScript project like package.json
. We also have three pieces that make up a wasmCloud project:
wadm.yaml
is a declarative deployment manifest used bywadm
.wasmcloud.toml
is a metadata and permissions configuration file for the application component.- The
wit
directory holds WebAssembly Interface Type (WIT) definitions for standard APIs used across the Wasm ecosystem—we'll discuss these at greater length in a moment.
Let's open wasmcloud.toml
and have a look around:
name = "typescript-http-hello-world"
language = "typescript"
type = "actor"
version = "0.1.0"
[actor]
wit_world = "hello"
wasm_target = "wasm32-wasi-preview2"
build_command = "npm run build"
build_artifact = "dist/http-hello-world.wasm"
destination = "build/http_hello_world_s.wasm"
Here we have metadata for details like naming and versioning. For now, we're most interested in the [actor]
fields:
wit_world
specifies the specific "world" of APIs defined in WIT that this application can use.wasm_target
identifies the version of WebAssembly to use as a compilation target—in this case, WebAssembly with the Wasm System Interface (WASI) 0.2build_command
defines the custom build command that we will use to build a TypeScript-based componentbuild_artifact
identifies the initial compiled Wasm binary for your build command.destination
defines the name and location of the final, signed Wasm artifact for the component.
Now let's take a look at the wit
directory.
Understanding WIT dependencies
We will interact with the httpserver
and keyvalue
capabilities via WebAssembly System Interface (WASI) APIs—ecosystem-standard APIs used for communication with and between components.
WASI is especially useful for building and maintaining standard and popular libraries in an efficient and language-agnostic way—so developers can focus on writing simple, portable, idiomatic code.
Take a look at the contents of wit
:
├── deps
├── deps.toml
└── world.wit
deps
is a directory that holds WIT definitions.deps.toml
is a TOML configuration file used to manage WIT dependencies.world.wit
defines the interfaces that comprise your WIT "world."
Our template has pre-populated the wit
directory with relevant definitions. However, if you wish to select your WIT dependencies and populate the directory yourself, you can use the wit-deps
tool.
Open deps.toml
. Here we have specified interfaces that we wish to utilize in our app. WASI interfaces like wasi-http
or wasi-keyvalue
are available to browse in the WebAssembly project's GitHub repo.
WIT definitions contain documentation as comments within the WIT files themselves. It is very useful to read through the WIT comprehensively before using a new interface.
Take a look at /deps/http/types.wit
. Here you'll find a detailed explanation of the interface's types. Note the outgoing-request
type—it will come up again in a moment.
/// Represents an outgoing HTTP Request.
resource outgoing-request
With our WIT dependencies in place, we can specify interface imports and exports in world.wit
. A WIT world is a set of standard interfaces that you can use to interact with components via defined functions and types. The WIT interfaces don't provide functionality in themselves—instead, they provide a common language for the contracts between components.
Remember that we targeted the hello
world in our wasmcloud.toml
file; this is where that world is defined. Let's update the hello
world in world.wit
:
package wasmcloud:hello;
world hello {
import wasi:keyvalue/eventual@0.1.0;
export wasi:http/incoming-handler@0.2.0;
}
The wasmcloud.toml
file points to the hello
world in world.wit
, and the hello
world connects to our WIT definitions.
Now that our dependencies are defined, we're ready to get coding.
Working with WASI APIs in your TypeScript code
Open http-hello-world.ts
.
import { IncomingRequest, ResponseOutparam, OutgoingResponse, Fields } from 'wasi:http/types@0.2.0';
// Implementation of wasi-http incoming-handler
//
// NOTE: To understand the types involved, take a look at wit/deps/http/types.wit
function handle(req: IncomingRequest, resp: ResponseOutparam) {
// Start building an outgoing response
const outgoingResponse = new OutgoingResponse(new Fields());
// Access the outgoing response body
let outgoingBody = outgoingResponse.body();
// Create a stream for the response body
let outputStream = outgoingBody.write();
// // Write hello world to the response stream
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode('Hello from Typescript!\n')),
);
// Set the status code for the response
outgoingResponse.setStatusCode(200);
// Set the created response
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
}
export const incomingHandler = {
handle,
};
Most of this file is simple boilerplate. We'd like to use the WASI APIs that we've made available, but where can we learn how to use those APIs in TypeScript?
We can find documented TypeScript bindings for some of the WASI APIs defined by our WIT files in the types
directory. Currently, the bindings correspond to the hello
WIT world as it was defined in the template with only the HTTP export. Typically, we would update the bindings to reflect our changes to the WIT world by running a build.
wash build
At the time of writing, the underlying JavaScript component-builder JCO does not generate types for wasi:keyvalue
. TypeScript-based components using the wasi:keyvalue
API still work, but understanding how to use the API can be a little more challenging. This is a known issue and will be resolved in a future JCO release. For now, you can tell the TypeScript compiler to ignore the missing types by adding //@ts-expect-error
before each import statement. Simply including the import statement will allow the wasmCloud host to provide the functionality at runtime.
Note that the bindings are based on the imports and exports defined in your WIT world. If you were to update the WIT world again, you would need to generate bindings again.
In our types
directory we have a variety of bindings in .d.ts
files. Open types/wasi-http-types.d.ts
. You will find definitions of the bindings between HTTP types in your WIT world and their implementations in TypeScript. Take, for example, the outgoing-response
type that we observed in types.wit
. We can find the binding in types/wasi-http-types.d.ts
on lines 280-287:
/**
* Construct an `outgoing-response`, with a default `status-code` of `200`.
* If a different `status-code` is needed, it must be set via the
* `set-status-code` method.
*
* * `headers` is the HTTP Headers for the Response.
*/
export { OutgoingResponse };
Taken in conjunction, our WIT files and our generated bindings give us the tools we need to use the API and put the pieces together. If you return to our root http-hello-world.ts
project file, you'll notice that we're importing a variety of types from the hello
world, including OutgoingResponse
:
import { IncomingRequest, ResponseOutparam, OutgoingResponse, Fields } from 'wasi:http/types@0.2.0';
While we're here, let's import some more types that we will need later on. Note that we've added //@ts-expect-error
before our wasi:keyvalue
import statements per the warning above. (Also note that we're renaming wasi:keyvalue
's delete
function to eightysix
, since the compiler doesn't like having a function named delete
.)
import {
IncomingRequest,
ResponseOutparam,
OutgoingResponse,
Fields,
Method,
} from 'wasi:http/types@0.2.0';
//@ts-expect-error -- bindings not currently generated by JCO
import { Bucket, OutgoingValue, IncomingValue } from 'wasi:keyvalue/types@0.1.0';
//@ts-expect-error -- bindings not currently generated by JCO
import { set, get, delete as eightysix } from 'wasi:keyvalue/eventual@0.1.0';
Now let's look at the rest of the file. First, the handle
function handles an incoming req
(of the IncomingRequest
type) and return a resp
(of the ResponseOutparam
type). Most of our app will take place within this function.
function handle(req: IncomingRequest, resp: ResponseOutparam);
Next we have the code for sending HTTP responses. Let's go ahead and delete that, since we'll be replacing it soon.
// Start building an outgoing response
const outgoingResponse = new OutgoingResponse(new Fields());
// Access the outgoing response body
let outgoingBody = outgoingResponse.body();
// Create a stream for the response body
let outputStream = outgoingBody.write();
// Write hello world to the response stream
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode('Hello from Typescript!\n')),
);
// Set the status code for the response
outgoingResponse.setStatusCode(200);
// Set the created response
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
Now, below the handle
function and above the export
for incomingHandler
, add a new function called getNameFromPath
—much like in the Quickstart. This will extract and return a name provided in incoming HTTP queries.
function getNameFromPath(path: string): string {
const parts = path.split('=');
if (parts.length == 2) {
return parts[1];
}
return `Anonymous`;
}
Call getNameFromPath
at the top of the handle
function. We'll use the pathWithQuery()
method from the WASI HTTP API on req
to grab the path and query from the incoming request:
// Get the name
const name = getNameFromPath(req.pathWithQuery() || '');
This is the first time we're actually implementing a new WASI method in this component, so we should pause to consider exactly what we're doing:
req
is defined as theIncomingRequest
type at the top of the function.- When we look at our WIT bindings, we can see that
IncomingRequest
has a methodpathWithQuery()
.
In order to guide our CRUD operations for the guestbook, we'd also like to know the method of the incoming request. We can detect that with the API as well. Try finding the method of the request with what you've learned so far.
If you guessed that you could use a method on req
again, well done! Below the previous line add:
// Find the request method
const checkMethod: Method = req.method();
const method = checkMethod.tag;
The method()
method uses the Method
type. If we consult the wasi-http-types.d.ts
WIT bindings, starting on line 373 we see how to interact with our method types. Using .tag
will give us the method as a string—for example, get
.
We've implemented our basic HTTP operations. Now let's turn to key-value storage.
Adding CRUD
Typically, we would follow the same procedure we used for wasi-http
to explore the wasi-keyvalue
API and start adding CRUD operations. This is the procedure we use in our other language walkthroughs, but because JCO doesn't currently generate bindings for keyvalue
, we have to be a little more creative.
In /wit/deps/keyvalue/types.wit
, we see that collections of key-value pairs are referenced as buckets
in the API:
/// A bucket is a collection of key-value pairs. Each key-value pair is stored
/// as a entry in the bucket, and the bucket itself acts as a collection of all
/// these entries.
///
/// It is worth noting that the exact terminology for bucket in key-value stores
/// can very depending on the specific implementation. For example,
/// 1. Amazon DynamoDB calls a collection of key-value pairs a table
/// 2. Redis has hashes, sets, and sorted sets as different types of collections
/// 3. Cassandra calls a collection of key-value pairs a column family
/// 4. MongoDB calls a collection of key-value pairs a collection
/// 5. Riak calls a collection of key-value pairs a bucket
/// 6. Memcached calls a collection of key-value pairs a slab
/// 7. Azure Cosmos DB calls a collection of key-value pairs a container
///
/// In this interface, we use the term `bucket` to refer to a collection of key-value
If you make an import error when building with JCO, you get a list of available imports, an excerpt of which we'll list here as a useful reference:
[static]bucket.openBucket, [static]outgoingValue.newOutgoingValue, [method]outgoingValue.outgoingValueWriteBodyAsync, [method]outgoingValue.outgoingValueWriteBodySync, [static]incomingValue.incomingValueConsumeSync, [static]incomingValue.incomingValueConsumeAsync, [method]incomingValue.incomingValueSize, [resourceDrop]bucket, [resourceDrop]outgoingValue, [resourceDrop]incomingValue
Hey, there's our Bucket
with its openBucket
method! You might recall that we already imported the Bucket
type. Add the following below the method
detection:
// Open the bucket
const bucket = Bucket.openBucket('');
Next we'll create a variable called value
that will hold the new values assigned to keys in PUT/set operations. This is a wasi-keyvalue
pattern that we would use in any language: the newOutgoingValue()
method is creating a new resource, which we can use to perform synchronous write operations.
// Create new outgoing value resource
const value = OutgoingValue.newOutgoingValue();
Now we'll replace our existing response with a switch case that creates/updates, reads, or destroys guestbook records based on the HTTP method of the incoming request. For this rudimentary app, each case will send an appropriate HTTP response:
switch (
method
) {
case `get`:
const result = get(bucket, name);
if (result === undefined) {
outputStream.blockingWriteAndFlush(new Uint8Array(new TextEncoder().encode(`Not found\n`)));
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
} else {
// Take this result (of the IncomingValue type) and consume that value to get bytes, which we will then send as part of http response
const incoming = IncomingValue.incomingValueConsumeSync(result);
// Send the HTTP response. We don't need to do anything to the incoming value -- it's already the expected bytes.
outputStream.blockingWriteAndFlush(incoming);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
}
case `put`:
// Write the actual value to the value resource
value.outgoingValueWriteBodySync(new Uint8Array(new TextEncoder().encode(`attended`)));
// Set it and forget it
set(bucket, name, value);
// Write and send the HTTP response
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`Added ${name}!\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
case `delete`:
const delcheck = get(bucket, name);
if (delcheck === undefined) {
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`${name} not found\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
} else {
// Delete the key
eightysix(bucket, name);
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`It's like ${name} was never there\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
}
default:
// Write hello world to the response stream, set the status code, and set response
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`Try a GET, PUT, or DELETE\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
}
Most of our application logic is happening in this block, so let's pause to consider a few important elements.
The condition for each case is the request method returned to the method
const. Within the cases, each CRUD operation runs as an imported function (get
, set
, or eightysix
-formerly-known-as-delete
) against bucket
and name
.
For the set
, we use the outgoingValueWriteBodySync()
method to write the body of the value to value
. The value must be written as bytes. With our value written to value
, we can pass it to set
.
The get
logic is similar but runs in reverse. get
gives us a result that must be consumed with incomingValueConsumeSync
, which gives us a byte result, which can be passed directly to the HTTP response.
At this point, http-hello-world.ts
should look like this:
import {
IncomingRequest,
ResponseOutparam,
OutgoingResponse,
Fields,
Method,
} from 'wasi:http/types@0.2.0';
//@ts-expect-error -- bindings not currently generated by JCO
import { Bucket, OutgoingValue, IncomingValue } from 'wasi:keyvalue/types@0.1.0';
//@ts-expect-error -- bindings not currently generated by JCO
import { set, get, delete as eightysix } from 'wasi:keyvalue/eventual@0.1.0';
function handle(req: IncomingRequest, resp: ResponseOutparam) {
// Prepare an outgoing response
const outgoingResponse = new OutgoingResponse(new Fields());
const outgoingBody = outgoingResponse.body();
const outputStream = outgoingBody.write();
// Get the name
const name = getNameFromPath(req.pathWithQuery() || '');
// Find the request method
const checkMethod: Method = req.method();
const method = checkMethod.tag;
// Open the bucket
const bucket = Bucket.openBucket('');
// Create new outgoing value resource
const value = OutgoingValue.newOutgoingValue();
switch (method) {
case `get`:
const result = get(bucket, name);
if (result === undefined) {
outputStream.blockingWriteAndFlush(new Uint8Array(new TextEncoder().encode(`Not found\n`)));
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
} else {
// Take this result (of the IncomingValue type) and consume that value to get bytes, which we will then send as part of http response
const incoming = IncomingValue.incomingValueConsumeSync(result);
// Send the HTTP response. We don't need to do anything to the incoming value -- it's already the expected bytes.
outputStream.blockingWriteAndFlush(incoming);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
}
case `put`:
// Write the actual value to the value resource
value.outgoingValueWriteBodySync(new Uint8Array(new TextEncoder().encode(`attended`)));
// Set it and forget it
set(bucket, name, value);
// Write and send the HTTP response
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`Added ${name}!\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
case `delete`:
const delcheck = get(bucket, name);
if (delcheck === undefined) {
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`${name} not found\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
} else {
// Delete the key
eightysix(bucket, name);
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`It's like ${name} was never there\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
}
default:
// Write hello world to the response stream, set the status code, and set response
outputStream.blockingWriteAndFlush(
new Uint8Array(new TextEncoder().encode(`Try a GET, PUT, or DELETE\n`)),
);
outgoingResponse.setStatusCode(200);
ResponseOutparam.set(resp, { tag: 'ok', val: outgoingResponse });
return;
}
}
function getNameFromPath(path: string): string {
const parts = path.split('=');
if (parts.length == 2) {
return parts[1];
}
return `Anonymous`;
}
export const incomingHandler = {
handle,
};
Since we've made changes to http-hello-world.ts
, we'll build again to update the .wasm
artifact:
wash build
Start Redis
Up front, we said that we don't have to worry about the underlying software that provides a capability until we deploy. Now it's time to start thinking about which capability providers we want to use. You can explore the list of available capability providers in the wasmCloud GitHub repo. For key-value storage, we'll use Redis.
Start a Redis server with either redis-server
or Docker:
redis-server &
docker run -d --name redis -p 6379:6379 redis
Prepare for deployment
The last step to deploy our component is preparing the declarative manifest in wadm.yaml
, which defines the desired state for our application when it is running on wasmCloud. This will look familiar if you've used container orchestrators like Kubernetes. The manifest included with the TypeScript template looks like this:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: typescript-http-hello-world
annotations:
version: v0.0.1
description: 'Demo of Typescript HTTP hello world server'
experimental: 'true'
spec:
components:
# (Capability Provider) mediates HTTP access
- name: httpserver
type: capability
properties:
image: wasmcloud.azurecr.io/httpserver:0.19.1
contract: wasmcloud:httpserver
# (Actor) A test actor that returns a string for any given HTTP request
- name: typescript-http-hello-world
type: actor
properties:
image: file://./build/http_hello_world_s.wasm
traits:
# Govern the spread/scheduling of the actor
- type: spreadscaler
properties:
replicas: 1
# Link the HTTP server, and inform it to listen on port 8080
# on the local machine
- type: linkdef
properties:
target: httpserver
values:
ADDRESS: 127.0.0.1:8080
The metadata fields provide naming and versioning for our application. We'll update the name to describe what we've built:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: typescript-http-hello-world
name: cruddy
annotations:
version: v0.0.1
description: "Demo of Typescript HTTP hello world server"
description: "CRUD demo"
experimental: "true"
You will also need both Python 3.10 or later and pip. After you have those, you'll need to install componentize-py v0.12 or later, since wash
depends on componentize-py
to compile Python code to Wasm.
Creating a new component
In wasmCloud, an application component is a WebAssembly component dedicated to an application's creative logic. Typically, we will simply refer to this as a "component." Historically, wasmCloud referred to this piece of an application as an "actor," but that term is deprecated in favor of "component" and CLI commands will change as of v1.0.
With wash
installed, you can run the following command in your shell to create a new component from a simple template:
wash new actor cruddy-py --git wasmcloud/wasmcloud --subfolder examples/python/actors/http-hello-world --branch 0.82-examples
This command instructs wash
to create a new component called "cruddy-py
". For our template, we're using the http-hello-world
template from the 0.82 branch of the wasmCloud project repo on GitHub. The new actor
command always creates new components from templates—if you don't specify one from the outset, wash
will provide you with a set of options.
The repo we're using gives us the skeleton for a new actor in Python. Let's take a look at the cruddy-py
directory:
├── app.py
├── build
├── wadm.yaml
├── wasmcloud.toml
└── wit
Here we have an app.py
file for the project and an empty build
directory. We also have three pieces that make up a wasmCloud project:
wadm.yaml
is a declarative deployment manifest used bywadm
.wasmcloud.toml
is a metadata and permissions configuration file for the application component.- The
wit
directory holds WebAssembly Interface Type (WIT) definitions for standard APIs used across the Wasm ecosystem—we'll discuss these at greater length in a moment.
Let's open wasmcloud.toml
and have a look around:
name = "python-http-hello-world"
language = "python"
type = "component"
version = "0.1.0"
[component]
build_command = "componentize-py -d ./wit -w hello componentize app -o build/http_hello_world.wasm"
build_artifact = "build/http_hello_world.wasm"
destination = "build/http_hello_world_s.wasm"
Here we have metadata for details like naming and versioning. For now, we're most interested in the three [component]
fields:
build_command
defines the custom build command that we will use to build a Python-based component usingcomponentize-py
build_artifact
identifies the initial compiled Wasm binary for your build command.destination
defines the name and location of the final, signed Wasm artifact for the component.
Now let's take a look at the wit
directory.
Understanding WIT dependencies
We will interact with the httpserver
and keyvalue
capabilities via WebAssembly System Interface (WASI) APIs—ecosystem-standard APIs used for communication with and between components.
WASI is especially useful for building and maintaining standard and popular libraries in an efficient and language-agnostic way—so developers can focus on writing simple, portable, idiomatic code.
Take a look at the contents of wit
:
├── deps
├── deps.toml
└── world.wit
deps
is a directory that holds WIT definitions.deps.toml
is a TOML configuration file used to manage WIT dependencies.world.wit
defines the interfaces that comprise your WIT "world."
Our template has pre-populated the wit
directory with relevant definitions. However, if you wish to select your WIT dependencies and populate the directory yourself, you can use the wit-deps
tool.
Open deps.toml
. Here we have specified interfaces that we wish to utilize in our app. WASI interfaces like wasi-http
or wasi-keyvalue
are available to browse in the WebAssembly project's GitHub repo.
WIT definitions contain documentation as comments within the WIT files themselves. It is very useful to read through the WIT comprehensively before using a new interface.
Take a look at /deps/http/types.wit
. Here you'll find a detailed explanation of the interface's types. Note the outgoing-request
type—it will come up again in a moment.
/// Represents an outgoing HTTP Request.
resource outgoing-request
With our WIT dependencies in place, we can specify interface imports and exports in world.wit
. A WIT world is a set of standard interfaces that you can use to interact with components via defined functions and types. The WIT interfaces don't provide functionality in themselves—instead, they provide a common language for the contracts between components.
Remember that we targeted the hello
world in our wasmcloud.toml
file; this is where that world is defined. Let's update the hello
world in world.wit
:
package wasmcloud:hello;
world hello {
import wasi:keyvalue/eventual@0.1.0;
export wasi:http/incoming-handler@0.2.0;
}
The wasmcloud.toml
file points to the hello
world in world.wit
, and the hello
world connects to our WIT definitions.
Now that our dependencies are defined, we're ready to get coding.
Working with WASI APIs in your Python code
Open app.py
.
from hello import exports
from hello.types import Ok
from hello.imports.types import (
IncomingRequest, ResponseOutparam,
OutgoingResponse, Fields, OutgoingBody
)
class IncomingHandler(exports.IncomingHandler):
def handle(self, _: IncomingRequest, response_out: ResponseOutparam):
# Construct the HTTP response to send back
outgoingResponse = OutgoingResponse(Fields.from_list([]))
# Set the status code to OK
outgoingResponse.set_status_code(200)
outgoingBody = outgoingResponse.body()
# Write our Hello World message to the response body
outgoingBody.write().blocking_write_and_flush(bytes("Hello from Python!\n", "utf-8"))
OutgoingBody.finish(outgoingBody, None)
# Set and send the HTTP response
ResponseOutparam.set(response_out, Ok(outgoingResponse))
Most of this file is simple boilerplate. We'd like to use the WASI APIs that we've made available, but where can we learn how to use those APIs in Python?
componentize-py
makes it easy to generate documented Python bindings for the WASI APIs defined by our WIT files. This will also enable IDE suggestions and autofills, providing a smoother development experience. To generate the bindings:
componentize-py bindings .
Note that the bindings will be based on the imports and exports defined in your WIT world. If you were to update the WIT world again, you would need to generate bindings again.
Now you have a new hello
directory with bindings in .py
files. Open hello/imports/types.py
. You will find definitions of the bindings between types in your WIT world and their implementations in Python. Take, for example, the outgoing-response
type that we observed in types.wit
. We can find the binding in hello/imports/types.py
(Try searching the file for "OutgoingResponse"):
class OutgoingResponse:
"""
Represents an outgoing HTTP Response.
"""
def __init__(self, headers: Fields):
"""
Construct an `outgoing-response`, with a default `status-code` of `200`.
If a different `status-code` is needed, it must be set via the
`set-status-code` method.
* `headers` is the HTTP Headers for the Response.
"""
raise NotImplementedError
def status_code(self) -> int:
"""
Get the HTTP Status Code for the Response.
"""
raise NotImplementedError
def set_status_code(self, status_code: int) -> None:
"""
Set the HTTP Status Code for the Response. Fails if the status-code
given is not a valid http status code.
Raises: `hello.types.Err(None)`
"""
raise NotImplementedError
Taken in conjunction, our WIT files and our generated bindings give us the tools we need to use the API and put the pieces together. If you return to our root app.py
project file, you'll notice that we're importing a variety of types from the hello
world, including OutgoingResponse
:
from hello.imports.types import (
IncomingRequest, ResponseOutparam,
OutgoingResponse, Fields, OutgoingBody
)
While we're here, let's import some more types and some key-value functions that we will need later on:
from hello.imports.types import (
IncomingRequest, ResponseOutparam,
OutgoingResponse, Fields, OutgoingBody,
Bucket, OutgoingValue, IncomingValue, MethodGet, MethodPut, MethodDelete
)
from hello.imports.eventual import (set, get, delete)
Now let's look at the rest of the file. First, an IncomingHandler
is instantiated with a handle
function inside set to handle an incoming request
(of the IncomingRequest
type) and return a response
(of the ResponseOutparam
type). Most of our app will take place within this function.
class IncomingHandler(exports.IncomingHandler):
def handle(self, _: IncomingRequest, response_out: ResponseOutparam):
Next we have the code for sending HTTP responses. Let's go ahead and delete that, since we'll be replacing it soon.
# Construct the HTTP response to send back
outgoingResponse = OutgoingResponse(Fields.from_list([]))
# Set the status code to OK
outgoingResponse.set_status_code(200)
outgoingBody = outgoingResponse.body()
# Write our Hello World message to the response body
outgoingBody.write().blocking_write_and_flush(bytes("Hello from Python!\n", "utf-8"))
OutgoingBody.finish(outgoingBody, None)
# Set and send the HTTP response
ResponseOutparam.set(response_out, Ok(outgoingResponse))
Now, outside and below the IncomingHandler
object, add a new function called getName
—much like in the Quickstart. This will extract and return a name provided in incoming HTTP queries.
# Helper function to get name
def get_name_from_path(path: str) -> str:
parts = path.split("=")
if len(parts) == 2:
return parts[-1]
else:
return "Anonymous"
Call get_name_from_path
at the top of the handler function. We'll use the path_with_query()
method from the WASI HTTP API to grab the path and query from the incoming request:
# Get the name
name = get_name_from_path(request.path_with_query())
This is the first time we're actually implementing a new WASI method in this component, so we should pause to consider exactly what we're doing:
request
is defined as theIncomingRequest
type at the top of the function.- When we look at our WIT bindings, we can see that
IncomingRequest
has a methodpath_with_query()
. (Our IDE autofill can surface the method even more easily.)
In order to guide our CRUD operations for the guestbook, we'd also like to know the method of the incoming request. We can detect that with the API as well. Try finding the method of the request with what you've learned so far.
If you guessed that you could use a method on request
again, well done! Below the previous line add:
# Detect request method
method = request.method()
This will return a method of one of the types we imported earlier (MethodGet
, MethodPut
, or MethodDelete
).
We've implemented our basic HTTP operations. Now let's turn to key-value storage.
Adding CRUD
We can follow the same procedure we used for wasi-http
to explore the wasi-keyvalue API and start adding CRUD operations.
In /wit/deps/keyvalue/types.wit
, we see that collections of key-value pairs are referenced as buckets
in the API:
/// A bucket is a collection of key-value pairs. Each key-value pair is stored
/// as a entry in the bucket, and the bucket itself acts as a collection of all
/// these entries.
///
/// It is worth noting that the exact terminology for bucket in key-value stores
/// can very depending on the specific implementation. For example,
/// 1. Amazon DynamoDB calls a collection of key-value pairs a table
/// 2. Redis has hashes, sets, and sorted sets as different types of collections
/// 3. Cassandra calls a collection of key-value pairs a column family
/// 4. MongoDB calls a collection of key-value pairs a collection
/// 5. Riak calls a collection of key-value pairs a bucket
/// 6. Memcached calls a collection of key-value pairs a slab
/// 7. Azure Cosmos DB calls a collection of key-value pairs a container
///
/// In this interface, we use the term `bucket` to refer to a collection of key-value
Peruse the /hello/imports/types.py
file to explore the Python bindings. (Searching bucket
in the file is a quick way to find the relevant details.) You might also recall that we imported the Bucket
type. The syntax to open a bucket is pretty intuitive—add the following below the method
detection:
# Open the bucket
bucket = Bucket.open_bucket("")
Next we'll create a variable called value
that will hold the new values assigned to keys in PUT/set operations. This is a wasi-keyvalue
pattern that we would use in any language: the new_outgoing_value()
method is creating a new resource, which we can use to perform synchronous write operations.
# Create new outgoing value resource, then write the actual value
value = OutgoingValue.new_outgoing_value()
Now we'll replace our existing response with a conditional sequence that creates/updates, reads, or destroys guestbook records based on the HTTP method of the incoming request. For this rudimentary app, each case will send an appropriate HTTP response:
# Begin conditional sequence on method
if type(method) == MethodGet:
# Get the value for this key (will return None if not found)
result = get(bucket, name)
# Checking for noneness to handle errors
if result == None:
# Send error message if the value is none
outgoingBody.write().blocking_write_and_flush(bytes("{} not found\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
else:
# Take this result (of the IncomingValue type) and consume that value to get a byte array, which we will then send as part of http response
incoming = IncomingValue.incoming_value_consume_sync(result)
# Send the HTTP response. We don't need to do anything to the incoming value -- it's already the expected byte array.
outgoingBody.write().blocking_write_and_flush(incoming)
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
elif type(method) == MethodPut:
# Write the actual value to the value resource
value.outgoing_value_write_body_sync(bytes("attended\n", "utf-8"))
# Set it and forget it
set(bucket, name, value)
# Write and send the HTTP response
outgoingBody.write().blocking_write_and_flush(bytes("Added {}!\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
elif type(method) == MethodDelete:
# Get the value for this key (will return None if not found)
result = get(bucket, name)
# Checking for noneness to handle errors
if result == None:
# Send error message if the value is none
outgoingBody.write().blocking_write_and_flush(bytes("{} not found\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
else:
# Delete the key
delete(bucket, name)
# Write and send the HTTP response
outgoingBody.write().blocking_write_and_flush(bytes("It's like {} was never there\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
else:
# Write and send the HTTP response
outgoingBody.write().blocking_write_and_flush(bytes("Try a GET, PUT, or DELETE\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
Most of our application logic is happening in this block, so let's pause to consider a few important elements.
The condition for each case is the request method returned to the method
variable as a type. Within the conditionals, each CRUD operation runs as an imported function (get
, set
, or delete
) against bucket
and name
. In the case of delete
, that's extremely straightforward, since no data is being passed. The set
and get
operations are just a touch more complicated.
For the set
, we use the outgoing_value_write_body_sync()
method to write the body of the value to value
. The value must be written as bytes. With our value written to value
, we can pass it to set
.
The get
logic is similar but runs in reverse. get
gives us a result that must be consumed with incoming_value_consume_sync
, which gives us a byte result, which can be passed directly to the HTTP response.
At this point, app.py
should look like this:
from hello import exports
from hello.types import Ok
from hello.imports.types import (
IncomingRequest, ResponseOutparam,
OutgoingResponse, Fields, OutgoingBody, Bucket,
OutgoingValue, IncomingValue, MethodGet, MethodPut, MethodDelete
)
from hello.imports.eventual import (set, get, delete)
class IncomingHandler(exports.IncomingHandler):
def handle(self, request: IncomingRequest, response_out: ResponseOutparam):
# Construct the HTTP response to send back
outgoingResponse = OutgoingResponse(Fields.from_list([]))
outgoingResponse.set_status_code(200)
outgoingBody = outgoingResponse.body()
# Get the name
name = get_name_from_path(request.path_with_query())
# Detect request method
method = request.method()
# Open the bucket
bucket = Bucket.open_bucket("")
# Create new outgoing value resource
value = OutgoingValue.new_outgoing_value()
# Begin conditional sequence on method
if type(method) == MethodGet:
# Get the value for this key (will return None if not found)
result = get(bucket, name)
# Checking for noneness to handle errors
if result == None:
# Send error message if the value is none
outgoingBody.write().blocking_write_and_flush(bytes("{} not found\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
else:
# Take this result (of the IncomingValue type) and consume that value to get bytes, which we will then send as part of http response
incoming = IncomingValue.incoming_value_consume_sync(result)
# Send the HTTP response. We don't need to do anything to the incoming value -- it's already the expected bytes.
outgoingBody.write().blocking_write_and_flush(incoming)
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
elif type(method) == MethodPut:
# Write the actual value to the value resource
value.outgoing_value_write_body_sync(bytes("attended\n", "utf-8"))
# Set it and forget it
set(bucket, name, value)
# Write and send the HTTP response
outgoingBody.write().blocking_write_and_flush(bytes("Added {}!\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
elif type(method) == MethodDelete:
# Get the value for this key (will return None if not found)
result = get(bucket, name)
# Checking for noneness to handle errors
if result == None:
# Send error message if the value is none
outgoingBody.write().blocking_write_and_flush(bytes("{} not found\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
else:
# Delete the key
delete(bucket, name)
# Write and send the HTTP response
outgoingBody.write().blocking_write_and_flush(bytes("It's like {} was never there\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
else:
# Write and send the HTTP response
outgoingBody.write().blocking_write_and_flush(bytes("Try a GET, PUT, or DELETE\n".format(name), "utf-8"))
OutgoingBody.finish(outgoingBody, None)
ResponseOutparam.set(response_out, Ok(outgoingResponse))
# Helper function to get name
def get_name_from_path(path: str) -> str:
parts = path.split("=")
if len(parts) == 2:
return parts[-1]
else:
return "Anonymous"
Since we've made changes to app.py
, we'll build again to update the .wasm
artifact:
wash build
Start Redis
Up front, we said that we don't have to worry about the underlying software that provides a capability until we deploy. Now it's time to start thinking about which capability providers we want to use. You can explore the list of available capability providers in the wasmCloud GitHub repo. For key-value storage, we'll use Redis.
Start a Redis server with either redis-server
or Docker:
redis-server &
docker run -d --name redis -p 6379:6379 redis
Prepare for deployment
The last step to deploy our component is preparing the declarative manifest in wadm.yaml
, which defines the desired state for our application when it is running on wasmCloud. This will look familiar if you've used container orchestrators like Kubernetes. The manifest included with the hello-world-python
template looks like this:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: python-http-hello-world
annotations:
version: v0.0.1
description: 'Python component that uses wasi:http'
experimental: 'true'
spec:
components:
- name: python-http
type: actor
properties:
image: file://./build/http_hello_world_s.wasm
traits:
# Govern the spread/scheduling of the actor
- type: spreadscaler
properties:
replicas: 1
# Link the HTTP server, and inform it to listen on port 8081
# on the local machine
- type: linkdef
properties:
target: httpserver
values:
ADDRESS: 127.0.0.1:8080
# Add a capability providers that mediates HTTP access
- name: httpserver
type: capability
properties:
image: wasmcloud.azurecr.io/httpserver:0.19.1
contract: wasmcloud:httpserver
The metadata fields provide naming and versioning for our application. We'll update the name to describe what we've built:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: python-http-hello-world
name: cruddy
annotations:
version: v0.0.1
description: 'Python component that uses wasi:http'
description: "CRUD demo"
experimental: "true"
Every time we publish a new version, we'll need to increment (or otherwise change) the version
annotation here.
Next in the spec, we have our components: the http-hello-world
templated component that we've been working on, and the httpserver
capability fulfilled by a capability provider. We'll explore capabilities more in a moment, but first, we'll give the component a link to the keyvalue
capability, specifying the address for the Redis service:
spec:
components:
- name: http-hello-world
type: actor
properties:
image: file://./build/http_hello_world_s.wasm
traits:
- type: spreadscaler
properties:
replicas: 1
- type: linkdef
properties:
target: httpserver
values:
address: 0.0.0.0:8080
- type: linkdef
properties:
target: keyvalue
values:
address: redis://127.0.0.1:6379
Finally, we'll add the keyvalue
capability provider as the final component that makes up our application. As part of this entry, we'll specify that the component—provided through the defined image—is fulfilling the contract for the keyvalue capability. wasmCloud operates on a zero-trust security model: without being explicitly linked to a capability, the application component would have no access to it.
- name: httpserver
type: capability
properties:
image: wasmcloud.azurecr.io/httpserver:0.19.1
contract: wasmcloud:httpserver
- name: keyvalue
type: capability
properties:
image: wasmcloud.azurecr.io/kvredis:0.22.0
contract: wasmcloud:keyvalue
We effectively promised our application component that someone would get the job done, and now these first-party providers (developed as part of the wasmCloud project) are stepping up to fill the role. A different provider—first-party, third-party, or of original design—could just as easily do the same. The same component we wrote could perform CRUDdy operations against any number of different key-value stores—etcd, MongoDB, Cassandra, or your own—as long as a provider exists for it. If a provider doesn't exist yet, you have all the tools you need to create one.
Once you put all of the pieces of wadm.yaml
together, the file should look like this:
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: cruddy
annotations:
version: v0.0.1
description: 'CRUD demo'
experimental: 'true'
spec:
components:
- name: http-hello-world
type: actor
properties:
image: file://./build/http_hello_world_s.wasm
traits:
- type: spreadscaler
properties:
replicas: 1
- type: linkdef
properties:
target: httpserver
values:
address: 0.0.0.0:8080
- type: linkdef
properties:
target: keyvalue
values:
address: redis://127.0.0.1:6379
- name: httpserver
type: capability
properties:
image: wasmcloud.azurecr.io/httpserver:0.19.1
contract: wasmcloud:httpserver
- name: keyvalue
type: capability
properties:
image: wasmcloud.azurecr.io/kvredis:0.22.0
contract: wasmcloud:keyvalue
Launch and iterate
Start a wasmCloud host with wash up
. Now we're ready to launch our app:
wash app deploy wadm.yaml
To view your wasmCloud apps and check status:
wash app list
Once the app status is Deployed
, we can test a PUT against our app with curl
:
curl -X PUT "localhost:8080?name=Alice"
We should get the result:
Added Alice
We can test a GET and DELETE as well:
curl "localhost:8080?name=Alice"
attended
curl -X DELETE "localhost:8080?name=Alice"
It's like Alice was never there
If we want to update our application, we can wash build
, update the version in wadm.yaml
, and wash app deploy wadm.yaml
again.
Note that wasmCloud includes an experimental feature for dev loop iteration. Rather than wash app deploy
, from your project directory you can run:
wash dev --experimental --host-id=<your-host-id>
This will start a dev deployment that continuously watches for changes to the .wasm
file for your component. If you would like to try this experimental feature, first clean up your existing deployment according to the instructions below.
Clean up
Once you're finished working with these tutorial materials, undeploy the application to stop it from running on the wasmCloud host and delete active links, freeing up associated ports:
wash app undeploy cruddy
To completely remove all versions of the application from wasmCloud:
wash app delete cruddy --delete-all
Next steps
In this tutorial, you've learned how to create a guestbook application component that uses WASI APIs including HTTP and Key-Value. In doing so, you've also learned how to read the WIT definitions for those APIs and utilize idiomatic bindings for our chosen language. With these fundamentals in place, a good next step might be to try building more complex components, explore other WASI APIs, or create your own interfaces and providers. If you have questions or feedback, join the wasmCloud Slack and let us know. Happy coding!