One of the many things that Cosmonic makes incredibly simple is building and deploying services. In this post, we'll show how easy it is to build a service from scratch, and how a service can shift from monolith to globally distributed function at runtime without rebuilding.
Series
This blog post is part of a series on building a applications on Cosmonic. If you haven't read the previous posts, you may want to start there:
- Part 1: Deploy a Leaderboard Service (this post)
- Part 2: Build a React Frontend for the Leaderboard API
Introduction
We design, build, and deploy new services all the time. But is that process low friction? In most cases, it isn't. In this blog post, let's build and deploy a service to take a look at what a low-friction process feels like that doesn't lock us into a single deployment footprint.
Let's assume that you have a number of services in your backend and many of them need to store data in the shape of a leaderboard. You want to expose this service to your backend so that code from other teams can create leaderboards and add scores. The API is fairly straightforward and we'll expose it over HTTP to keep things simple.
We're going to want the following API:
Resource | Method | Description |
---|---|---|
/leaderboards | GET | Retrieves the names and IDs of all leaderboards |
/leaderboards | POST | Creates a new leaderboard or fetch existing |
/leaderboards/{id} | GET | Retrieves a leaderboard with a given ID |
/leaderboards/{id}/scores | POST | Adds a score to the leaderboard. Note that the score could "fall off" the bottom if low enough |
In a regular development scenario, we'd go off and create an application in something like Go, pick a router library, pick an HTTP server library, paste in a pile of boilerplate that deals with configuring the port(s) used by the server, IP listening, IPv6 (if applicable), TLS (if applicable), and the list goes on, and on, and on... We'd spend days shoveling boilerplate from old projects into the new project before we ever even got to the core business purpose of managing leaderboards.
The worst part would be that our new application is permanently coupled to all those new dependencies from the boilerplate and non-functional requirements. We wouldn't be able to change our minds next week to pivot to a serverless function without ripping all the guts out of this code.
Build It
Let's go through the process of creating and building the leaderboard actor.
Create an Actor Project
To get started, run the following cosmo
command in your favorite terminal:
$ cosmo new actor
✔ Select a project template: · hello: a hello-world actor (in Rust) that responds over an http connection
🤷 Project Name : leaderboard
🔧 Cloning template from repo wasmCloud/project-templates subfolder actor/hello...
🔧 Using template subfolder actor/hello...
🔧 Generating template...
✨ Done! New project created /Users/kevin/code/scratch/leaderboard
At this point you now have a fully functioning actor in the leaderboard
directory. Go ahead and
cd
into that directory and run cosmo build
to make sure your toolchain is installed and working.
Add Claims and Dependencies
Next, we know we're going to want to store our leaderboard data in a key value store, so modify the
wasmcloud.toml
file to include both the HTTP server and key-value claims:
name = "leaderboard"
language = "rust"
type = "actor"
version = "0.1.0"
[actor]
claims = ["wasmcloud:httpserver", "wasmcloud:keyvalue"]
Likewise we'll need to add a reference to the key value interface and serde
and serde_json
, so
modify your Cargo.toml
to look as follows:
[package]
name = "leaderboard"
version = "0.1.0"
authors = [ "" ]
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
name = "leaderboard"
[dependencies]
futures = "0.3"
wasmbus-rpc = "0.14"
wasmcloud-interface-httpserver = "0.11.0"
wasmcloud-interface-keyvalue = "0.11.0"
serde = "1.0.162"
serde_json = "1.0.96"
[profile.release]
# Optimize for small code size
lto = true
opt-level = "s"
strip = true
Handling Requests
Next, let's update the handle_request
function in the leaderboard actor to reflect the desired
API:
async fn handle_request(&self, ctx: &Context, req: &HttpRequest) -> RpcResult<HttpResponse> {
let path = &req.path[1..req.path.len()];
let segments: Vec<&str> = path.trim_end_matches('/').split('/').collect();
match (req.method.as_ref(), segments.as_slice()) {
("GET", ["leaderboards"]) => get_leaderboards(ctx).await,
("POST", ["leaderboards"]) => create_leaderboard(ctx, deser_json(&req.body)?).await,
("GET", ["leaderboards", leaderboard_id]) => get_leaderboard(ctx, leaderboard_id).await,
("POST", ["leaderboards", leaderboard_id, "scores"]) =>
post_score(ctx, leaderboard_id, deser_json(&req.body)?).await,
(_, _) => Ok(HttpResponse::not_found())
}
}
This looks exactly like the API we wanted to build, and there isn't a drop of configuration, TLS,
port management, or even server dependency in this code. The best is yet to come! The business logic
for post_score
should be really simple: grab an existing leaderboard, add the value and trim
accordingly. Unfortunately, this simple bit of logic often gets lost in the swamp of side-effects
and tightly coupled dependencies.
Let's look at a better way to define this function, in the actor:
async fn post_score(ctx: &Context, id: &str, score: ScoreRecord) -> RpcResult<HttpResponse> {
let kv = KeyValueSender::new();
let key = format!("leaderboard.{}", id);
let Ok(res) = kv.get(ctx, &key).await else {
return Ok(HttpResponse::not_found())
};
if res.exists {
let mut board: Leaderboard =
serde_json::from_str(&res.value).map_err(|e| RpcError::Deser(e.to_string()))?;
board.add_score(score);
kv.set(
ctx,
&SetRequest {
key,
value: serde_json::to_string(&board).unwrap(),
expires: 0,
},
)
.await?;
Ok(HttpResponse::ok(serde_json::to_string(&board).unwrap()))
} else {
Ok(HttpResponse::not_found())
}
}
There's something subtle but extremely powerful going on here. This code communicates with a key-value store, but it doesn't know or care about which store. There's no connection string, no URL, no credentials, and no explicit choice of database vendor. All of that is managed at runtime. This means this code will work with any provider that implements this contract, including test harness providers.
And here's the add_score
function on the Leaderboard
struct:
fn add_score(&mut self, score: ScoreRecord) {
self.scores.push(score);
self.scores.sort_by(|a, b| b.value.cmp(&a.value));
self.scores.truncate(MAX_SCORES as _);
}
This should give us a reverse sort so that the highest scores are stored in descending order. The
rest of the code is just the implementation of create_leaderboard
, get_leaderboards
, and
get_leaderboard
. These all make use of the
wasmCloud key-value
interface.
Full code listing for the leaderboard actor
use serde::{Deserialize, Serialize};
use wasmbus_rpc::actor::prelude::*;
use wasmcloud_interface_httpserver::{HttpRequest, HttpResponse, HttpServer, HttpServerReceiver};
use wasmcloud_interface_keyvalue::{KeyValue, KeyValueSender, SetRequest};
#[derive(Debug, Default, Actor, HealthResponder)]
#[services(Actor, HttpServer)]
struct LeaderboardActor {}
// Enhancement idea: make the max scores a parameter on the create leaderboard operation
const MAX_SCORES: u8 = 10;
#[async_trait]
impl HttpServer for LeaderboardActor {
/// Process the HTTP API routes
async fn handle_request(&self, ctx: &Context, req: &HttpRequest) -> RpcResult<HttpResponse> {
let path = &req.path[1..req.path.len()];
let segments: Vec<&str> = path.trim_end_matches('/').split('/').collect();
let mut result = match (req.method.as_ref(), segments.as_slice()) {
("GET", ["leaderboards"]) => get_leaderboards(ctx).await,
("POST", ["leaderboards"]) => create_leaderboard(ctx, deser_json(&req.body)?).await,
("GET", ["leaderboards", leaderboard_id]) => get_leaderboard(ctx, leaderboard_id).await,
("POST", ["leaderboards", leaderboard_id, "scores"]) => {
post_score(ctx, leaderboard_id, deser_json(&req.body)?).await
}
(_, _) => Ok(HttpResponse::not_found()),
}?;
result.header.extend([
(
"Access-Control-Allow-Origin".to_owned(),
vec!["*".to_owned()],
),
(
"Content-Type".to_owned(),
vec!["application/json".to_owned()],
),
]);
Ok(result)
}
}
/// Creates a new leaderboard if one does not already exist. Stores a leaderboard.{id} item in the
/// KV store as well as a leaderboards item that maintains a JSON payload of the leaderboard
/// summaries.
async fn create_leaderboard(
ctx: &Context,
leaderboard: LeaderboardSummary,
) -> RpcResult<HttpResponse> {
let lb = Leaderboard {
id: leaderboard.id.to_string(),
name: leaderboard.name.to_string(),
scores: vec![],
};
let raw = serde_json::to_string(&lb).map_err(|e| RpcError::Ser(e.to_string()))?;
let key = format!("leaderboard.{}", leaderboard.id);
let kv = KeyValueSender::new();
let Ok(res) = kv.get(ctx, &format!("leaderboard.{}", leaderboard.id)).await else {
return Ok(HttpResponse::not_found())
};
let mut list = get_leaderboard_list(ctx).await;
if !res.exists {
kv.set(
ctx,
&SetRequest {
key,
value: raw,
expires: 0,
},
)
.await?;
list.push(leaderboard);
}
kv.set(
ctx,
&SetRequest {
key: "leaderboards".to_string(),
value: serde_json::to_string(&list).unwrap(),
expires: 0,
},
)
.await?;
Ok(HttpResponse::ok(
serde_json::to_string(&list).unwrap_or_default(),
))
}
/// Returns an HTTP 200 with a list of board summaries: ID and name.
async fn get_leaderboards(ctx: &Context) -> RpcResult<HttpResponse> {
let boards = get_leaderboard_list(ctx).await;
let raw = serde_json::to_vec(&boards).unwrap_or_default();
Ok(HttpResponse::ok(raw))
}
/// Utility function used to grab a list of leaderboard summaries
async fn get_leaderboard_list(ctx: &Context) -> Vec<LeaderboardSummary> {
let kv = KeyValueSender::new();
let Ok(res) = kv.get(ctx, "leaderboards").await else {
return vec![]
};
if res.exists {
serde_json::from_str(&res.value).unwrap_or_default()
} else {
vec![]
}
}
/// Retrieves leaderboard details and renders them
async fn get_leaderboard(ctx: &Context, id: &str) -> RpcResult<HttpResponse> {
let kv = KeyValueSender::new();
let Ok(res) = kv.get(ctx, &format!("leaderboard.{}", id)).await else {
return Ok(HttpResponse::not_found())
};
if res.exists {
Ok(HttpResponse::ok(res.value))
} else {
Ok(HttpResponse::not_found())
}
}
/// Posts a score to a leaderboard
async fn post_score(ctx: &Context, id: &str, score: ScoreRecord) -> RpcResult<HttpResponse> {
let kv = KeyValueSender::new();
let key = format!("leaderboard.{}", id);
let Ok(res) = kv.get(ctx, &key).await else {
return Ok(HttpResponse::not_found())
};
if res.exists {
let mut board: Leaderboard =
serde_json::from_str(&res.value).map_err(|e| RpcError::Deser(e.to_string()))?;
board.add_score(score);
kv.set(
ctx,
&SetRequest {
key,
value: serde_json::to_string(&board).unwrap(),
expires: 0,
},
)
.await?;
Ok(HttpResponse::ok(serde_json::to_string(&board).unwrap()))
} else {
Ok(HttpResponse::not_found())
}
}
/// Utility function to deserialize a JSON blob into a strongly typed value
fn deser_json<'de, T: Deserialize<'de>>(raw: &'de [u8]) -> RpcResult<T> {
serde_json::from_slice(raw).map_err(|e| RpcError::Deser(e.to_string()))
}
/// Represents a single row on a leaderboard
#[derive(Serialize, Deserialize, Default, Clone)]
struct ScoreRecord {
pub owner_id: String,
pub owner_name: String,
pub value: u32,
}
/// Represents a leaderboard summary and its contained scores
#[derive(Serialize, Deserialize, Default, Clone)]
struct Leaderboard {
pub id: String,
pub name: String,
pub scores: Vec<ScoreRecord>,
}
impl Leaderboard {
fn add_score(&mut self, score: ScoreRecord) {
self.scores.push(score);
self.scores.sort_by(|a, b| b.value.cmp(&a.value));
self.scores.truncate(MAX_SCORES as _);
}
}
/// Structure used when listing out summary information for leaderboards
#[derive(Serialize, Deserialize, Default, Clone)]
struct LeaderboardSummary {
pub id: String,
pub name: String,
}
You can find this code and all supporting files in the wasmCloud examples repository.
Make sure you can run cosmo build
in the project directory before continuing on.
Run It
Now for the fun part! Running this sample is super easy with only a few steps.
- Make sure you have a host running in Cosmonic's infrastructure (it will have the Cosmonic logo in the top right).
- Start the HTTP Server (wormhole) capability provider in your Cosmonic canvas. You don't need to provide any configuration: we take care of all the details.
- Start the Built-in Key Value provider in your constellation. Again, you don't need to provide any configuration.
- Run
cosmo up
(if you haven't already) to attach your local infrastructure to ours.
At this point, you should have two hosts visible in your constellation: one hosted by us and one running on your device. It will look something like this screenshot (the host names will differ):
With these two hosts running, you can move on to the next steps:
- 🚀 From inside the
leaderboard
project directory, runcosmo launch
. This compiles the actor, signs it with encryption keys, and deploys it to your local wasmCloud host. - Drag from the
wasmcloud:httpserver
contract on the leaderboard actor to the HTTP Server provider. Accept the defaults as you don't need to supply configuration. - Drag from the
wasmcloud:keyvalue
contract on the leaderboard actor to the Key Value provider. Accept the defaults as you don't need any configuration. - Click the
+
sign to add a wormhole. It should only give you the option to attach to the leaderboard actor via the HTTP server (wormhole) provider. You don't even need to provide configuration for this HTTP endpoint!
Now you have a publicly accessible HTTP endpoint that will go through Cosmonic infrastructure and invoke the actor you're developing on your local machine, all without ever opening a firewall port or messing with a VPN.
If everything has worked, then we should be able to start exercising our leaderboard API. To start, let's create a leaderboard (the domain for the HTTP endpoint in your environment will be different):
curl -X POST https://bitter-glitter-7562.cosmonic.app/leaderboards -d '{"id": "ktop99", "name": "Kevin Top 99"}'
Add a few scores to that new leaderboard:
curl -X POST https://bitter-glitter-7562.cosmonic.app/leaderboards/ktop99/scores -d '{"owner_id": "kevin", "owner_name": "Kevin", "value": 300}'
curl -X POST https://bitter-glitter-7562.cosmonic.app/leaderboards/ktop99/scores -d '{"owner_id": "kevin", "owner_name": "Kevin", "value": 700}'
curl -X POST https://bitter-glitter-7562.cosmonic.app/leaderboards/ktop99/scores -d '{"owner_id": "kevin", "owner_name": "Kevin", "value": 900}'
Query a leaderboard:
curl https://bitter-glitter-7562.cosmonic.app/leaderboards/ktop99 | jq
{
"id": "ktop99",
"name": "Kevin Top 99",
"scores": [
{
"owner_id": "kevin",
"owner_name": "Kevin",
"value": 900
},
{
"owner_id": "kevin",
"owner_name": "Kevin",
"value": 700
},
{
"owner_id": "kevin",
"owner_name": "Kevin",
"value": 300
}
]
}
The really fun part is going through your local, tight developer loop. If you're following along
on your workstation, make a change to the actor and simply re-run cosmo launch
: this will
automatically compile, sign, and re-deploy the actor.
In the current implementation, all scores are naively added to the leaderboard with no processing.
If you're enjoying this exercise, then you can try to modify the leaderboard code so that each
person (owner_id
) can only ever have 1 score on the board at a time. Also, the size (or depth) of
the leaderboards is currently fixed with a constant value. See if you can modify the code so that
when a board is created, the maximum size is one of the parameters.
Wrap-up
In this post we covered how easy it is to create an actor, deploy some built-in providers to Cosmonic, and then launch that actor in a tight development loop as you iterate over features and enhancements. If you found this sample interesting, we encourage you to tinker with it and share your experiences with us in our Discord. In the next post