use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::Arc;
use axum::Router;
use axum::extract::Request;
use axum::http::header;
use axum::middleware::Next;
use axum::response::IntoResponse;
use axum::routing::get;
use tokio::net::{TcpListener, ToSocketAddrs};
use utoipa::openapi::HttpMethod;
use utoipa::{PartialSchema, ToSchema};
use crate::auth::{AllowUnannotated, AuthGate};
use crate::config::ServerustConfig;
use crate::container::Container;
use crate::events::{EventDispatcher, EventHandler, EventHandlerRegistry};
use crate::openapi::{OpenApiState, SegurancaDaRota, redoc_html, swagger_ui_html};
use crate::pipeline::Interceptor;
use crate::route::IntoRoute;
type RouterMutator = Box<dyn FnOnce(Router<Container>) -> Router<Container> + Send + Sync>;
pub struct App {
router: Router<Container>,
container: Container,
openapi: OpenApiState,
openapi_path: &'static str,
docs_path: &'static str,
redoc_path: &'static str,
interceptors: Vec<RouterMutator>,
docs_enabled: bool,
auth_installed: bool,
allow_unannotated: bool,
public_routes: Vec<(&'static str, &'static str)>,
event_registries: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}
impl App {
pub fn new() -> Self {
Self {
router: Router::new(),
container: Container::new(),
openapi: OpenApiState::default(),
openapi_path: "/openapi.json",
docs_path: "/docs",
redoc_path: "/redoc",
interceptors: Vec::new(),
docs_enabled: true,
auth_installed: false,
allow_unannotated: false,
public_routes: Vec::new(),
event_registries: HashMap::new(),
}
}
pub fn openapi_info(mut self, title: impl Into<String>, version: impl Into<String>) -> Self {
self.openapi.set_info(title, version);
self
}
pub fn register_schema<T: ToSchema + PartialSchema>(mut self) -> Self {
self.openapi.register_schema::<T>();
self
}
pub fn docs(mut self, path: &'static str) -> Self {
self.docs_path = path;
self
}
pub fn redoc(mut self, path: &'static str) -> Self {
self.redoc_path = path;
self
}
pub fn without_docs(mut self) -> Self {
self.docs_enabled = false;
self
}
pub fn provide<T: ?Sized + Send + Sync + 'static>(mut self, value: Arc<T>) -> Self {
self.container.insert(value);
self
}
pub fn r#override<T: ?Sized + Send + Sync + 'static>(mut self, value: Arc<T>) -> Self {
self.container.insert(value);
self
}
pub fn interceptor<I: Interceptor>(mut self, interceptor: I) -> Self {
let interceptor = std::sync::Arc::new(interceptor);
let mutator: RouterMutator = Box::new(move |router: Router<Container>| {
let interceptor = interceptor.clone();
let layer = axum::middleware::from_fn(move |req: Request, next: Next| {
let interceptor = interceptor.clone();
async move { interceptor.intercept(req, next).await }
});
router.layer(layer)
});
self.interceptors.push(mutator);
self
}
pub fn layer<L>(mut self, layer: L) -> Self
where
L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
L::Service: tower::Service<Request> + Clone + Send + Sync + 'static,
<L::Service as tower::Service<Request>>::Response: IntoResponse + 'static,
<L::Service as tower::Service<Request>>::Error: Into<std::convert::Infallible> + 'static,
<L::Service as tower::Service<Request>>::Future: Send + 'static,
{
let mutator: RouterMutator = Box::new(move |router: Router<Container>| router.layer(layer));
self.interceptors.push(mutator);
self
}
pub fn auth<L>(mut self, layer: L) -> Self
where
L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
L::Service: tower::Service<Request> + Clone + Send + Sync + 'static,
<L::Service as tower::Service<Request>>::Response: IntoResponse + 'static,
<L::Service as tower::Service<Request>>::Error: Into<std::convert::Infallible> + 'static,
<L::Service as tower::Service<Request>>::Future: Send + 'static,
{
self.auth_installed = true;
self.layer(layer)
}
pub fn allow_unannotated(mut self) -> Self {
self.allow_unannotated = true;
self.layer(axum::Extension(AllowUnannotated))
}
pub fn public_routes(&self) -> &[(&'static str, &'static str)] {
&self.public_routes
}
pub fn config(self, cfg: ServerustConfig) -> Self {
self.provide::<ServerustConfig>(Arc::new(cfg))
}
pub fn route<R: IntoRoute>(mut self, handler: R) -> Self {
let route = handler.into_route();
let route_method = metodo_str(&route.method);
let seguranca = if route.is_public {
SegurancaDaRota::Publica
} else if route.required_scopes.is_empty() {
SegurancaDaRota::ExigeIdentidade
} else {
SegurancaDaRota::ExigeEscopos(route.required_scopes)
};
self.openapi
.push_operation(route.path, route.method, route.operation, seguranca);
let method_router = if route.is_public {
self.public_routes.push((route_method, route.path));
route.method_router
} else {
route.method_router.layer(AuthGate)
};
self.router = self.router.route(route.path, method_router);
self
}
pub fn event<E, H>(mut self, handler: H) -> Self
where
E: Send + 'static,
H: EventHandler<E>,
{
let key = TypeId::of::<EventHandlerRegistry<E>>();
let registry = self
.event_registries
.entry(key)
.or_insert_with(|| Box::new(EventHandlerRegistry::<E>::new()));
registry
.downcast_mut::<EventHandlerRegistry<E>>()
.expect("type invariant: key matches registry type")
.register(handler);
self
}
pub fn has_event_handlers(&self) -> bool {
!self.event_registries.is_empty()
}
pub fn into_event_dispatcher<E: Send + 'static>(mut self) -> EventDispatcher<E> {
let key = TypeId::of::<EventHandlerRegistry<E>>();
let registry = self
.event_registries
.remove(&key)
.and_then(|b| b.downcast::<EventHandlerRegistry<E>>().ok())
.unwrap_or_else(|| Box::new(EventHandlerRegistry::<E>::new()));
registry.into_dispatcher(self.container)
}
pub fn into_router(self) -> Router {
if self.auth_installed {
eprint!(
"{}",
relatorio_de_init(&self.public_routes, self.allow_unannotated)
);
}
let mut user_router = self.router;
for mutator in self.interceptors {
user_router = mutator(user_router);
}
if !self.docs_enabled {
return user_router.with_state(self.container);
}
let doc = self.openapi.build(self.auth_installed);
let json = doc.to_json().unwrap_or_else(|_| "{}".to_string());
let swagger_html = swagger_ui_html(self.openapi_path);
let redoc_page = redoc_html(self.openapi_path);
user_router
.route(
self.openapi_path,
get(move || {
let json = json.clone();
async move {
(
[(header::CONTENT_TYPE, "application/json")],
json,
)
.into_response()
}
}),
)
.route(
self.docs_path,
get(move || {
let html = swagger_html.clone();
async move {
(
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response()
}
}),
)
.route(
self.redoc_path,
get(move || {
let html = redoc_page.clone();
async move {
(
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response()
}
}),
)
.with_state(self.container)
}
pub async fn run_http<A: ToSocketAddrs>(self, addr: A) -> std::io::Result<()> {
let listener = TcpListener::bind(addr).await?;
let local = listener.local_addr()?;
let docs_path = self.docs_path;
let openapi_path = self.openapi_path;
let router = self.into_router();
eprintln!();
eprintln!(" 🦀 serverust on http://{local}");
eprintln!(" docs: http://{local}{docs_path}");
eprintln!(" openapi: http://{local}{openapi_path}");
eprintln!();
axum::serve(listener, router).await
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
fn relatorio_de_init(publicas: &[(&'static str, &'static str)], afrouxado: bool) -> String {
use std::fmt::Write as _;
let mut saida = String::new();
if afrouxado {
saida.push_str(
"\n ⚠️ serverust: default deny DESLIGADO por .allow_unannotated()\n \
toda rota responde sem identidade. Isto é escape hatch de migração — \
marque as rotas com #[public] e remova a chamada.\n\n",
);
return saida;
}
let _ = writeln!(saida, "\n 🔒 serverust: default deny ativo");
if publicas.is_empty() {
let _ = writeln!(
saida,
" nenhuma rota pública — toda rota exige identidade"
);
} else {
let _ = writeln!(
saida,
" {} rota(s) pública(s), sem exigir identidade:",
publicas.len()
);
for (metodo, path) in publicas {
let _ = writeln!(saida, " {metodo} {path}");
}
}
saida.push('\n');
saida
}
fn metodo_str(metodo: &HttpMethod) -> &'static str {
match metodo {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
HttpMethod::Head => "HEAD",
HttpMethod::Options => "OPTIONS",
HttpMethod::Trace => "TRACE",
}
}
#[cfg(test)]
mod testes_do_relatorio {
use super::*;
#[test]
fn lista_as_rotas_publicas_com_metodo_e_path() {
let texto = relatorio_de_init(&[("GET", "/health"), ("POST", "/webhooks/stripe")], false);
assert!(texto.contains("default deny ativo"), "{texto}");
assert!(texto.contains("2 rota(s) pública(s)"), "{texto}");
assert!(texto.contains("GET /health"), "{texto}");
assert!(texto.contains("POST /webhooks/stripe"), "{texto}");
}
#[test]
fn sem_rotas_publicas_diz_isso_explicitamente() {
let texto = relatorio_de_init(&[], false);
assert!(texto.contains("nenhuma rota pública"), "{texto}");
}
#[test]
fn escape_hatch_avisa_em_vez_de_listar() {
let texto = relatorio_de_init(&[("GET", "/health")], true);
assert!(texto.contains("DESLIGADO"), "{texto}");
assert!(texto.contains("allow_unannotated"), "{texto}");
assert!(
!texto.contains("/health"),
"listar rotas públicas com o default deny desligado sugere que as \
demais estão protegidas — {texto}"
);
}
}