use std::collections::HashSet;
use std::sync::Arc;
use axum::Router;
use axum::handler::Handler;
use axum::http::Method;
use axum::routing::get;
use super::RouteDoc;
use super::service_v2::State;
#[derive(Clone)]
pub struct FrontendExtensionContext {
state: Arc<State>,
}
impl FrontendExtensionContext {
pub(crate) fn new(state: Arc<State>) -> Self {
Self { state }
}
pub fn is_ready(&self) -> bool {
self.state.is_ready()
}
pub fn is_cancelled(&self) -> bool {
self.state.is_cancelled()
}
pub fn has_any_ready_model(&self) -> bool {
self.state.manager().has_any_ready_model()
}
pub fn is_model_ready_to_serve(&self, model: &str) -> bool {
self.state.manager().is_model_ready_to_serve(model)
}
pub fn model_display_names(&self) -> HashSet<String> {
self.state.manager().model_display_names()
}
pub fn serving_ready_display_names(&self) -> HashSet<String> {
self.state.manager().serving_ready_display_names()
}
}
pub struct FrontendRouteSet {
route_docs: Vec<RouteDoc>,
router: Router,
}
impl FrontendRouteSet {
pub fn builder() -> FrontendRouteSetBuilder {
FrontendRouteSetBuilder::default()
}
pub fn route_docs(&self) -> &[RouteDoc] {
&self.route_docs
}
pub(crate) fn into_parts(self) -> (Vec<RouteDoc>, Router) {
(self.route_docs, self.router)
}
}
pub fn validate_extension_route_path(path: &str) -> anyhow::Result<()> {
if !path.starts_with('/') {
anyhow::bail!("frontend route path must start with '/': {path:?}");
}
if path.split('/').any(|seg| seg.starts_with([':', '*'])) {
anyhow::bail!("frontend route path segment must not start with ':' or '*': {path:?}");
}
if path
.chars()
.any(|c| matches!(c, '{' | '}' | '*') || c.is_whitespace() || c.is_control())
{
anyhow::bail!(
"frontend route path must be static (no '{{...}}', '*', or whitespace): {path:?}"
);
}
Ok(())
}
#[derive(Default)]
pub struct FrontendRouteSetBuilder {
route_docs: Vec<RouteDoc>,
router: Router,
seen_paths: HashSet<String>,
}
impl FrontendRouteSetBuilder {
pub fn get<H, T>(mut self, path: impl Into<String>, handler: H) -> anyhow::Result<Self>
where
H: Handler<T, ()>,
T: 'static,
{
let path = path.into();
validate_extension_route_path(&path)?;
if !self.seen_paths.insert(path.clone()) {
anyhow::bail!("duplicate frontend route registered: GET {path}");
}
self.route_docs
.push(RouteDoc::new(Method::GET, path.clone()));
self.router = self.router.route(&path, get(handler));
Ok(self)
}
pub fn build(self) -> FrontendRouteSet {
FrontendRouteSet {
route_docs: self.route_docs,
router: self.router,
}
}
}
pub type FrontendRouteExtension = Arc<
dyn Fn(FrontendExtensionContext) -> anyhow::Result<FrontendRouteSet> + Send + Sync + 'static,
>;
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
#[test]
fn validate_route_path_rejects_non_static() {
assert!(validate_extension_route_path("/ok").is_ok());
assert!(validate_extension_route_path("/a/b_c").is_ok());
for bad in [
"no-slash",
"/:id",
"/{id}",
"/x/{*rest}",
"/*rest",
"/has space",
] {
assert!(
validate_extension_route_path(bad).is_err(),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn builder_rejects_duplicate_path_within_set() {
let dup = FrontendRouteSet::builder()
.get("/a", || async { StatusCode::OK })
.unwrap()
.get("/a", || async { StatusCode::OK });
assert!(dup.is_err(), "duplicate GET path within a set must error");
}
#[test]
fn builder_rejects_colon_path() {
let bad = FrontendRouteSet::builder().get("/:id", || async { StatusCode::OK });
assert!(bad.is_err(), "colon-prefixed segment must error, not panic");
}
}