use std::error::Error;
use std::fmt;
use crate::transport::CALL_VERB;
pub const LAYER_RULE: &str = "nb exposes, sb dispatches, bp records";
pub const MAX_ROUTE_PATH_BYTES: usize = 512;
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RouteValidationError {
InvalidOperationName {
name: String,
message: &'static str,
},
InvalidPath {
path: String,
message: &'static str,
},
InvalidMethod {
method: String,
message: &'static str,
},
InvalidModule(syncbat::RegisterValidationError),
DuplicateRoute {
method: &'static str,
path: String,
},
}
impl fmt::Display for RouteValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidOperationName { name, message } => {
write!(
f,
"operation name `{name}` is invalid for a route: {message}"
)
}
Self::InvalidPath { path, message } => {
write!(f, "route path `{path}` is invalid: {message}")
}
Self::InvalidMethod { method, message } => {
write!(f, "route method `{method}` is invalid: {message}")
}
Self::InvalidModule(error) => write!(f, "module is invalid for exposure: {error}"),
Self::DuplicateRoute { method, path } => {
write!(f, "duplicate boundary route {method} {path}")
}
}
}
}
impl Error for RouteValidationError {}
impl From<syncbat::RegisterValidationError> for RouteValidationError {
fn from(error: syncbat::RegisterValidationError) -> Self {
Self::InvalidModule(error)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Endpoint {
operation_name: String,
path: String,
}
impl Endpoint {
pub fn new(
operation_name: impl Into<String>,
path: impl Into<String>,
) -> Result<Self, RouteValidationError> {
let operation_name = operation_name.into();
let path = path.into();
validate_route_operation_name(&operation_name)?;
validate_route_path(&path)?;
Ok(Self {
operation_name,
path,
})
}
#[must_use]
pub fn operation_name(&self) -> &str {
&self.operation_name
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Route {
method: &'static str,
endpoint: Endpoint,
}
impl Route {
pub fn new(method: &'static str, endpoint: Endpoint) -> Result<Self, RouteValidationError> {
validate_route_method(method)?;
Ok(Self { method, endpoint })
}
#[must_use]
pub fn method(&self) -> &'static str {
self.method
}
#[must_use]
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
#[must_use]
pub fn operation_name(&self) -> &str {
self.endpoint.operation_name()
}
#[must_use]
pub fn path(&self) -> &str {
self.endpoint.path()
}
}
pub struct ServerModule {
module: syncbat::Module,
routes: Vec<Route>,
}
impl ServerModule {
pub fn expose(
module: syncbat::Module,
base_path: impl AsRef<str>,
) -> Result<Self, RouteValidationError> {
module.validate()?;
let base_path = normalize_base_path(base_path.as_ref());
validate_base_path(&base_path)?;
let mut routes = Vec::with_capacity(module.operation_count());
for (name, _) in module.operations() {
let endpoint = Endpoint::new(name, format!("{base_path}/{name}"))?;
routes.push(Route::new(CALL_VERB, endpoint)?);
}
Ok(Self { module, routes })
}
#[must_use]
pub fn module(&self) -> &syncbat::Module {
&self.module
}
#[must_use]
pub fn name(&self) -> &str {
self.module.name()
}
#[must_use]
pub fn routes(&self) -> &[Route] {
&self.routes
}
#[must_use]
pub fn operation_count(&self) -> usize {
self.module.operation_count()
}
#[must_use]
pub fn into_module(self) -> syncbat::Module {
self.module
}
}
#[derive(Default)]
pub struct Server {
modules: Vec<ServerModule>,
}
impl Server {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn mount(&mut self, module: ServerModule) -> Result<&mut Self, RouteValidationError> {
for route in module.routes() {
if self.routes().any(|existing| {
existing.method() == route.method() && existing.path() == route.path()
}) {
return Err(RouteValidationError::DuplicateRoute {
method: route.method(),
path: route.path().to_owned(),
});
}
}
self.modules.push(module);
Ok(self)
}
#[must_use]
pub fn modules(&self) -> &[ServerModule] {
&self.modules
}
pub fn routes(&self) -> impl Iterator<Item = &Route> {
self.modules.iter().flat_map(|module| module.routes())
}
#[must_use]
pub fn introspect(&self) -> Introspection {
introspect_modules(&self.modules)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Introspection {
pub module_count: usize,
pub operation_count: usize,
pub route_count: usize,
pub layer_rule: &'static str,
}
#[must_use]
pub fn introspect_modules(modules: &[ServerModule]) -> Introspection {
let operation_count = modules
.iter()
.map(ServerModule::operation_count)
.sum::<usize>();
let route_count = modules
.iter()
.map(|module| module.routes().len())
.sum::<usize>();
Introspection {
module_count: modules.len(),
operation_count,
route_count,
layer_rule: LAYER_RULE,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CoreHealth {
pub mounted_operations: Vec<String>,
pub missing_operations: Vec<String>,
pub layer_rule: &'static str,
}
impl CoreHealth {
#[must_use]
pub fn is_healthy(&self) -> bool {
self.missing_operations.is_empty()
}
}
#[must_use]
pub fn inspect_core_operations<I, S>(core: &syncbat::Core, operation_names: I) -> CoreHealth
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut mounted_operations = Vec::new();
let mut missing_operations = Vec::new();
for name in operation_names {
let name = name.as_ref();
if core.contains_operation(name) {
mounted_operations.push(name.to_owned());
} else {
missing_operations.push(name.to_owned());
}
}
CoreHealth {
mounted_operations,
missing_operations,
layer_rule: LAYER_RULE,
}
}
fn normalize_base_path(base_path: &str) -> String {
let trimmed = base_path.trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
fn validate_base_path(path: &str) -> Result<(), RouteValidationError> {
if path.is_empty() {
return Ok(());
}
validate_route_path(path)
}
fn validate_route_method(method: &str) -> Result<(), RouteValidationError> {
if method.is_empty() {
return Err(RouteValidationError::InvalidMethod {
method: method.to_owned(),
message: "empty",
});
}
if method
.bytes()
.any(|byte| !matches!(byte, b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-'))
{
return Err(RouteValidationError::InvalidMethod {
method: method.to_owned(),
message: "expected ASCII uppercase letters, digits, '_' or '-'",
});
}
Ok(())
}
fn validate_route_operation_name(name: &str) -> Result<(), RouteValidationError> {
syncbat::OperationName::new(name)
.map(|_| ())
.map_err(|error| {
let message: &'static str = match error {
syncbat::OperationNameError::Empty => "empty",
syncbat::OperationNameError::TooLong { .. } => "too long",
syncbat::OperationNameError::LeadingOrTrailingDot
| syncbat::OperationNameError::ConsecutiveDots => {
"dot-separated tokens must be non-empty"
}
syncbat::OperationNameError::IllegalCharacter { .. } => {
"expected ASCII letters, digits, '.', '_' or '-'"
}
_ => "invalid operation name",
};
RouteValidationError::InvalidOperationName {
name: name.to_owned(),
message,
}
})
}
fn validate_route_path(path: &str) -> Result<(), RouteValidationError> {
if path.is_empty() {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "empty",
});
}
if path.len() > MAX_ROUTE_PATH_BYTES {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "too long",
});
}
if !path.starts_with('/') {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "must start with '/'",
});
}
if path == "/" {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "must include at least one segment",
});
}
if path.len() > 1 && path.ends_with('/') {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "must not end with '/'",
});
}
if path.contains("//") {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "empty path segments are not allowed",
});
}
for segment in path.split('/').skip(1) {
if segment == "." || segment == ".." {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "relative path segments are not allowed",
});
}
if segment.bytes().any(
|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-'),
) {
return Err(RouteValidationError::InvalidPath {
path: path.to_owned(),
message: "expected ASCII letters, digits, '/', '.', '_' or '-'",
});
}
}
Ok(())
}