use rustlavel_core::{Config, Context, ContextBuilder, Result};
use rustlavel_http::plugin::{Plugin, Setup};
use rustlavel_http::{Files, Handler, Middleware, Request, Response, Router, Server, TestClient};
use std::path::PathBuf;
pub struct App {
router: Router,
config: Config,
context: Option<ContextBuilder>,
root: PathBuf,
public: Option<PathBuf>,
#[cfg(feature = "openapi")]
openapi: Option<(rustlavel_openapi::Info, String)>,
#[cfg(feature = "db")]
migrations: Vec<&'static dyn rustlavel_db::Migration>,
#[cfg(feature = "db")]
seeders: Vec<&'static dyn rustlavel_db::Seeder>,
#[cfg(feature = "queue")]
queue: Option<std::sync::Arc<dyn rustlavel_queue::Queue>>,
#[cfg(feature = "queue")]
jobs: Option<std::sync::Arc<rustlavel_queue::JobRegistry>>,
#[cfg(feature = "queue")]
scheduler: Option<rustlavel_queue::Scheduler>,
}
impl App {
pub fn new() -> Result<Self> {
App::from_root(std::env::current_dir()?)
}
pub fn from_root(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
let config = rustlavel_core::boot(&root)?;
let public = root.join("public");
Ok(App {
router: Router::new(),
context: Some(Context::builder().config(config.clone())),
config,
public: public.is_dir().then_some(public),
root,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "db")]
migrations: Vec::new(),
#[cfg(feature = "db")]
seeders: Vec::new(),
#[cfg(feature = "queue")]
queue: None,
#[cfg(feature = "queue")]
jobs: None,
#[cfg(feature = "queue")]
scheduler: None,
})
}
pub fn bare() -> Self {
let config = Config::with_defaults();
App {
router: Router::new(),
context: Some(Context::builder().config(config.clone())),
config,
root: PathBuf::from("."),
public: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "db")]
migrations: Vec::new(),
#[cfg(feature = "db")]
seeders: Vec::new(),
#[cfg(feature = "queue")]
queue: None,
#[cfg(feature = "queue")]
jobs: None,
#[cfg(feature = "queue")]
scheduler: None,
}
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn root(&self) -> &std::path::Path {
&self.root
}
#[cfg(feature = "db")]
pub(crate) fn registered_migrations(&self) -> Vec<&'static dyn rustlavel_db::Migration> {
self.migrations.clone()
}
#[cfg(feature = "db")]
pub(crate) fn registered_seeders(&self) -> Vec<&'static dyn rustlavel_db::Seeder> {
self.seeders.clone()
}
#[cfg(feature = "queue")]
pub(crate) fn registered_queue(&self) -> Option<std::sync::Arc<dyn rustlavel_queue::Queue>> {
self.queue.clone()
}
#[cfg(feature = "queue")]
pub(crate) fn registered_jobs(&self) -> Option<std::sync::Arc<rustlavel_queue::JobRegistry>> {
self.jobs.clone()
}
#[cfg(feature = "queue")]
pub(crate) fn take_scheduler(&mut self) -> Option<rustlavel_queue::Scheduler> {
self.scheduler.take()
}
pub fn routes(mut self, define: impl FnOnce(&mut Router)) -> Self {
define(&mut self.router);
self
}
pub fn middleware(mut self, middleware: impl Middleware) -> Self {
self.router.middleware(middleware);
self
}
pub fn state<T: Send + Sync + 'static>(mut self, value: T) -> Self {
self.context = Some(self.context.take().expect("context builder").state(value));
self
}
pub fn plugin(mut self, plugin: impl Plugin) -> Self {
let name = plugin.name();
let mut setup =
Setup { router: &mut self.router, config: &self.config, context: &mut self.context };
Box::new(plugin).register(&mut setup);
rustlavel_core::debug!("plugin registered: {name}");
self
}
#[cfg(feature = "view")]
pub fn views(self, engine: rustlavel_view::Engine) -> Self {
self.state(engine)
}
#[cfg(feature = "openapi")]
pub fn openapi(mut self, info: rustlavel_openapi::Info) -> Self {
self.openapi = Some((info, "/openapi.json".to_string()));
self
}
#[cfg(feature = "db")]
pub fn migrations(mut self, migrations: Vec<&'static dyn rustlavel_db::Migration>) -> Self {
self.migrations = migrations;
self
}
#[cfg(feature = "db")]
pub fn seeders(mut self, seeders: Vec<&'static dyn rustlavel_db::Seeder>) -> Self {
self.seeders = seeders;
self
}
#[cfg(feature = "queue")]
pub fn queue(mut self, queue: impl rustlavel_queue::Queue + 'static) -> Self {
self.queue = Some(std::sync::Arc::new(queue));
self
}
#[cfg(feature = "queue")]
pub fn jobs(mut self, registry: rustlavel_queue::JobRegistry) -> Self {
self.jobs = Some(std::sync::Arc::new(registry));
self
}
#[cfg(feature = "queue")]
pub fn schedule(mut self, scheduler: rustlavel_queue::Scheduler) -> Self {
self.scheduler = Some(scheduler);
self
}
pub fn fallback(mut self, handler: impl Handler) -> Self {
self.router.fallback(handler);
self
}
pub fn public(mut self, dir: impl Into<PathBuf>) -> Self {
self.public = Some(dir.into());
self
}
fn finish(mut self) -> (Router, Context) {
if !self.router.routes().iter().any(|route| route.pattern == "/up") {
self.router.get("/up", |_req: Request| async {
Response::json(rustlavel_core::Json::object([("status", "ok".into())]))
});
}
#[cfg(feature = "openapi")]
if let Some((info, path)) = self.openapi.take() {
rustlavel_openapi::mount(&mut self.router, &info, &path);
}
if let Some(dir) = self.public.clone()
&& self.router.routes().iter().all(|route| route.pattern != "/{path:*}") {
self.router.fallback(Files::new(dir));
}
#[cfg(feature = "view")]
if self.root.join(self.config.string("view.root", rustlavel_view::DEFAULT_ROOT)).is_dir() {
let engine = crate::view::engine_from_config(&self.config, &self.root);
self.context = Some(self.context.take().expect("context builder").state(engine));
}
let context = self.context.take().expect("context builder").build();
(self.router, context)
}
pub async fn run(self) -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
None | Some("serve") => self.serve().await,
Some("route:list") => {
let (router, _) = self.finish();
print_route_table(&router);
Ok(())
}
Some(other) => {
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
crate::console::Console::dispatch(self, other, &rest).await
}
}
}
pub async fn serve(self) -> Result<()> {
let host = self.config.string("server.host", "127.0.0.1");
let port = self.config.int("server.port", 8000);
let name = self.config.string("app.name", "Rustlavel");
let environment = self.config.environment();
rustlavel_core::info!("{name} [{environment}]");
#[cfg(feature = "db")]
rustlavel_db::set_log_bindings(!self.config.is_production());
let (router, context) = self.finish();
Server::new(router, context).listen(format!("{host}:{port}")).await
}
pub fn take_parts(self) -> (Router, Context) {
self.finish()
}
pub fn test_client(self) -> TestClient {
let (router, context) = self.finish();
TestClient::new(router).with_context(context)
}
pub fn route_table(&self) -> Vec<(String, String, String)> {
self.router
.routes()
.iter()
.map(|route| {
(
route.method.to_string(),
route.pattern.clone(),
route.name.clone().unwrap_or_default(),
)
})
.collect()
}
}
fn print_route_table(router: &Router) {
let width = router.routes().iter().map(|r| r.pattern.len()).max().unwrap_or(4).max(4);
println!("\n {:<8}{:<width$} NAME", "METHOD", "URI");
for route in router.routes() {
println!(
" {:<8}{:<width$} {}",
route.method.as_str(),
route.pattern,
route.name.as_deref().unwrap_or("")
);
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use rustlavel_core::Json;
#[tokio::test]
async fn serves_registered_routes() {
let client = App::bare()
.routes(|r| {
r.get("/", |_req: Request| async { "home" });
})
.test_client();
client.get("/").await.assert_ok().assert_see("home");
}
#[tokio::test]
async fn adds_a_health_endpoint_by_default() {
let client = App::bare().test_client();
client.get("/up").await.assert_ok().assert_json("status", "ok");
}
#[tokio::test]
async fn an_application_route_wins_over_the_default_health_endpoint() {
let client = App::bare()
.routes(|r| {
r.get("/up", |_req: Request| async { "custom" });
})
.test_client();
client.get("/up").await.assert_see("custom");
}
#[tokio::test]
async fn state_is_resolvable_from_a_handler() {
struct Greeting(&'static str);
let client = App::bare()
.state(Greeting("hello from state"))
.routes(|r| {
r.get("/", |req: Request| async move {
req.state::<Greeting>().unwrap().0.to_string()
});
})
.test_client();
client.get("/").await.assert_see("hello from state");
}
#[tokio::test]
async fn a_plugin_can_add_routes_and_state() {
struct Counter(usize);
struct CounterPlugin;
impl Plugin for CounterPlugin {
fn name(&self) -> &'static str {
"counter"
}
fn register(self: Box<Self>, setup: &mut Setup<'_>) {
setup.state(Counter(7));
setup.router.get("/counter", |req: Request| async move {
Json::object([("count", (req.state::<Counter>().unwrap().0 as i64).into())])
});
}
}
let client = App::bare().plugin(CounterPlugin).test_client();
client.get("/counter").await.assert_ok().assert_json("count", 7);
}
#[test]
fn route_table_lists_names() {
let app = App::bare().routes(|r| {
r.get("/users/{id}", |_req: Request| async { "u" }).name("users.show");
});
let table = app.route_table();
assert!(table.contains(&("GET".into(), "/users/{id}".into(), "users.show".into())));
}
}