Skip to content

Commit

Permalink
Add CLI commands to run DB migrations
Browse files Browse the repository at this point in the history
This prompted a refactor of the `RunCommand` trait to add `A: App` and
pass the `App` instance as a parameter in `RunCommand::run`. This is
possibly useful for other commands, and potentially for the consumer's
custom commands as well. Another benefit of passing the `App` instance
as a parameter is it simplifies the syntax for specifying the `A`
generic argument.

#41
  • Loading branch information
spencewenski committed Apr 7, 2024
1 parent 257e915 commit f394e3f
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 68 deletions.
24 changes: 6 additions & 18 deletions examples/minimal/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
use async_trait::async_trait;
use clap::{Parser, Subcommand};

use roadster::app_context::AppContext;
use roadster::cli::RunCommand;

use crate::app::App;
use crate::app_state::AppState;

/// Custom version of [RunCommand] that removes the `C` and `S` generics because we know what they
/// are so we don't need to provide them every time we want to implement a command.
#[async_trait]
pub trait RunAppCommand {
async fn run(&self, cli: &AppCli, state: &AppState) -> anyhow::Result<bool>;
}

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

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

#[async_trait]
impl RunAppCommand for AppCommand {
async fn run(&self, _cli: &AppCli, _state: &AppState) -> anyhow::Result<bool> {
impl RunCommand<App> for AppCommand {
async fn run(&self, _app: &App, _cli: &AppCli, _state: &AppState) -> anyhow::Result<bool> {
Ok(false)
}
}
2 changes: 1 addition & 1 deletion examples/minimal/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use roadster::app;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
app::start::<App>().await?;
app::start(App).await?;

Ok(())
}
16 changes: 9 additions & 7 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use tracing::{debug, error, info, instrument};

use crate::app_context::AppContext;
#[cfg(feature = "cli")]
use crate::cli::{RoadsterCli, RunCommand};
use crate::cli::{RoadsterCli, RunCommand, RunRoadsterCommand};
use crate::config::app_config::AppConfig;
#[cfg(not(feature = "cli"))]
use crate::config::environment::Environment;
Expand All @@ -40,7 +40,10 @@ use crate::tracing::init_tracing;
use crate::worker::queue_names;

// todo: this method is getting unweildy, we should break it up
pub async fn start<A>() -> anyhow::Result<()>
pub async fn start<A>(
// This parameter is (currently) not used when no features are enabled.
#[allow(unused_variables)] app: A,
) -> anyhow::Result<()>
where
A: App + Default + Send + Sync + 'static,
{
Expand Down Expand Up @@ -138,15 +141,14 @@ where

#[cfg(feature = "cli")]
{
if roadster_cli.run(&roadster_cli, &context, &state).await? {
if roadster_cli.run(&app, &roadster_cli, &context).await? {
return Ok(());
}
if app_cli.run(&app_cli, &context, &state).await? {
if app_cli.run(&app, &app_cli, &state).await? {
return Ok(());
}
}

// Todo: enable manual migrations
#[cfg(feature = "db-sql")]
if context.config.database.auto_migrate {
A::M::up(&context.db, None).await?;
Expand Down Expand Up @@ -290,10 +292,10 @@ where
}

#[async_trait]
pub trait App {
pub trait App: Send + Sync {
type State: From<Arc<AppContext>> + Into<Arc<AppContext>> + Clone + Send + Sync + 'static;
#[cfg(feature = "cli")]
type Cli: clap::Args + RunCommand<Self::Cli, Self::State>;
type Cli: clap::Args + RunCommand<Self>;
#[cfg(feature = "db-sql")]
type M: MigratorTrait;

Expand Down
13 changes: 11 additions & 2 deletions src/cli/list_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@ use async_trait::async_trait;
use clap::Parser;
use tracing::info;

use crate::app::App;
use crate::app_context::AppContext;
use crate::cli::{RoadsterCli, RunRoadsterCommand};

#[derive(Debug, Parser)]
pub struct ListRoutesArgs {}

#[async_trait]
impl RunRoadsterCommand for ListRoutesArgs {
async fn run(&self, _cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
impl<A> RunRoadsterCommand<A> for ListRoutesArgs
where
A: App,
{
async fn run(
&self,
_app: &A,
_cli: &RoadsterCli,
context: &AppContext,
) -> anyhow::Result<bool> {
info!("API routes:");
context
.api
Expand Down
85 changes: 85 additions & 0 deletions src/cli/migrate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use anyhow::bail;
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use sea_orm_migration::MigratorTrait;
use tracing::warn;

use crate::app::App;
use crate::app_context::AppContext;
use crate::cli::{RoadsterCli, RunRoadsterCommand};

#[derive(Debug, Parser)]
pub struct MigrateArgs {
#[clap(subcommand)]
pub command: MigrateCommand,
}

#[async_trait]
impl<A> RunRoadsterCommand<A> for MigrateArgs
where
A: App,
{
async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
self.command.run(app, cli, context).await
}
}

#[derive(Debug, Subcommand)]
pub enum MigrateCommand {
/// Apply pending migrations
Up(UpArgs),
/// Rollback applied migrations
Down(DownArgs),
/// Rollback all applied migrations, then reapply all migrations
Refresh,
/// Rollback all applied migrations
Reset,
/// Drop all tables from the database, then reapply all migrations
Fresh,
/// Check the status of all migrations
Status,
}

#[async_trait]
impl<A> RunRoadsterCommand<A> for MigrateCommand
where
A: App,
{
async fn run(&self, _app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
if is_destructive(self) && !cli.allow_dangerous(context) {
bail!("Running destructive command `{:?}` is not allowed in environment `{:?}`. To override, provide the `--allow-dangerous` CLI arg.", self, context.config.environment);
} else if is_destructive(self) {
warn!(
"Running destructive command `{:?}` in environment `{:?}`",
self, context.config.environment
);
}
match self {
MigrateCommand::Up(args) => A::M::up(&context.db, args.steps).await?,
MigrateCommand::Down(args) => A::M::down(&context.db, args.steps).await?,
MigrateCommand::Refresh => A::M::refresh(&context.db).await?,
MigrateCommand::Reset => A::M::reset(&context.db).await?,
MigrateCommand::Fresh => A::M::fresh(&context.db).await?,
MigrateCommand::Status => A::M::status(&context.db).await?,
};
Ok(true)
}
}

#[derive(Debug, Parser)]
pub struct UpArgs {
/// The number of pending migration steps to apply.
#[clap(short = 'n', long)]
pub steps: Option<u32>,
}

#[derive(Debug, Parser)]
pub struct DownArgs {
/// The number of applied migration steps to rollback.
#[clap(short = 'n', long)]
pub steps: Option<u32>,
}

fn is_destructive(command: &MigrateCommand) -> bool {
!matches!(command, MigrateCommand::Status)
}
96 changes: 60 additions & 36 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
use async_trait::async_trait;
use clap::{Parser, Subcommand};

use crate::app::App;
use crate::app_context::AppContext;
#[cfg(feature = "open-api")]
use crate::cli::list_routes::ListRoutesArgs;
#[cfg(feature = "db-sql")]
use crate::cli::migrate::MigrateArgs;
#[cfg(feature = "open-api")]
use crate::cli::open_api_schema::OpenApiArgs;
use crate::config::environment::Environment;

#[cfg(feature = "open-api")]
pub mod list_routes;
#[cfg(feature = "db-sql")]
pub mod migrate;
#[cfg(feature = "open-api")]
pub mod open_api_schema;

/// Implement to enable Roadster to run your custom CLI commands.
#[async_trait]
pub trait RunCommand<C, S>
pub trait RunCommand<A>
where
C: clap::Args,
S: Sync,
A: App + ?Sized + Sync,
{
/// Run the command.
///
Expand All @@ -29,21 +33,18 @@ 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.
///
/// # Arguments
///
/// * `cli` - The root-level clap args that were parsed, e.g. [RoadsterCli] or [crate::app::App::Cli].
/// * `context` - The [context][AppContext] for the app.
/// * `state` - The [state][crate::app::App::State] for the app.
async fn run(&self, cli: &C, context: &AppContext, state: &S) -> anyhow::Result<bool>;
async fn run(&self, app: &A, cli: &A::Cli, state: &A::State) -> anyhow::Result<bool>;
}

/// Specialized version of [RunCommand] that removes the `C` and `S` generics because we know what
/// `C` is and we don't need the custom app state `S` within roadster, so we don't need to provide
/// them everytime time we want to implement a roadster command.
/// Internal version of [RunCommand] that uses the [RoadsterCli] and [AppContext] instead of
/// the consuming app's versions of these objects. This (slightly) reduces the boilerplate
/// required to implement a Roadster command.
#[async_trait]
trait RunRoadsterCommand {
async fn run(&self, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool>;
pub(crate) trait RunRoadsterCommand<A>
where
A: App,
{
async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool>;
}

/// Roadster: The Roadster CLI provides various utilities for managing your application. If no subcommand
Expand All @@ -55,25 +56,31 @@ pub struct RoadsterCli {
/// environment variable if it's set.
#[clap(short, long)]
pub environment: Option<Environment>,

/// Allow dangerous/destructive operations when running in the `production` environment. If
/// this argument is not provided, dangerous/destructive operations will not be performed
/// when running in `production`.
#[clap(long, action)]
pub allow_dangerous: bool,

#[command(subcommand)]
pub command: Option<RoadsterCommand>,
}

/// We implement [RunCommand] instead of [RunRoadsterCommand] for the top-level [RoadsterCli] so
/// we can run the roadster cli in the same way as the app-specific cli.
impl RoadsterCli {
pub fn allow_dangerous(&self, context: &AppContext) -> bool {
context.config.environment != Environment::Production || self.allow_dangerous
}
}

#[async_trait]
impl<S> RunCommand<RoadsterCli, S> for RoadsterCli
impl<A> RunRoadsterCommand<A> for RoadsterCli
where
S: Sync,
A: App,
{
async fn run(
&self,
cli: &RoadsterCli,
context: &AppContext,
_state: &S,
) -> anyhow::Result<bool> {
async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
if let Some(command) = self.command.as_ref() {
command.run(cli, context).await
command.run(app, cli, context).await
} else {
Ok(false)
}
Expand All @@ -89,10 +96,13 @@ pub enum RoadsterCommand {
}

#[async_trait]
impl RunRoadsterCommand for RoadsterCommand {
async fn run(&self, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
impl<A> RunRoadsterCommand<A> for RoadsterCommand
where
A: App,
{
async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
match self {
RoadsterCommand::Roadster(args) => args.run(cli, context).await,
RoadsterCommand::Roadster(args) => args.run(app, cli, context).await,
}
}
}
Expand All @@ -104,9 +114,12 @@ pub struct RoadsterArgs {
}

#[async_trait]
impl RunRoadsterCommand for RoadsterArgs {
async fn run(&self, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
self.command.run(cli, context).await
impl<A> RunRoadsterCommand<A> for RoadsterArgs
where
A: App,
{
async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
self.command.run(app, cli, context).await
}
}

Expand All @@ -116,20 +129,31 @@ pub enum RoadsterSubCommand {
/// using the `Aide` crate will be included in the output.
#[cfg(feature = "open-api")]
ListRoutes(ListRoutesArgs),

/// Generate an OpenAPI 3.1 schema for the app's API routes. Note: only the routes defined
/// using the `Aide` crate will be included in the schema.
#[cfg(feature = "open-api")]
OpenApi(OpenApiArgs),

/// Perform DB operations using SeaORM migrations.
#[cfg(feature = "db-sql")]
#[clap(visible_aliases = ["m", "migration"])]
Migrate(MigrateArgs),
}

#[async_trait]
impl RunRoadsterCommand for RoadsterSubCommand {
async fn run(&self, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
impl<A> RunRoadsterCommand<A> for RoadsterSubCommand
where
A: App,
{
async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result<bool> {
match self {
#[cfg(feature = "open-api")]
RoadsterSubCommand::ListRoutes(args) => args.run(cli, context).await,
RoadsterSubCommand::ListRoutes(args) => args.run(app, cli, context).await,
#[cfg(feature = "open-api")]
RoadsterSubCommand::OpenApi(args) => args.run(cli, context).await,
RoadsterSubCommand::OpenApi(args) => args.run(app, cli, context).await,
#[cfg(feature = "db-sql")]
RoadsterSubCommand::Migrate(args) => args.run(app, cli, context).await,
}
}
}
Loading

0 comments on commit f394e3f

Please sign in to comment.