From 96260c861224ef0972fc4e0f6c1da1b1e80588de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Mon, 25 Nov 2024 08:35:25 +0100 Subject: [PATCH] feat: implement flareon::main macro for easy framework bootstrapping (#77) This is the first step in making Flareon an actual "framework" rather than just a library. This macro currently doesn't do much now, but eventually we'd like to have an entire CLI that would be automatically generated for each Flareon project and would allow to do some common operations (run server, run migrations, copy static files to a directory, etc.), as well as allow Flareon users to define their own ones. --- Cargo.lock | 6 -- examples/admin/Cargo.toml | 2 - examples/admin/src/main.rs | 13 ++-- examples/hello-world/Cargo.toml | 1 - examples/hello-world/src/main.rs | 11 ++-- examples/sessions/Cargo.toml | 1 - examples/sessions/src/main.rs | 11 ++-- examples/todo-list/Cargo.toml | 2 - examples/todo-list/src/main.rs | 59 ++++++++++--------- flareon-macros/src/lib.rs | 10 ++++ flareon-macros/src/main_fn.rs | 40 +++++++++++++ flareon-macros/tests/compile_tests.rs | 8 +++ flareon-macros/tests/ui/attr_main.rs | 6 ++ flareon-macros/tests/ui/attr_main_args.rs | 4 ++ flareon-macros/tests/ui/attr_main_args.stderr | 11 ++++ flareon/Cargo.toml | 2 +- flareon/src/lib.rs | 58 +++++++++--------- flareon/src/private.rs | 1 + flareon/src/test.rs | 14 ++--- flareon/tests/router.rs | 4 +- 20 files changed, 161 insertions(+), 103 deletions(-) create mode 100644 flareon-macros/src/main_fn.rs create mode 100644 flareon-macros/tests/ui/attr_main.rs create mode 100644 flareon-macros/tests/ui/attr_main_args.rs create mode 100644 flareon-macros/tests/ui/attr_main_args.stderr diff --git a/Cargo.lock b/Cargo.lock index ad2854f..88bee32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,9 +800,7 @@ dependencies = [ name = "example-admin" version = "0.1.0" dependencies = [ - "env_logger", "flareon", - "tokio", ] [[package]] @@ -810,7 +808,6 @@ name = "example-hello-world" version = "0.1.0" dependencies = [ "flareon", - "tokio", ] [[package]] @@ -819,7 +816,6 @@ version = "0.1.0" dependencies = [ "askama", "flareon", - "tokio", ] [[package]] @@ -827,9 +823,7 @@ name = "example-todo-list" version = "0.1.0" dependencies = [ "askama", - "env_logger", "flareon", - "tokio", ] [[package]] diff --git a/examples/admin/Cargo.toml b/examples/admin/Cargo.toml index 6e74357..2f627c5 100644 --- a/examples/admin/Cargo.toml +++ b/examples/admin/Cargo.toml @@ -6,6 +6,4 @@ description = "Admin panel - Flareon example." edition = "2021" [dependencies] -env_logger = "0.11.5" flareon = { path = "../../flareon" } -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } diff --git a/examples/admin/src/main.rs b/examples/admin/src/main.rs index 6d41353..4c84ade 100644 --- a/examples/admin/src/main.rs +++ b/examples/admin/src/main.rs @@ -36,10 +36,8 @@ impl FlareonApp for HelloApp { } } -#[tokio::main] -async fn main() { - env_logger::init(); - +#[flareon::main] +async fn main() -> flareon::Result { let flareon_project = FlareonProject::builder() .config( ProjectConfig::builder() @@ -57,10 +55,7 @@ async fn main() { .middleware_with_context(StaticFilesMiddleware::from_app_context) .middleware(SessionMiddleware::new()) .build() - .await - .unwrap(); + .await?; - flareon::run(flareon_project, "127.0.0.1:8000") - .await - .unwrap(); + Ok(flareon_project) } diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index ea41f5e..42c46ba 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -7,4 +7,3 @@ edition = "2021" [dependencies] flareon = { path = "../../flareon" } -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } diff --git a/examples/hello-world/src/main.rs b/examples/hello-world/src/main.rs index 3d1a69f..1d766af 100644 --- a/examples/hello-world/src/main.rs +++ b/examples/hello-world/src/main.rs @@ -22,15 +22,12 @@ impl FlareonApp for HelloApp { } } -#[tokio::main] -async fn main() { +#[flareon::main] +async fn main() -> flareon::Result { let flareon_project = FlareonProject::builder() .register_app_with_views(HelloApp, "") .build() - .await - .unwrap(); + .await?; - flareon::run(flareon_project, "127.0.0.1:8000") - .await - .unwrap(); + Ok(flareon_project) } diff --git a/examples/sessions/Cargo.toml b/examples/sessions/Cargo.toml index cad9ad3..fb322f0 100644 --- a/examples/sessions/Cargo.toml +++ b/examples/sessions/Cargo.toml @@ -8,4 +8,3 @@ edition = "2021" [dependencies] askama = "0.12.1" flareon = { path = "../../flareon" } -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } diff --git a/examples/sessions/src/main.rs b/examples/sessions/src/main.rs index 7996f2e..7cc5bc2 100644 --- a/examples/sessions/src/main.rs +++ b/examples/sessions/src/main.rs @@ -82,16 +82,13 @@ impl FlareonApp for HelloApp { } } -#[tokio::main] -async fn main() { +#[flareon::main] +async fn main() -> flareon::Result { let flareon_project = FlareonProject::builder() .register_app_with_views(HelloApp, "") .middleware(SessionMiddleware::new()) .build() - .await - .unwrap(); + .await?; - flareon::run(flareon_project, "127.0.0.1:8000") - .await - .unwrap(); + Ok(flareon_project) } diff --git a/examples/todo-list/Cargo.toml b/examples/todo-list/Cargo.toml index 1f441a2..30347be 100644 --- a/examples/todo-list/Cargo.toml +++ b/examples/todo-list/Cargo.toml @@ -8,5 +8,3 @@ edition = "2021" [dependencies] askama = "0.12.1" flareon = { path = "../../flareon" } -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } -env_logger = "0.11.5" diff --git a/examples/todo-list/src/main.rs b/examples/todo-list/src/main.rs index c599ffb..4d0c2ae 100644 --- a/examples/todo-list/src/main.rs +++ b/examples/todo-list/src/main.rs @@ -1,14 +1,14 @@ mod migrations; use askama::Template; -use flareon::db::migrations::MigrationEngine; -use flareon::db::{model, query, Database, Model}; +use flareon::config::{DatabaseConfig, ProjectConfig}; +use flareon::db::migrations::DynMigration; +use flareon::db::{model, query, Model}; use flareon::forms::Form; use flareon::request::{Request, RequestExt}; use flareon::response::{Response, ResponseExt}; use flareon::router::{Route, Router}; use flareon::{reverse, Body, FlareonApp, FlareonProject, StatusCode}; -use tokio::sync::OnceCell; #[derive(Debug, Clone)] #[model] @@ -24,12 +24,8 @@ struct IndexTemplate<'a> { todo_items: Vec, } -static DB: OnceCell = OnceCell::const_new(); - async fn index(request: Request) -> flareon::Result { - let db = DB.get().unwrap(); - - let todo_items = TodoItem::objects().all(db).await?; + let todo_items = TodoItem::objects().all(request.db()).await?; let index_template = IndexTemplate { request: &request, todo_items, @@ -49,12 +45,11 @@ async fn add_todo(mut request: Request) -> flareon::Result { let todo_form = TodoForm::from_request(&mut request).await?.unwrap(); { - let db = DB.get().unwrap(); TodoItem { id: 0, title: todo_form.title, } - .save(db) + .save(request.db()) .await?; } @@ -69,8 +64,9 @@ async fn remove_todo(request: Request) -> flareon::Result { let todo_id = todo_id.parse::().expect("todo_id is not a number"); { - let db = DB.get().unwrap(); - query!(TodoItem, $id == todo_id).delete(db).await?; + query!(TodoItem, $id == todo_id) + .delete(request.db()) + .await?; } Ok(reverse!(request, "index")) @@ -83,6 +79,16 @@ impl FlareonApp for TodoApp { "todo-app" } + fn migrations(&self) -> Vec> { + // TODO: this is way too complicated for the user-facing API + #[allow(trivial_casts)] + migrations::MIGRATIONS + .iter() + .copied() + .map(|x| Box::new(x) as Box) + .collect() + } + fn router(&self) -> Router { Router::with_urls([ Route::with_handler_and_name("/", index, "index"), @@ -92,23 +98,22 @@ impl FlareonApp for TodoApp { } } -#[tokio::main] -async fn main() { - env_logger::init(); - - let db = DB - .get_or_init(|| async { Database::new("sqlite::memory:").await.unwrap() }) - .await; - MigrationEngine::new(migrations::MIGRATIONS.iter().copied()) - .run(db) - .await - .unwrap(); - +#[flareon::main] +async fn main() -> flareon::Result { let todo_project = FlareonProject::builder() + .config( + ProjectConfig::builder() + .database_config( + DatabaseConfig::builder() + .url("sqlite::memory:") + .build() + .unwrap(), + ) + .build(), + ) .register_app_with_views(TodoApp, "") .build() - .await - .unwrap(); + .await?; - flareon::run(todo_project, "127.0.0.1:8080").await.unwrap(); + Ok(todo_project) } diff --git a/flareon-macros/src/lib.rs b/flareon-macros/src/lib.rs index 8b1063a..338f6bb 100644 --- a/flareon-macros/src/lib.rs +++ b/flareon-macros/src/lib.rs @@ -1,5 +1,6 @@ mod dbtest; mod form; +mod main_fn; mod model; mod query; @@ -12,6 +13,7 @@ use syn::{parse_macro_input, ItemFn}; use crate::dbtest::fn_to_dbtest; use crate::form::impl_form_for_struct; +use crate::main_fn::fn_to_flareon_main; use crate::model::impl_model_for_struct; use crate::query::{query_to_tokens, Query}; @@ -122,6 +124,14 @@ pub fn dbtest(_args: TokenStream, input: TokenStream) -> TokenStream { .into() } +#[proc_macro_attribute] +pub fn main(_args: TokenStream, input: TokenStream) -> TokenStream { + let fn_input = parse_macro_input!(input as ItemFn); + fn_to_flareon_main(fn_input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + pub(crate) fn flareon_ident() -> proc_macro2::TokenStream { let flareon_crate = crate_name("flareon").expect("flareon is not present in `Cargo.toml`"); match flareon_crate { diff --git a/flareon-macros/src/main_fn.rs b/flareon-macros/src/main_fn.rs new file mode 100644 index 0000000..dee1695 --- /dev/null +++ b/flareon-macros/src/main_fn.rs @@ -0,0 +1,40 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::ItemFn; + +use crate::flareon_ident; + +pub(super) fn fn_to_flareon_main(main_function_decl: ItemFn) -> syn::Result { + let mut new_main_decl = main_function_decl.clone(); + new_main_decl.sig.ident = + syn::Ident::new("__flareon_main", main_function_decl.sig.ident.span()); + + if !main_function_decl.sig.inputs.is_empty() { + return Err(syn::Error::new_spanned( + main_function_decl.sig.inputs, + "flareon::main function must have zero arguments", + )); + } + + let crate_name = flareon_ident(); + let result = quote! { + fn main() { + let body = async { + let project: #crate_name::FlareonProject = __flareon_main().await.unwrap(); + #crate_name::run_cli(project).await.unwrap(); + + #new_main_decl + }; + #[allow(clippy::expect_used)] + { + return #crate_name::__private::tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed building the Runtime") + .block_on(body); + } + } + + }; + Ok(result) +} diff --git a/flareon-macros/tests/compile_tests.rs b/flareon-macros/tests/compile_tests.rs index f10471f..53fa0c5 100644 --- a/flareon-macros/tests/compile_tests.rs +++ b/flareon-macros/tests/compile_tests.rs @@ -26,3 +26,11 @@ fn func_query() { t.compile_fail("tests/ui/func_query_double_field.rs"); t.compile_fail("tests/ui/func_query_invalid_field.rs"); } + +#[rustversion::attr(not(nightly), ignore)] +#[test] +fn attr_main() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/attr_main.rs"); + t.compile_fail("tests/ui/attr_main_args.rs"); +} diff --git a/flareon-macros/tests/ui/attr_main.rs b/flareon-macros/tests/ui/attr_main.rs new file mode 100644 index 0000000..0b028c4 --- /dev/null +++ b/flareon-macros/tests/ui/attr_main.rs @@ -0,0 +1,6 @@ +use flareon::FlareonProject; + +#[flareon::main] +async fn main() -> flareon::Result { + std::process::exit(0); +} diff --git a/flareon-macros/tests/ui/attr_main_args.rs b/flareon-macros/tests/ui/attr_main_args.rs new file mode 100644 index 0000000..e69d3ab --- /dev/null +++ b/flareon-macros/tests/ui/attr_main_args.rs @@ -0,0 +1,4 @@ +#[flareon::main] +async fn main(arg: i32) -> flareon::Result { + std::process::exit(0); +} diff --git a/flareon-macros/tests/ui/attr_main_args.stderr b/flareon-macros/tests/ui/attr_main_args.stderr new file mode 100644 index 0000000..f5cf275 --- /dev/null +++ b/flareon-macros/tests/ui/attr_main_args.stderr @@ -0,0 +1,11 @@ +error: flareon::main function must have zero arguments + --> tests/ui/attr_main_args.rs:2:15 + | +2 | async fn main(arg: i32) -> flareon::Result { + | ^^^^^^^^ + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/ui/attr_main_args.rs:4:2 + | +4 | } + | ^ consider adding a `main` function to `$DIR/tests/ui/attr_main_args.rs` diff --git a/flareon/Cargo.toml b/flareon/Cargo.toml index e7e177f..fc53410 100644 --- a/flareon/Cargo.toml +++ b/flareon/Cargo.toml @@ -43,7 +43,7 @@ sync_wrapper.workspace = true thiserror.workspace = true time.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -tower.workspace = true +tower = { workspace = true, features = ["util"] } tower-sessions = { workspace = true, features = ["memory-store"] } [dev-dependencies] diff --git a/flareon/src/lib.rs b/flareon/src/lib.rs index e9a4617..4270a8a 100644 --- a/flareon/src/lib.rs +++ b/flareon/src/lib.rs @@ -78,6 +78,7 @@ use derive_more::{Debug, Deref, Display, From}; pub use error::Error; use flareon::config::DatabaseConfig; use flareon::router::RouterService; +pub use flareon_macros::main; use futures_core::Stream; use futures_util::FutureExt; use http::request::Parts; @@ -86,6 +87,7 @@ use log::info; use request::Request; use router::{Route, Router}; use sync_wrapper::SyncWrapper; +use tower::util::BoxCloneService; use tower::Service; use crate::admin::AdminModelManager; @@ -333,12 +335,13 @@ impl http_body::Body for Body { } } +pub type BoxedHandler = BoxCloneService; + /// A Flareon project, ready to be run. #[derive(Debug)] -// TODO add Middleware type? -pub struct FlareonProject { +pub struct FlareonProject { context: AppContext, - handler: S, + handler: BoxedHandler, } /// A part of [`FlareonProject`] that contains the shared context and configs @@ -466,7 +469,7 @@ impl FlareonProjectBuilder { } /// Builds the Flareon project instance. - pub async fn build(self) -> Result> { + pub async fn build(self) -> Result { self.into_builder_with_service().build().await } @@ -483,7 +486,11 @@ impl FlareonProjectBuilder { } } -impl> FlareonProjectBuilder { +impl FlareonProjectBuilder +where + S: Service + Send + Sync + Clone + 'static, + S::Future: Send, +{ #[must_use] pub fn middleware>( self, @@ -510,13 +517,13 @@ impl> FlareonProjectBuilder { } /// Builds the Flareon project instance. - pub async fn build(mut self) -> Result> { + pub async fn build(mut self) -> Result { let database = Self::init_database(self.context.config.database_config()).await?; self.context.database = Some(database); Ok(FlareonProject { context: self.context, - handler: self.handler, + handler: BoxedHandler::new(self.handler), }) } @@ -532,19 +539,14 @@ impl Default for FlareonProjectBuilder { } } -impl FlareonProject<()> { +impl FlareonProject { #[must_use] pub fn builder() -> FlareonProjectBuilder { FlareonProjectBuilder::default() } -} -impl FlareonProject -where - S: Service + Send + Sync + Clone + 'static, -{ #[must_use] - pub fn into_context(self) -> (AppContext, S) { + pub fn into_context(self) -> (AppContext, BoxedHandler) { (self.context, self.handler) } } @@ -557,11 +559,7 @@ where /// # Errors /// /// This function returns an error if the server fails to start. -pub async fn run(project: FlareonProject, address_str: &str) -> Result<()> -where - S: Service + Send + Sync + Clone + 'static, - S::Future: Send, -{ +pub async fn run(project: FlareonProject, address_str: &str) -> Result<()> { let listener = tokio::net::TcpListener::bind(address_str) .await .map_err(|e| ErrorRepr::StartServer { source: e })?; @@ -582,11 +580,7 @@ where /// # Errors /// /// This function returns an error if the server fails to start. -pub async fn run_at(project: FlareonProject, listener: tokio::net::TcpListener) -> Result<()> -where - S: Service + Send + Sync + Clone + 'static, - S::Future: Send, -{ +pub async fn run_at(project: FlareonProject, listener: tokio::net::TcpListener) -> Result<()> { let (mut context, mut project_handler) = project.into_context(); if let Some(database) = &context.database { @@ -673,6 +667,13 @@ where Ok(()) } +pub async fn run_cli(project: FlareonProject) -> Result<()> { + // TODO: we want to have a (extensible) CLI interface soon, but for simplicity + // we just run the server now + run(project, "127.0.0.1:8080").await?; + Ok(()) +} + fn request_parts_for_diagnostics(request: Request) -> (Option, Request) { if config::DEBUG_MODE { let (parts, body) = request.into_parts(); @@ -697,11 +698,10 @@ pub(crate) fn prepare_request(request: &mut Request, context: Arc) { request.extensions_mut().insert(context); } -async fn pass_to_axum(request: Request, handler: &mut S) -> Result -where - S: Service + Send + Sync + Clone + 'static, - S::Future: Send, -{ +async fn pass_to_axum( + request: Request, + handler: &mut BoxedHandler, +) -> Result { poll_fn(|cx| handler.poll_ready(cx)).await?; let response = handler.call(request).await?; diff --git a/flareon/src/private.rs b/flareon/src/private.rs index 80d7ee7..3ee22cc 100644 --- a/flareon/src/private.rs +++ b/flareon/src/private.rs @@ -7,3 +7,4 @@ pub use async_trait::async_trait; pub use bytes::Bytes; +pub use tokio; diff --git a/flareon/src/test.rs b/flareon/src/test.rs index 69efe63..8639dfc 100644 --- a/flareon/src/test.rs +++ b/flareon/src/test.rs @@ -17,24 +17,20 @@ use crate::db::Database; use crate::request::{Request, RequestExt}; use crate::response::Response; use crate::router::Router; -use crate::{AppContext, Body, Error, Result}; +use crate::{AppContext, Body, BoxedHandler, Result}; /// A test client for making requests to a Flareon project. /// /// Useful for End-to-End testing Flareon projects. #[derive(Debug)] -pub struct Client { +pub struct Client { context: Arc, - handler: S, + handler: BoxedHandler, } -impl Client -where - S: Service + Send + Sync + Clone + 'static, - S::Future: Send, -{ +impl Client { #[must_use] - pub fn new(project: FlareonProject) -> Self { + pub fn new(project: FlareonProject) -> Self { let (context, handler) = project.into_context(); Self { context: Arc::new(context), diff --git a/flareon/tests/router.rs b/flareon/tests/router.rs index 5b9fae5..f7bfc15 100644 --- a/flareon/tests/router.rs +++ b/flareon/tests/router.rs @@ -1,7 +1,7 @@ use bytes::Bytes; use flareon::request::{Request, RequestExt}; use flareon::response::{Response, ResponseExt}; -use flareon::router::{Route, Router, RouterService}; +use flareon::router::{Route, Router}; use flareon::test::Client; use flareon::{Body, FlareonApp, FlareonProject, StatusCode}; @@ -43,7 +43,7 @@ async fn path_params() { } #[must_use] -async fn project() -> FlareonProject { +async fn project() -> FlareonProject { struct RouterApp; impl FlareonApp for RouterApp { fn name(&self) -> &'static str {