Skip to content

Commit

Permalink
feat!: Switch to Axum's FromRef for custom state (#250)
Browse files Browse the repository at this point in the history
Our previous approach to allowing consumers to provide custom
state/context was to embed it as a field in Roadster's `AppContext`.
This is not how Axum recommends libraries support custom state, which
makes it difficult (impossible?) to integrate Roadster with other
Axum libraries.

The recommended approach is to keep the state generic, and add a type
constraint to require Roadster's state to be able to be retrieved from
the custom state using the
[FromRef](https://docs.rs/axum/latest/axum/extract/derive.FromRef.html)
trait. See the following for more details:
https://docs.rs/axum/latest/axum/extract/struct.State.html#for-library-authors

One example of an integration that was blocked by our non-recommended
approach: Leptos requires the app's state provide Leptos's state using
`FromRef`. In order to support Leptos with our previous approach, we
would have needed to add the Leptos state directly to Roadster's
`AppContext`. This adds a dependency on Roadster for consumers who may
want to use Leptos, and also adds a direct dependency between Roadster
and Leptos (though, it would be optional behind a feature flag). This
maybe could work in the short term, but this approach doesn't scale to
other libraries -- Roadster would need to follow a similar approach for
all other libraries consumers may want to use. Instead, it's better to
simply follow Axum's recommendation to provide maximum flexibility to
consumers.
  • Loading branch information
spencewenski authored Jun 30, 2024
1 parent 04632d8 commit 65f5db4
Show file tree
Hide file tree
Showing 52 changed files with 937 additions and 752 deletions.
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ rust-version = "1.74.1"

[features]
default = ["sidekiq", "db-sql", "open-api", "jwt-ietf", "cli", "otel"]
http = ["dep:axum", "dep:axum-extra", "dep:tower", "dep:tower-http"]
http = ["dep:axum-extra", "dep:tower", "dep:tower-http"]
open-api = ["http", "dep:aide", "dep:schemars"]
sidekiq = ["dep:rusty-sidekiq", "dep:bb8", "dep:num_cpus"]
db-sql = ["dep:sea-orm", "dep:sea-orm-migration"]
Expand Down Expand Up @@ -43,7 +43,9 @@ opentelemetry-otlp = { version = "0.16.0", features = ["metrics", "trace", "logs
tracing-opentelemetry = { version = "0.24.0", features = ["metrics"], optional = true }

# Controllers
axum = { workspace = true, optional = true }
# `axum` is not optional because we use the `FromRef` trait pretty extensively, even in parts of
# the code that wouldn't otherwise need `axum`.
axum = { workspace = true, features = ["macros"] }
axum-extra = { version = "0.9.0", features = ["typed-header"], optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.0", features = ["trace", "timeout", "request-id", "util", "normalize-path", "sensitive-headers", "catch-panic", "compression-full", "decompression-full", "limit", "cors"], optional = true }
Expand Down
21 changes: 10 additions & 11 deletions examples/full/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#[cfg(feature = "grpc")]
use crate::api::grpc::routes;
use crate::api::http;
use crate::app_state::CustomAppContext;
use crate::app_state::AppState;
use crate::cli::AppCli;
use crate::service::example::example_service;
use crate::worker::example::ExampleWorker;
Expand All @@ -26,8 +26,7 @@ const BASE: &str = "/api";
pub struct App;

#[async_trait]
impl RoadsterApp for App {
type State = CustomAppContext;
impl RoadsterApp<AppState> for App {
type Cli = AppCli;
type M = Migrator;

Expand All @@ -37,25 +36,25 @@ impl RoadsterApp for App {
.build())
}

async fn with_state(_context: &AppContext) -> RoadsterResult<Self::State> {
Ok(())
async fn provide_state(app_context: AppContext) -> RoadsterResult<AppState> {
Ok(AppState { app_context })
}

async fn services(
registry: &mut ServiceRegistry<Self>,
context: &AppContext<Self::State>,
registry: &mut ServiceRegistry<Self, AppState>,
state: &AppState,
) -> RoadsterResult<()> {
registry
.register_builder(
HttpService::builder(Some(BASE), context).api_router(http::routes(BASE)),
HttpService::builder(Some(BASE), state).api_router(http::routes(BASE)),
)
.await?;

registry
.register_builder(
SidekiqWorkerService::builder(context)
SidekiqWorkerService::builder(state)
.await?
.register_app_worker(ExampleWorker::build(context))?,
.register_app_worker(ExampleWorker::build(state))?,
)
.await?;

Expand All @@ -67,7 +66,7 @@ impl RoadsterApp for App {
)?;

#[cfg(feature = "grpc")]
registry.register_service(GrpcService::new(routes(context)?))?;
registry.register_service(GrpcService::new(routes(state)?))?;

Ok(())
}
Expand Down
8 changes: 5 additions & 3 deletions examples/full/src/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use axum::extract::FromRef;
use roadster::app::context::AppContext;

pub type CustomAppContext = ();

pub type AppState = AppContext<CustomAppContext>;
#[derive(Clone, FromRef)]
pub struct AppState {
pub app_context: AppContext,
}
27 changes: 7 additions & 20 deletions examples/full/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
use crate::app::App;
use crate::app_state::AppState;
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use roadster::app::context::AppContext;

use roadster::api::cli::RunCommand;
use roadster::error::RoadsterResult;

use crate::app::App;
use crate::app_state::CustomAppContext;

/// Full Example: Commands specific to managing the `full` app are provided in the CLI
/// as well. Subcommands not listed under the `roadster` subcommand are specific to `full`.
#[derive(Debug, Parser)]
Expand All @@ -19,16 +16,11 @@ pub struct AppCli {
}

#[async_trait]
impl RunCommand<App> for AppCli {
impl RunCommand<App, AppState> for AppCli {
#[allow(clippy::disallowed_types)]
async fn run(
&self,
app: &App,
cli: &AppCli,
context: &AppContext<CustomAppContext>,
) -> RoadsterResult<bool> {
async fn run(&self, app: &App, cli: &AppCli, state: &AppState) -> RoadsterResult<bool> {
if let Some(command) = self.command.as_ref() {
command.run(app, cli, context).await
command.run(app, cli, state).await
} else {
Ok(false)
}
Expand All @@ -42,13 +34,8 @@ impl RunCommand<App> for AppCli {
pub enum AppCommand {}

#[async_trait]
impl RunCommand<App> for AppCommand {
async fn run(
&self,
_app: &App,
_cli: &AppCli,
_context: &AppContext<CustomAppContext>,
) -> RoadsterResult<bool> {
impl RunCommand<App, AppState> for AppCommand {
async fn run(&self, _app: &App, _cli: &AppCli, _state: &AppState) -> RoadsterResult<bool> {
Ok(false)
}
}
5 changes: 2 additions & 3 deletions examples/full/src/worker/example.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use crate::app::App;
use crate::app_state::AppState;
use async_trait::async_trait;
use roadster::service::worker::sidekiq::app_worker::AppWorker;
Expand All @@ -17,8 +16,8 @@ impl Worker<String> for ExampleWorker {
}

#[async_trait]
impl AppWorker<App, String> for ExampleWorker {
fn build(_context: &AppState) -> Self {
impl AppWorker<AppState, String> for ExampleWorker {
fn build(_state: &AppState) -> Self {
Self {}
}
}
88 changes: 59 additions & 29 deletions src/api/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ use crate::app::App;
use crate::app::MockApp;
use crate::error::RoadsterResult;
use async_trait::async_trait;
use axum::extract::FromRef;
use clap::{Args, Command, FromArgMatches};
use std::ffi::OsString;

pub mod roadster;

/// Implement to enable Roadster to run your custom CLI commands.
#[async_trait]
pub trait RunCommand<A>
pub trait RunCommand<A, S>
where
A: App + ?Sized + Sync,
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
A: App<S> + ?Sized + Sync,
{
/// Run the command.
///
Expand All @@ -25,17 +28,14 @@ where
/// continue execution after the command is complete.
/// * `Err(...)` - If the implementation experienced an error while handling the command. The
/// app should end execution after the command is complete.
async fn run(
&self,
app: &A,
cli: &A::Cli,
context: &AppContext<A::State>,
) -> RoadsterResult<bool>;
async fn run(&self, app: &A, cli: &A::Cli, state: &S) -> RoadsterResult<bool>;
}

pub(crate) fn parse_cli<A, I, T>(args: I) -> RoadsterResult<(RoadsterCli, A::Cli)>
pub(crate) fn parse_cli<A, S, I, T>(args: I) -> RoadsterResult<(RoadsterCli, A::Cli)>
where
A: App,
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
A: App<S>,
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
Expand Down Expand Up @@ -78,47 +78,77 @@ where
Ok((roadster_cli, app_cli))
}

pub(crate) async fn handle_cli<A>(
pub(crate) async fn handle_cli<A, S>(
app: &A,
roadster_cli: &RoadsterCli,
app_cli: &A::Cli,
context: &AppContext<A::State>,
state: &S,
) -> RoadsterResult<bool>
where
A: App,
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
A: App<S>,
{
if roadster_cli.run(app, roadster_cli, context).await? {
if roadster_cli.run(app, roadster_cli, state).await? {
return Ok(true);
}
if app_cli.run(app, app_cli, context).await? {
if app_cli.run(app, app_cli, state).await? {
return Ok(true);
}
Ok(false)
}

#[cfg(test)]
pub struct TestCli<S>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{
_state: std::marker::PhantomData<S>,
}

#[cfg(test)]
mockall::mock! {
pub Cli {}
pub TestCli<S>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{}

#[async_trait]
impl RunCommand<MockApp> for Cli {
async fn run(
&self,
app: &MockApp,
cli: &<MockApp as App>::Cli,
context: &AppContext<<MockApp as App>::State>,
) -> RoadsterResult<bool>;
impl<S> RunCommand<MockApp<S>, S> for TestCli<S>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{
async fn run(&self, app: &MockApp<S>, cli: &<MockApp<S> as App<S>>::Cli, state: &S) -> RoadsterResult<bool>;
}

impl clap::FromArgMatches for Cli {
impl<S> clap::FromArgMatches for TestCli<S>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error>;
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error>;
}

impl clap::Args for Cli {
impl<S> clap::Args for TestCli<S>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{
fn augment_args(cmd: clap::Command) -> clap::Command;
fn augment_args_for_update(cmd: clap::Command) -> clap::Command;
}

impl<S> Clone for TestCli<S>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{
fn clone(&self) -> Self;
}
}

#[cfg(test)]
Expand Down Expand Up @@ -150,12 +180,12 @@ mod tests {
#[cfg_attr(coverage_nightly, coverage(off))]
fn parse_cli(_case: TestCase, #[case] args: Option<&str>, #[case] arg_list: Option<Vec<&str>>) {
// Arrange
let augment_args_context = MockCli::augment_args_context();
let augment_args_context = MockTestCli::<AppContext>::augment_args_context();
augment_args_context.expect().returning(|c| c);
let from_arg_matches_context = MockCli::from_arg_matches_context();
let from_arg_matches_context = MockTestCli::<AppContext>::from_arg_matches_context();
from_arg_matches_context
.expect()
.returning(|_| Ok(MockCli::default()));
.returning(|_| Ok(MockTestCli::<AppContext>::default()));

let args = if let Some(args) = args {
args.split(' ').collect_vec()
Expand All @@ -169,7 +199,7 @@ mod tests {
.collect_vec();

// Act
let (roadster_cli, _a) = super::parse_cli::<MockApp, _, _>(args).unwrap();
let (roadster_cli, _a) = super::parse_cli::<MockApp<AppContext>, _, _, _>(args).unwrap();

// Assert
assert_toml_snapshot!(roadster_cli);
Expand Down
15 changes: 7 additions & 8 deletions src/api/cli/roadster/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::app::context::AppContext;
use crate::app::App;
use crate::error::RoadsterResult;
use async_trait::async_trait;
use axum::extract::FromRef;
use clap::Parser;
use serde_derive::Serialize;
use tracing::info;
Expand All @@ -13,21 +14,19 @@ use tracing::info;
pub struct HealthArgs {}

#[async_trait]
impl<A> RunRoadsterCommand<A> for HealthArgs
impl<A, S> RunRoadsterCommand<A, S> for HealthArgs
where
A: App,
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
A: App<S>,
{
async fn run(
&self,
_app: &A,
_cli: &RoadsterCli,
#[allow(unused_variables)] context: &AppContext<A::State>,
#[allow(unused_variables)] state: &S,
) -> RoadsterResult<bool> {
let health = health_check::<A::State>(
#[cfg(any(feature = "sidekiq", feature = "db-sql"))]
context,
)
.await?;
let health = health_check(state).await?;
let health = serde_json::to_string_pretty(&health)?;
info!("\n{health}");
Ok(true)
Expand Down
Loading

0 comments on commit 65f5db4

Please sign in to comment.