Skip to main content
Kevin Hoffman
Kevin Hoffman
Kevin Hoffman
||12 min read

Build with Cosmonic - Part 1: Build and Deploy a Leaderboard Service with Rust and Wasm

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:

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:

ResourceMethodDescription
/leaderboardsGETRetrieves the names and IDs of all leaderboards
/leaderboardsPOSTCreates a new leaderboard or fetch existing
/leaderboards/{id}GETRetrieves a leaderboard with a given ID
/leaderboards/{id}/scoresPOSTAdds 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):

two hosts

With these two hosts running, you can move on to the next steps:

  • 🚀 From inside the leaderboard project directory, run cosmo 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.

Cosmonic constellation

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

Keep up to date

Subscribe to Cosmonic for occasional communication straight to your inbox.