Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Async capability combinators #179

Merged
merged 15 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions crux_core/src/capabilities/compose.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! A capability which can spawn tasks which orchestrate across other capabilities. This
//! is useful for orchestrating a number of different effects into a single transaction.

use crate::capability::{CapabilityContext, Never};
use crate::Capability;
use futures::Future;

/// Compose capability can be used to orchestrate effects into a single transaction.
///
/// Example include:
/// * Running a number of HTTP requests in parallel and waiting for all to finish
/// * Chaining effects together, where the output of one is the input of the next and the intermediate
/// results are not useful to the app
/// * Implementing request timeouts by selecting across a HTTP effect and a time effect
/// * Any arbitrary graph of effects which depend on each other (or not).
///
/// Note that testing composed effects is more difficult, because it is not possible to enter the effect
/// transaction "in the middle" - only from the beginning - or to ignore some of the effects with out
/// stalling the entire downstream dependency chain.
pub struct Compose<Ev> {
context: CapabilityContext<Never, Ev>,
}

/// A restricted context given to the closure passed to [`Compose::spawn`]. This context can only
/// update the app, not request from the shell or spawn further tasks.
pub struct ComposeContext<Ev> {
context: CapabilityContext<Never, Ev>,
}

impl<Ev> ComposeContext<Ev> {
/// Update the app with an event. This forwards to [`CapabilityContext::update_app`].
pub fn update_app(&self, event: Ev)
where
Ev: 'static,
{
self.context.update_app(event);
}
}

impl<Ev> Compose<Ev> {
pub fn new(context: CapabilityContext<Never, Ev>) -> Self {
Self { context }
}

/// Spawn a task which orchestrates across other capabilities.
///
/// The argument is a closure which receives a [`ComposeContext`] which can be used to send
/// events to the app.
///
/// For example:
/// ```
/// fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
/// match event {
/// Event::Trigger => caps.compose.spawn(|context| {
/// let caps = Clone::clone(caps);
charypar marked this conversation as resolved.
Show resolved Hide resolved
///
/// async move {
/// let (result_one, result_two) =
/// join(caps.one.one_async(10), caps.two.two_async(20)).await;
///
/// context.update_app(Event::Finished(result_one, result_two))
/// }
/// }),
/// Event::Finished(one, two) => {
/// model.total = one + two;
/// }
/// }
/// }
/// ```
pub fn spawn<F, Fut>(&self, effects_task: F)
where
F: FnOnce(ComposeContext<Ev>) -> Fut,
Fut: Future<Output = ()> + 'static + Send,
Ev: 'static,
{
let context = self.context.clone();
self.context.spawn(effects_task(ComposeContext { context }));
}
}

impl<E> Clone for Compose<E> {
fn clone(&self) -> Self {
Self {
context: self.context.clone(),
}
}
}

impl<Ev> Capability<Ev> for Compose<Ev> {
type Operation = Never;
type MappedSelf<MappedEv> = Compose<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,
{
Compose::new(self.context.map_event(f))
}
}
1 change: 1 addition & 0 deletions crux_core/src/capabilities/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod compose;
pub mod render;
29 changes: 29 additions & 0 deletions crux_core/src/capability/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,35 @@ pub trait Operation: serde::Serialize + PartialEq + Send + 'static {
type Output: serde::de::DeserializeOwned + Send + 'static;
}

/// A type that can be used as a capability operation, but which will never be sent to the shell.
/// This type is useful for capabilities that don't request effects.
/// For example, you can use this type as the Operation for a
/// capability that just composes other capabilities.
///
/// e.g.
/// ```rust
///# use crux_core::capability::{CapabilityContext, Never};
///# use crux_macros::Capability;
/// #[derive(Capability)]
/// pub struct Orchestrate<E> {
/// context: CapabilityContext<Never, E>,
/// }
///# impl<E> Orchestrate<E> {
///# pub fn new(context: CapabilityContext<Never, E>) -> Self {
///# Self { context }
///# }
///# }
///
/// ```

#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Never {}

/// Implement `Operation` for `Never` to allow using it as a capability operation.
impl Operation for Never {
type Output = ();
}

/// Implement the `Capability` trait for your capability. This will allow
/// mapping events when composing apps from submodules.
///
Expand Down
238 changes: 238 additions & 0 deletions crux_core/tests/capability_orchestration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
mod app {
use crux_macros::Effect;
use futures::future::join;
use serde::Serialize;

#[derive(Default, Clone)]
pub struct App;

#[derive(Debug, PartialEq)]
pub enum Event {
Trigger,
Finished(usize, usize),
}

#[derive(Default, Serialize, Debug, PartialEq)]
pub struct Model {
pub total: usize,
}

#[derive(Effect, Clone)]
pub struct Capabilities {
one: super::capabilities::one::CapabilityOne<Event>,
two: super::capabilities::two::CapabilityTwo<Event>,
compose: crux_core::compose::Compose<Event>,
}

impl crux_core::App for App {
type Event = Event;
type Model = Model;
type ViewModel = Model;
type Capabilities = Capabilities;

fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
match event {
Event::Trigger => caps.compose.spawn(|context| {
let caps = Clone::clone(caps);

async move {
let (result_one, result_two) =
join(caps.one.one_async(10), caps.two.two_async(20)).await;

context.update_app(Event::Finished(result_one, result_two))
}
}),
Event::Finished(one, two) => {
model.total = one + two;
}
}
}

fn view(&self, _model: &Self::Model) -> Self::ViewModel {
todo!()
}
}
}

pub mod capabilities {
pub mod one {
use crux_core::capability::{CapabilityContext, Operation};
use crux_macros::Capability;
use serde::{Deserialize, Serialize};

#[derive(PartialEq, Serialize, Deserialize, Debug)]
pub struct OpOne {
number: usize,
}

impl Operation for OpOne {
type Output = usize;
}

#[derive(Capability)]
pub struct CapabilityOne<E> {
context: CapabilityContext<OpOne, E>,
}

// Needed to allow 'this = (*self).clone()' without requiring E: Clone
// See https://github.com/rust-lang/rust/issues/26925
impl<E> Clone for CapabilityOne<E> {
fn clone(&self) -> Self {
Self {
context: self.context.clone(),
}
}
}

impl<E> CapabilityOne<E> {
pub fn new(context: CapabilityContext<OpOne, E>) -> Self {
Self { context }
}

pub fn one<F>(&self, number: usize, event: F)
where
F: Fn(usize) -> E + Send + 'static,
E: 'static,
{
let this = Clone::clone(self);

this.context.spawn({
let this = this.clone();

async move {
let result = this.one_async(number).await;

this.context.update_app(event(result))
}
});
}

pub async fn one_async(&self, number: usize) -> usize
where
E: 'static,
{
self.context.request_from_shell(OpOne { number }).await
}
}
}

pub mod two {
use crux_core::capability::{CapabilityContext, Operation};
use crux_macros::Capability;
use serde::{Deserialize, Serialize};

#[derive(PartialEq, Serialize, Deserialize, Debug)]
pub struct OpTwo {
number: usize,
}

impl Operation for OpTwo {
type Output = usize;
}

#[derive(Capability)]
pub struct CapabilityTwo<E> {
context: CapabilityContext<OpTwo, E>,
}

// Needed to allow 'this = (*self).clone()' without requiring E: Clone
// See https://github.com/rust-lang/rust/issues/26925
impl<E> Clone for CapabilityTwo<E> {
fn clone(&self) -> Self {
Self {
context: self.context.clone(),
}
}
}

impl<E> CapabilityTwo<E> {
pub fn new(context: CapabilityContext<OpTwo, E>) -> Self {
Self { context }
}

pub fn two<F>(&self, number: usize, event: F)
where
F: Fn(usize) -> E + Send + 'static,
E: 'static,
{
let this = Clone::clone(self);

this.context.spawn({
let this = this.clone();

async move {
let result = this.two_async(number).await;

this.context.update_app(event(result))
}
});
}

pub async fn two_async(&self, number: usize) -> usize
where
E: 'static,
{
self.context.request_from_shell(OpTwo { number }).await
}
}
}
}

#[cfg(test)]
mod tests {
use crux_core::testing::AppTester;

use crate::app::{Event, Model};

use super::app::{App, Effect};

#[test]
fn updates_state_once_both_effects_are_done() {
let app: AppTester<App, Effect> = AppTester::default();
let mut model = Model::default();

let update = app.update(Event::Trigger, &mut model);

let mut effects = update.into_effects().filter(|e| e.is_one() || e.is_two());

// Resolve the first effect
// We should not see any events
match effects.next().expect("there should be an effect") {
Effect::CapabilityOne(mut req) => {
let update = app.resolve(&mut req, 1).expect("should resolve");

assert!(update.events.is_empty());
}
Effect::CapabilityTwo(mut req) => {
let update = app.resolve(&mut req, 2).expect("should resolve");

assert!(update.events.is_empty());
}
Effect::Compose(_) => unreachable!(),
}

// Resolve the second effect
// This time we _should_ see an event
let mut events = match effects.next().expect("there should be an effect") {
Effect::CapabilityOne(mut req) => {
let update = app.resolve(&mut req, 1).expect("should resolve");

update.events
}
Effect::CapabilityTwo(mut req) => {
let update = app.resolve(&mut req, 2).expect("should resolve");

update.events
}
Effect::Compose(_) => unreachable!(),
};

assert_eq!(events, vec![Event::Finished(1, 2)]);
let update = app.update(events.remove(0), &mut model);

assert!(update.effects.is_empty());
assert!(update.events.is_empty());

assert_eq!(model, Model { total: 3 });
}
}
Loading