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>,
on_shutdown: Vec<rustlavel_http::OnShutdown>,
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())),
on_shutdown: Vec::new(),
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())),
on_shutdown: Vec::new(),
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 registered<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.context.as_ref().and_then(|builder| builder.registered::<T>())
}
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 on_shutdown(
mut self,
work: impl FnOnce() -> rustlavel_http::BoxFuture<()> + Send + Sync + 'static,
) -> Self {
self.on_shutdown.push(Box::new(work));
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(self, plugin: impl Plugin) -> Self {
self.plugin_boxed(Box::new(plugin))
}
pub fn plugin_boxed(mut self, plugin: Box<dyn Plugin>) -> Self {
let name = plugin.name();
let mut setup =
Setup { router: &mut self.router, config: &self.config, context: &mut self.context };
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()
&& !self
.context
.as_ref()
.expect("context builder")
.has_state::<rustlavel_view::Engine>()
{
let engine = crate::view::engine_from_config(&self.config, &self.root);
self.context = Some(self.context.take().expect("context builder").state(engine));
}
let named = self.router.named_routes();
self.context =
Some(self.context.take().expect("context builder").state(named.clone()));
let context = self.context.take().expect("context builder").build();
#[cfg(feature = "view")]
if let Some(engine) = context.state::<rustlavel_view::Engine>() {
let _ = engine
.routes_cell()
.set(std::sync::Arc::new(crate::view::RouteTable::new(named)));
}
(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, &args[1..]);
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(mut 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());
if crate::live_reload::requested(self.config.is_production()) {
crate::live_reload::install(&mut self.router, &self.root);
rustlavel_core::info!("live reload on");
}
let on_shutdown = std::mem::take(&mut self.on_shutdown);
let (router, context) = self.finish();
#[allow(unused_mut)]
let mut flushes: Vec<rustlavel_http::OnShutdown> = Vec::new();
#[cfg(feature = "search")]
if let Some(shipper) = context.state::<rustlavel_search::LogShipper>() {
let shipper = shipper.clone();
flushes.push(Box::new(move || Box::pin(async move { shipper.shutdown().await })));
}
#[cfg(feature = "otel")]
if let Some(exporter) = context.state::<rustlavel_otel::Exporter>() {
let exporter = exporter.clone();
flushes.push(Box::new(move || Box::pin(async move { exporter.shutdown().await })));
}
let mut server = Server::new(router, context);
for work in on_shutdown.into_iter().chain(flushes) {
server = server.on_shutdown(work);
}
server.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 matching_routes<'a>(
router: &'a Router,
filters: &[String],
) -> Vec<&'a rustlavel_http::router::Route> {
let value_of = |flag: &str| -> Option<String> {
filters
.iter()
.position(|argument| argument == flag)
.and_then(|at| filters.get(at + 1))
.map(|value| value.to_lowercase())
};
let path = value_of("--path");
let method = value_of("--method");
let name = value_of("--name");
router
.routes()
.iter()
.filter(|route| {
path.as_ref().is_none_or(|want| route.pattern.to_lowercase().contains(want))
&& method
.as_ref()
.is_none_or(|want| route.method.as_str().eq_ignore_ascii_case(want))
&& name.as_ref().is_none_or(|want| {
route.name.as_deref().is_some_and(|had| had.to_lowercase().contains(want))
})
})
.collect()
}
fn print_route_table(router: &Router, filters: &[String]) {
let value_of = |flag: &str| -> Option<String> {
filters
.iter()
.position(|argument| argument == flag)
.and_then(|at| filters.get(at + 1))
.map(|value| value.to_lowercase())
};
let path = value_of("--path");
let method = value_of("--method");
let name = value_of("--name");
let matching = matching_routes(router, filters);
let _ = (&path, &method, &name);
let width = matching.iter().map(|r| r.pattern.len()).max().unwrap_or(4).max(4);
println!("\n {:<8}{:<width$} NAME", "METHOD", "URI");
for route in &matching {
println!(
" {:<8}{:<width$} {}",
route.method.as_str(),
route.pattern,
route.name.as_deref().unwrap_or("")
);
}
let total = router.routes().len();
match matching.len() {
0 => println!("\n no route matches, out of {total}"),
shown if shown == total => println!("\n {shown} routes"),
shown => println!("\n {shown} of {total} routes"),
}
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())));
}
#[test]
fn route_list_filters_by_path_method_and_name() {
use rustlavel_http::router::Router;
let mut router = Router::new();
router.get("/admin/users", |_req: Request| async { "" }).name("admin.users");
router.post("/admin/users", |_req: Request| async { "" });
router.get("/login", |_req: Request| async { "" }).name("login");
let matching = |filters: &[&str]| {
let filters: Vec<String> = filters.iter().map(|f| f.to_string()).collect();
super::matching_routes(&router, &filters)
.iter()
.map(|r| format!("{} {}", r.method.as_str(), r.pattern))
.collect::<Vec<_>>()
};
assert_eq!(matching(&[]).len(), 3, "no filter lists everything");
assert_eq!(matching(&["--path", "admin"]).len(), 2);
assert_eq!(matching(&["--method", "POST"]), ["POST /admin/users"]);
assert_eq!(matching(&["--method", "post"]), ["POST /admin/users"]);
assert_eq!(matching(&["--name", "login"]), ["GET /login"]);
assert_eq!(matching(&["--path", "admin", "--method", "get"]), ["GET /admin/users"]);
assert!(matching(&["--path", "nowhere"]).is_empty());
}
}