mod error;
mod openapi;
mod part;
use std::{borrow::Cow, collections::BTreeMap, sync::Arc};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use crate::middlewares::SlashPolicy;
use crate::{
Site,
callables::OperationKind,
commands::CommandRegistry,
embed, emitters,
routes::{self},
services::ServiceRegistry,
signals::{self, SignalRegistry},
tasks::TaskRegistry,
};
use openapi::DocEngine;
pub use error::BundleError;
pub use openapi::OpenApiConf;
pub use part::{
BundlePart, asset_dir, bundle, command, cron, periodic, pgnotify, route, service, signal, task,
};
pub use vyuh_macros::{asset_dir, bundle, cron, periodic, pgnotify, route, service, signal, task};
pub use {
crate::apidocs::{ApiMeta, DocViewer},
crate::routes::RouteConf,
emitters::CronConf,
emitters::PeriodicConf,
emitters::PgNotifyConf,
signals::SignalConf,
};
pub trait IntoBundle {
fn into_bundle(self) -> Bundle;
}
impl IntoBundle for Bundle {
fn into_bundle(self) -> Bundle {
self
}
}
impl IntoBundle for axum::Router<Site> {
fn into_bundle(self) -> Bundle {
Bundle {
inner_router: self,
..Bundle::new()
}
}
}
pub struct Bundle {
pub(super) inner_router: routes::AxumRouter<Site>,
pub(crate) ops: BTreeMap<uuid::Uuid, crate::callables::Operation>,
pub(crate) name_index: BTreeMap<String, uuid::Uuid>,
pub(super) id: uuid::Uuid,
label: Option<String>,
pub(crate) signals: SignalRegistry,
pub(crate) emitters: emitters::EmitterRegistry,
pub(super) errors: Vec<BundleError>,
pub(crate) tasks: TaskRegistry,
pub(crate) asset_dirs: Vec<embed::Dir>,
pub(crate) services: ServiceRegistry,
pub(crate) commands: CommandRegistry,
pub(crate) doc_engine: DocEngine,
}
impl Bundle {
pub fn new() -> Self {
Self {
id: uuid::Uuid::new_v4(),
label: None,
inner_router: routes::AxumRouter::new(),
ops: BTreeMap::new(),
name_index: BTreeMap::new(),
signals: SignalRegistry::new(),
emitters: emitters::EmitterRegistry::new(),
errors: Vec::new(),
tasks: TaskRegistry::new(),
asset_dirs: Vec::new(),
services: ServiceRegistry::new(),
commands: CommandRegistry::new(),
doc_engine: DocEngine::new(),
}
}
pub fn id(&self) -> uuid::Uuid {
self.id
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn validate(&self) -> Result<(), BundleError> {
if !self.errors.is_empty() {
return Err(BundleError::ErrorList(self.errors.clone()));
}
Ok(())
}
pub fn to_router(&self) -> routes::AxumRouter<Site> {
self.inner_router.clone()
}
pub fn iter_operations(&self) -> impl Iterator<Item = &crate::callables::Operation> {
self.ops.values()
}
pub fn with_tags(
mut self,
tags: impl IntoIterator<Item = impl Into<Cow<'static, str>>>,
) -> Self {
let tags: Vec<Cow<'static, str>> = tags.into_iter().map(|t| t.into()).collect();
for op in self.ops.values_mut() {
op.tags.extend(tags.iter().cloned());
}
self
}
pub fn with_slash_policy(mut self, policy: SlashPolicy) -> Self {
for op in self.ops.values_mut() {
if op.kind == OperationKind::Route {
op.slash_policy = Some(policy);
}
}
self
}
pub fn merge<B: IntoBundle>(mut self, other: B) -> Self {
let other = other.into_bundle();
let router = match self.absorb(other) {
Ok(r) => r,
Err(e) => {
self.errors.push(e);
return self;
}
};
self.inner_router = self.inner_router.merge(router);
self
}
pub fn with_prefix(mut self, path: &str) -> Self {
if let Err(reason) = validate_route_prefix(path) {
self.errors.push(BundleError::InvalidRoutePrefix {
prefix: path.to_string(),
reason,
});
return self;
}
for op in self.ops.values_mut() {
op.nest(path);
}
self.inner_router = routes::AxumRouter::new().nest(path, self.inner_router);
self
}
pub fn layer<M>(mut self, mw: M) -> Self
where
M: routes::middleware::Middleware,
<M::Layer as tower::Layer<axum::routing::Route>>::Service:
tower::Service<axum::http::Request<axum::body::Body>> + Clone + Send + Sync + 'static,
<<M::Layer as tower::Layer<axum::routing::Route>>::Service as tower::Service<
axum::http::Request<axum::body::Body>,
>>::Response: axum::response::IntoResponse + 'static,
<<M::Layer as tower::Layer<axum::routing::Route>>::Service as tower::Service<
axum::http::Request<axum::body::Body>,
>>::Error: Into<std::convert::Infallible> + 'static,
<<M::Layer as tower::Layer<axum::routing::Route>>::Service as tower::Service<
axum::http::Request<axum::body::Body>,
>>::Future: Send + 'static,
{
if let Some(spec) = mw.layer_spec() {
for op in self.ops.values_mut() {
op.layers.push(spec.clone());
}
}
self.inner_router = self.inner_router.layer(mw.into_layer());
self
}
pub(crate) fn with_router_unchecked(mut self, router: routes::AxumRouter<Site>) -> Self {
self.inner_router = router;
self
}
pub fn reverse(&self, name: &str, args: &[(&str, &str)]) -> Option<String> {
let id = self.name_index.get(name)?;
let op = self.ops.get(id)?;
let mut path = op.path.to_string();
for (k, v) in args {
let placeholder = format!("{{{k}}}");
if path.contains(&placeholder) {
let encoded = utf8_percent_encode(v, NON_ALPHANUMERIC).to_string();
path = path.replace(&placeholder, &encoded);
}
}
if path.contains('{') || path.contains('}') {
return None;
}
Some(path)
}
fn absorb(&mut self, mut other: Bundle) -> Result<axum::Router<Site>, BundleError> {
self.errors.append(&mut other.errors);
for (name, _) in &other.name_index {
if self.name_index.contains_key(name) {
return Err(BundleError::DuplicateRouteName { name: name.clone() });
}
}
for op in other
.ops
.values()
.filter(|op| op.kind == OperationKind::Route)
{
if let Some((path, methods)) = self.find_route_collision(op) {
return Err(BundleError::DuplicateRoutePathMethod { path, methods });
}
}
self.ops.extend(other.ops);
self.name_index.extend(other.name_index);
self.signals.merge(other.signals);
self.asset_dirs.extend(other.asset_dirs);
if let Err(e) = self.emitters.merge(other.emitters) {
return Err(BundleError::Emitter(Arc::new(e)));
}
if let Err(e) = self.services.merge(other.services) {
return Err(BundleError::Service(Arc::new(e)));
}
if let Err(e) = self.tasks.merge(other.tasks) {
return Err(BundleError::Task(Arc::new(e)));
}
if let Err(e) = self.commands.merge(other.commands) {
return Err(BundleError::Command(Arc::new(e)));
}
self.doc_engine.merge(other.doc_engine);
Ok(other.inner_router)
}
pub(super) fn validate_route_operation(
&self,
op: &crate::callables::Operation,
) -> Result<(), BundleError> {
if op.name.trim().is_empty() {
return Err(BundleError::InvalidRouteName {
name: op.name.clone(),
reason: "route name cannot be empty".to_string(),
});
}
if let Err(reason) = validate_route_path(&op.path) {
return Err(BundleError::InvalidRoutePath {
name: op.name.clone(),
path: op.path.clone(),
reason,
});
}
if self.name_index.contains_key(&op.name) {
return Err(BundleError::DuplicateRouteName {
name: op.name.clone(),
});
}
if let Some((path, methods)) = self.find_route_collision(op) {
return Err(BundleError::DuplicateRoutePathMethod { path, methods });
}
Ok(())
}
fn find_route_collision(&self, op: &crate::callables::Operation) -> Option<(String, String)> {
self.ops
.values()
.filter(|existing| existing.kind == OperationKind::Route)
.find(|existing| existing.path == op.path && existing.methods.intersects(op.methods))
.map(|existing| {
let methods = existing
.methods
.to_vec()
.into_iter()
.filter(|name| op.methods.to_vec().contains(name))
.collect::<Vec<_>>()
.join("|");
(op.path.clone(), methods)
})
}
}
impl Default for Bundle {
fn default() -> Self {
Self::new()
}
}
fn validate_route_path(path: &str) -> Result<(), String> {
if path.is_empty() {
return Err("path cannot be empty".to_string());
}
if !path.starts_with('/') {
return Err("path must start with '/'".to_string());
}
if path.contains("//") {
return Err("path cannot contain '//'".to_string());
}
Ok(())
}
fn validate_route_prefix(path: &str) -> Result<(), String> {
validate_route_path(path)?;
if path == "/" {
return Err("prefix cannot be '/'".to_string());
}
if path.ends_with('/') {
return Err("prefix must not end with '/'".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn route_op(name: &str, path: &str, methods: routes::Methods) -> crate::callables::Operation {
crate::callables::Operation {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
description: None,
summary: None,
path: path.to_string(),
kind: OperationKind::Route,
methods,
args: Vec::new(),
layers: Vec::new(),
returns: Vec::new(),
tags: Vec::new(),
conf: None,
owner: None,
hidden: false,
bundle_id: None,
slash_policy: None,
}
}
fn bundle_with_route(op: crate::callables::Operation) -> Bundle {
let mut bundle = Bundle::new();
bundle.name_index.insert(op.name.clone(), op.id);
bundle.ops.insert(op.id, op);
bundle
}
#[test]
fn validates_route_path() {
let bundle = Bundle::new();
let err = bundle
.validate_route_operation(&route_op("bad", "bad", routes::Methods::GET))
.unwrap_err();
assert!(matches!(err, BundleError::InvalidRoutePath { .. }));
}
#[test]
fn validates_route_name() {
let bundle = Bundle::new();
let err = bundle
.validate_route_operation(&route_op("", "/ok", routes::Methods::GET))
.unwrap_err();
assert!(matches!(err, BundleError::InvalidRouteName { .. }));
}
#[test]
fn rejects_duplicate_route_names() {
let existing = route_op("notes", "/notes", routes::Methods::GET);
let bundle = bundle_with_route(existing);
let err = bundle
.validate_route_operation(&route_op("notes", "/other", routes::Methods::GET))
.unwrap_err();
assert!(matches!(err, BundleError::DuplicateRouteName { .. }));
}
#[test]
fn rejects_duplicate_route_path_method_pairs() {
let existing = route_op(
"list_notes",
"/notes",
routes::Methods::GET | routes::Methods::HEAD,
);
let bundle = bundle_with_route(existing);
let err = bundle
.validate_route_operation(&route_op("read_notes", "/notes", routes::Methods::HEAD))
.unwrap_err();
assert!(matches!(err, BundleError::DuplicateRoutePathMethod { .. }));
}
#[test]
fn allows_same_path_with_different_methods() {
let existing = route_op("list_notes", "/notes", routes::Methods::GET);
let bundle = bundle_with_route(existing);
assert!(
bundle
.validate_route_operation(&route_op("create_note", "/notes", routes::Methods::POST))
.is_ok()
);
}
#[test]
fn invalid_prefix_accumulates_error() {
let bundle = Bundle::new().with_prefix("api");
assert!(matches!(
bundle.errors.as_slice(),
[BundleError::InvalidRoutePrefix { .. }]
));
}
#[test]
fn valid_prefix_updates_operation_paths() {
let op = route_op("notes", "/notes", routes::Methods::GET);
let prefixed = bundle_with_route(op).with_prefix("/v1");
assert_eq!(
prefixed.reverse("notes", &[]),
Some("/v1/notes".to_string())
);
}
#[test]
fn reverse_encodes_path_params() {
let op = route_op("note", "/notes/{id}", routes::Methods::GET);
let bundle = bundle_with_route(op);
assert_eq!(
bundle.reverse("note", &[("id", "a/b c")]),
Some("/notes/a%2Fb%20c".to_string())
);
}
#[test]
fn reverse_returns_none_when_params_are_missing() {
let op = route_op("note", "/notes/{id}", routes::Methods::GET);
let bundle = bundle_with_route(op);
assert_eq!(bundle.reverse("note", &[]), None);
}
}