Capability APIs
In the previous chapter, we looked at the purpose of Capabilities and using them in Crux apps. In this one, we'll go through building our own. It will be a simple one, but real enough to show the key parts.
We'll extend the Counter example we've built in the Hello World chapter and make it worse. Intentionally. We'll add a random delay before we actually update the counter, just to annoy the user (please don't do that in your real apps). It is a silly example, but it will allow us to demonstrate a few things:
- Random numbers, current time and delay are also side-effects
- To introduce a random delay, we will need to chain two effects behind a single capability call
- The capability can also offer specific time delay API and we can show how capabilities with multiple operations work.
In fact, let's start with that.
Basic delay capability
The first job of our capability will be to pause for a given number of milliseconds and then send an event to the app.
There's a number of types and traits we will need to implement to make the capability work with the rest of Crux, so let's quickly go over them before we start. We will need
- The capability itself, able to hold on to the context used to interact with Crux
- The payload type for the effect, holding the number of milliseconds requested
- Implementation of the
Capability
trait
Let's start with the payload:
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct DelayOperation {
millis: usize
}
The request is just a named type holding onto a number. It will need to cross the FFI boundary, which is why it needs to be serializable, cloneable, etc.
We will need our request to implement the Operation
trait, which links it with
the type of the response we expect back. In our case we expect a response, but
there is no data, so we'll use the unit type.
use crux_core::capability::Operation;
impl Operation for DelayOperation {
type Output = ();
}
Now we can implement the capability:
use crux_core::capability::CapabilityContext;
use crux_core::macros::Capability;
#[derive(Capability)]
struct Delay<Ev> {
context: CapabilityContext<DelayOperation, Ev>,
}
impl<Ev> Delay<Ev>
where
Ev: 'static,
{
pub fn new(context: CapabilityContext<DelayOperation, Ev>) -> Self {
Self { context }
}
pub fn milliseconds(&self, millis: usize, event: Ev)
where
Ev: Send,
{
let ctx = self.context.clone();
self.context.spawn(async move {
ctx.request_from_shell(DelayOperation { millis }).await;
ctx.update_app(event);
});
}
}
There's a fair bit going on. The capability is generic over an event type Ev
and holds on to a CapabilityContext
. The constructor will be called by Crux
when starting an application that uses this capability.
The milliseconds
method is our capability's public API. It takes the delay in
milliseconds and the event to send back. In this case, we don't expect any
payload to return, so we take the Ev
type directly. We'll shortly see what an
event with data looks like as well.
The implementation of the method has a little bit of boilerplate to enable us to
use async
code. First we clone the context to be able to use it in the async
block. Then we use the context to spawn an async move
code block in which
we'll be able to use async
/await
. This bit of code will be the same in every
part of your capability that needs to interact with the Shell.
You can see we use two APIs to orchestrate the interaction. First
request_from_shell
sends the delay operation we made earlier to the Shell.
This call returns a future, which we can .await
. Once done, we use the other
API update_app
to dispatch the event we were given. At the .await
, the task
will be suspended, Crux will pass the operation to the Shell wrapped in the
Effect
type we talked about in the last chapter and the Shell will use its
native APIs to wait for the given duration, and eventually respond. This will
wake our task up again and we can continue working.
Finally, we need to implement the Capability
trait. This is done for us by the
#[derive(Capability)]
macro, but it is worth looking at the generated code:
impl<Ev> Capability<Ev> for Delay<Ev> {
type Operation = DelayOperation;
type MappedSelf<MappedEv> = Delay<MappedEv>;
fn map_event<F, NewEv>(&self, f: F) -> Self::MappedSelf<NewEv>
where
F: Fn(NewEv) -> Ev + Send + Sync + Copy + 'static,
Ev: 'static,
NewEv: 'static,
{
Delay::new(self.context.map_event(f))
}
}
What on earth is that for, you ask? This allows you to derive an instance of the
Delay
capability from an existing one and adapt it to a different Event
type. Yes, we know, don't read that sentence again. This will be useful to allow
composing Crux apps from smaller Crux apps to automatically wrap the child
events in the parent events.
We will cover this in depth in the chapter about Composable applications.
Random delays
To make the example more contrived, but also more educational, we'll add the random delay ability. This will
- Request a random number within given limits from the shell
- Then request the shell to delay by that number
- Then update the application, passing the number along, in case it is needed
First off, we need to add the new operation in. Here we have a choice, we can add a random delay operation, or we can add a random number generation operation and compose the two building blocks ourselves. We'll go for the second option because... well because this is an example.
Since we have multiple operations now, let's make our operation an enum
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum DelayOperation {
GetRandom(usize, usize),
Delay(usize),
}
We now also need an output type:
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum DelayOutput {
Random(usize),
TimeUp
}
And that changes the Operation
trait implementation:
impl Operation for DelayOperation {
type Output = DelayOutput;
}
The updated implementation looks like the following:
impl<Ev> Delay<Ev>
where
Ev: 'static,
{
pub fn new(context: CapabilityContext<DelayOperation, Ev>) -> Self {
Self { context }
}
pub fn milliseconds(&self, millis: usize, event: Ev) {
let ctx = self.context.clone();
self.context.spawn(async move {
ctx.request_from_shell(DelayOperation::Delay(millis)).await; // Changed
ctx.update_app(event);
});
}
pub fn random<F>(&self, min: usize, max: usize, event: F)
where F: Fn(usize) -> Ev
{
let ctx = self.context.clone();
self.context.spawn(async move {
let response = ctx.request_from_shell(DelayOperation::GetRandom(min, max)).await;
let DelayOutput::Random(millis) = response else {
panic!("Expected a random number")
};
ctx.request_from_shell(DelayOperation::Delay(millis)).await;
ctx.update_app(event(millis));
});
}
}
In the new API, the event handling is a little different from the original.
Because the event has a payload, we don't simply take an Ev
, we need a
function that returns Ev
, if given the random number. Seems cumbersome but
you'll see using it in the update
function of our app is quite natural:
fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
match event {
//
// ... Some events omitted
//
Event::Increment => {
caps.delay.random(200, 800, Event::DoIncrement);
}
Event::DoIncrement(_millis) => {
// optimistic update
model.count.value += 1;
model.confirmed = Some(false);
caps.render.render();
// real update
let base = Url::parse(API_URL).unwrap();
let url = base.join("/inc").unwrap();
caps.http.post(url.as_str()).expect_json().send(Event::Set);
}
Event::Decrement => {
caps.delay.milliseconds(500, Event::DoIncrement);
}
Event::DoDecrement => {
// optimistic update
model.count.value -= 1;
model.confirmed = Some(false);
caps.render.render();
// real update
let base = Url::parse(API_URL).unwrap();
let url = base.join("/dec").unwrap();
caps.http.post(url.as_str()).expect_json().send(Event::Set);
}
}
}
That is essentially it for the capabilities. You can check out the complete context API in the docs.