use std::collections::HashMap;
use std::time::Duration;
use crate::function::AppError;
use crate::util::config_reader::ConfigReader;
use crate::util::multi_level_map::ConfigValue;
const ALLOWED_METHODS: [&str; 6] = ["GET", "PUT", "POST", "DELETE", "HEAD", "PATCH"];
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const MIN_TIMEOUT: Duration = Duration::from_secs(1);
const MAX_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Debug)]
pub struct RouteInfo {
pub service: String,
pub methods: Vec<String>,
pub url: String,
pub timeout: Duration,
pub cors: Option<CorsInfo>,
pub headers: Option<HeaderInfo>,
pub authentication: Option<String>,
pub tracing: bool,
pub trace_id_header: Option<String>,
pub correlation_id_header: Option<String>,
pub traceparent_header: Option<String>,
pub flow: Option<String>,
pub stream_response: bool,
segments: Vec<Segment>,
}
#[derive(Clone, Debug)]
enum Segment {
Literal(String),
Param(String),
Any,
Prefix(String),
}
#[derive(Clone, Debug, Default)]
pub struct CorsInfo {
pub id: String,
pub options: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
}
#[derive(Clone, Debug, Default)]
pub struct HeaderInfo {
pub id: String,
pub request: HeaderTransform,
pub response: HeaderTransform,
}
#[derive(Clone, Debug, Default)]
pub struct HeaderTransform {
pub add: Vec<(String, String)>,
pub drop: Vec<String>,
pub keep: Vec<String>,
}
impl HeaderTransform {
pub fn apply(&self, headers: &mut HashMap<String, String>) {
if !self.keep.is_empty() {
let keep: Vec<String> = self.keep.iter().map(|k| k.to_lowercase()).collect();
headers.retain(|name, _| keep.contains(&name.to_lowercase()));
}
for name in &self.drop {
let name = name.to_lowercase();
headers.retain(|existing, _| existing.to_lowercase() != name);
}
for (name, value) in &self.add {
headers.insert(name.clone(), value.clone());
}
}
}
#[derive(Clone, Debug)]
pub struct StaticContent {
pub no_cache_pages: Vec<String>,
pub filter: Option<SimpleHttpFilter>,
}
impl Default for StaticContent {
fn default() -> Self {
StaticContent {
no_cache_pages: vec!["/".to_string(), "/index.html".to_string()],
filter: None,
}
}
}
#[derive(Clone, Debug)]
pub struct SimpleHttpFilter {
pub path_list: Vec<String>,
pub exclusion_list: Vec<String>,
pub service: String,
}
pub fn matched_element(elements: &[String], path: &str) -> bool {
elements.iter().any(|pattern| {
if let Some(prefix) = pattern.strip_suffix('*') {
path.starts_with(prefix)
} else if let Some(suffix) = pattern.strip_prefix('*') {
path.ends_with(suffix)
} else {
path == pattern
}
})
}
fn valid_patterns(patterns: &[String]) -> bool {
patterns.iter().all(|p| {
!p.is_empty()
&& match p.matches('*').count() {
0 => true,
1 => p.starts_with('*') || p.ends_with('*'),
_ => false,
}
})
}
pub struct RoutingTable {
routes: Vec<RouteInfo>,
static_content: StaticContent,
}
pub struct AssignedRoute<'a> {
pub info: &'a RouteInfo,
pub path_params: HashMap<String, String>,
}
impl RoutingTable {
pub fn load(reader: &ConfigReader) -> Result<Self, AppError> {
let map = reader.get_map();
let cors_blocks = parse_cors_blocks(map.get_element("cors"))?;
let header_blocks = parse_header_blocks(map.get_element("headers"))?;
let Some(ConfigValue::List(entries)) = map.get_element("rest") else {
return Err(AppError::new(400, "rest.yaml has no 'rest' section"));
};
let mut routes = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let ConfigValue::Map(entry) = entry else {
return Err(AppError::new(400, format!("rest[{index}] is not a map")));
};
routes.push(parse_route(index, entry, &cors_blocks, &header_blocks)?);
}
let static_content = parse_static_content(reader)?;
Ok(RoutingTable {
routes,
static_content,
})
}
pub fn retain_available(
&mut self,
is_registered: impl Fn(&str) -> bool,
) -> Vec<(String, String, String)> {
let mut skipped = Vec::new();
self.routes.retain(|route| {
let target = route.service.to_ascii_lowercase();
if target.starts_with("http://") || target.starts_with("https://") {
return true;
}
if is_registered(&route.service) {
return true;
}
skipped.push((
format!("[{}]", route.methods.join(", ")),
route.url.clone(),
route.service.clone(),
));
false
});
skipped
}
pub fn static_content(&self) -> &StaticContent {
&self.static_content
}
pub fn from_yaml_text(yaml: &str) -> Result<Self, AppError> {
let value: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| AppError::new(400, e.to_string()))?;
match ConfigValue::from_yaml(&value) {
ConfigValue::Map(map) => {
let reader = ConfigReader::from_map(map);
Self::load(&reader)
}
_ => Err(AppError::new(400, "rest.yaml text must be a YAML mapping")),
}
}
pub fn routes(&self) -> &[RouteInfo] {
&self.routes
}
pub fn has_url(&self, url: &str) -> bool {
self.routes.iter().any(|r| r.url.eq_ignore_ascii_case(url))
}
pub(crate) fn add_route(&mut self, route: RouteInfo) {
self.routes.push(route);
}
pub fn find(&self, method: &str, path: &str) -> Option<AssignedRoute<'_>> {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut best: Option<(usize, bool, AssignedRoute)> = None;
for info in &self.routes {
if method != "OPTIONS" && !info.methods.iter().any(|m| m == method) {
continue;
}
let Some(params) = info.match_path(&segments) else {
continue;
};
let literals = info
.segments
.iter()
.filter(|s| matches!(s, Segment::Literal(_)))
.count();
let wildcard = info.is_open_ended();
let better = match &best {
None => true,
Some((best_literals, best_wildcard, _)) => {
(!wildcard && *best_wildcard)
|| (wildcard == *best_wildcard && literals > *best_literals)
}
};
if better {
best = Some((
literals,
wildcard,
AssignedRoute {
info,
path_params: params,
},
));
}
}
best.map(|(_, _, assigned)| assigned)
}
pub fn path_matches_any_method(&self, path: &str) -> bool {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
self.routes
.iter()
.any(|info| info.match_path(&segments).is_some())
}
}
impl RouteInfo {
fn is_open_ended(&self) -> bool {
matches!(
self.segments.last(),
Some(Segment::Any) | Some(Segment::Prefix(_))
)
}
fn match_path(&self, request_segments: &[&str]) -> Option<HashMap<String, String>> {
if self.is_open_ended() {
if self.segments.len() > request_segments.len() {
return None;
}
} else if self.segments.len() != request_segments.len() {
return None;
}
let mut params = HashMap::new();
for (i, segment) in self.segments.iter().enumerate() {
let actual = request_segments.get(i)?;
match segment {
Segment::Any => {}
Segment::Literal(expected) => {
if actual.to_lowercase() != *expected {
return None;
}
}
Segment::Prefix(prefix) => {
if !actual.to_lowercase().starts_with(prefix.as_str()) {
return None;
}
}
Segment::Param(name) => {
params.insert(name.clone(), actual.to_string());
}
}
}
Some(params)
}
}
fn lookup<'a>(
map: &'a std::collections::BTreeMap<String, ConfigValue>,
key: &str,
) -> Option<&'a ConfigValue> {
if let Some(value) = map.get(key) {
return Some(value);
}
let mut parts = key.split('.');
let mut current = map.get(parts.next()?)?;
for part in parts {
match current {
ConfigValue::Map(nested) => current = nested.get(part)?,
_ => return None,
}
}
Some(current)
}
fn parse_route(
index: usize,
entry: &std::collections::BTreeMap<String, ConfigValue>,
cors_blocks: &HashMap<String, CorsInfo>,
header_blocks: &HashMap<String, HeaderInfo>,
) -> Result<RouteInfo, AppError> {
let text = |key: &str| {
lookup(entry, key)
.and_then(|v| v.as_text())
.map(str::to_string)
};
let service = text("service")
.ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'service'")))?;
if service.starts_with("http://") || service.starts_with("https://") {
return Err(AppError::new(
400,
format!("rest[{index}] HTTP relay is not yet ported (service '{service}')"),
));
}
let url =
text("url").ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'url'")))?;
let Some(ConfigValue::List(raw_methods)) = entry.get("methods") else {
return Err(AppError::new(
400,
format!("rest[{index}] missing 'methods' list"),
));
};
let mut methods = Vec::new();
for method in raw_methods {
let method = method
.as_text()
.map(str::to_uppercase)
.ok_or_else(|| AppError::new(400, format!("rest[{index}] method must be text")))?;
if !ALLOWED_METHODS.contains(&method.as_str()) {
return Err(AppError::new(
400,
format!("rest[{index}] invalid method '{method}' (allowed: {ALLOWED_METHODS:?})"),
));
}
methods.push(method);
}
let mut segments = Vec::new();
let parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
for part in &parts {
if let Some(name) = part.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
segments.push(Segment::Param(name.to_string()));
} else if *part == "*" {
segments.push(Segment::Any);
} else if let Some(prefix) = part.strip_suffix('*') {
segments.push(Segment::Prefix(prefix.to_lowercase()));
} else {
segments.push(Segment::Literal(part.to_lowercase()));
}
}
let cors =
match text("cors") {
Some(id) => Some(cors_blocks.get(&id).cloned().ok_or_else(|| {
AppError::new(400, format!("rest[{index}] unknown cors id '{id}'"))
})?),
None => None,
};
let headers = match text("headers") {
Some(id) => Some(header_blocks.get(&id).cloned().ok_or_else(|| {
AppError::new(400, format!("rest[{index}] unknown headers id '{id}'"))
})?),
None => None,
};
let tracing = matches!(entry.get("tracing"), Some(ConfigValue::Bool(true)));
let stream_response = matches!(entry.get("stream"), Some(ConfigValue::Bool(true)));
Ok(RouteInfo {
service,
methods,
url,
timeout: parse_timeout(text("timeout").as_deref()),
cors,
headers,
authentication: text("authentication"),
tracing,
trace_id_header: text("trace.id.header"),
correlation_id_header: text("correlation.id.header"),
traceparent_header: text("traceparent.header"),
flow: text("flow"),
stream_response,
segments,
})
}
pub(crate) fn parse_timeout(value: Option<&str>) -> Duration {
let parsed = value.and_then(|text| {
let text = text.trim().to_lowercase();
if let Some(ms) = text.strip_suffix("ms") {
ms.trim().parse::<u64>().ok().map(Duration::from_millis)
} else if let Some(minutes) = text.strip_suffix('m') {
minutes
.trim()
.parse::<u64>()
.ok()
.map(|m| Duration::from_secs(m * 60))
} else if let Some(seconds) = text.strip_suffix('s') {
seconds.trim().parse::<u64>().ok().map(Duration::from_secs)
} else {
text.parse::<u64>().ok().map(Duration::from_secs)
}
});
parsed
.unwrap_or(DEFAULT_TIMEOUT)
.clamp(MIN_TIMEOUT, MAX_TIMEOUT)
}
fn parse_cors_blocks(section: Option<&ConfigValue>) -> Result<HashMap<String, CorsInfo>, AppError> {
let mut blocks = HashMap::new();
let Some(ConfigValue::List(entries)) = section else {
return Ok(blocks);
};
for entry in entries {
let ConfigValue::Map(map) = entry else {
continue;
};
let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
return Err(AppError::new(400, "cors block missing 'id'"));
};
let mut info = CorsInfo {
id: id.to_string(),
..CorsInfo::default()
};
info.options = parse_header_lines(map.get("options"), id, "options")?;
info.headers = parse_header_lines(map.get("headers"), id, "headers")?;
blocks.insert(info.id.clone(), info);
}
Ok(blocks)
}
fn parse_header_lines(
list: Option<&ConfigValue>,
id: &str,
kind: &str,
) -> Result<Vec<(String, String)>, AppError> {
let mut out = Vec::new();
if let Some(ConfigValue::List(lines)) = list {
for line in lines {
let Some(line) = line.as_text() else { continue };
let Some((name, value)) = line.split_once(':') else {
return Err(AppError::new(
400,
format!("cors '{id}' {kind} line '{line}' is not 'name: value'"),
));
};
let name = name.trim();
if !name.to_lowercase().starts_with("access-control-") {
return Err(AppError::new(
400,
format!("cors '{id}' {kind} line '{name}' must be an Access-Control-* header"),
));
}
out.push((name.to_string(), value.trim().to_string()));
}
}
Ok(out)
}
fn parse_header_blocks(
section: Option<&ConfigValue>,
) -> Result<HashMap<String, HeaderInfo>, AppError> {
let mut blocks = HashMap::new();
let Some(ConfigValue::List(entries)) = section else {
return Ok(blocks);
};
for entry in entries {
let ConfigValue::Map(map) = entry else {
continue;
};
let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
return Err(AppError::new(400, "headers block missing 'id'"));
};
blocks.insert(
id.to_string(),
HeaderInfo {
id: id.to_string(),
request: parse_transform(map.get("request")),
response: parse_transform(map.get("response")),
},
);
}
Ok(blocks)
}
fn parse_transform(section: Option<&ConfigValue>) -> HeaderTransform {
let mut transform = HeaderTransform::default();
let Some(ConfigValue::Map(map)) = section else {
return transform;
};
if let Some(ConfigValue::List(add)) = map.get("add") {
for line in add {
if let Some((name, value)) = line.as_text().and_then(|l| l.split_once(':')) {
transform
.add
.push((name.trim().to_string(), value.trim().to_string()));
}
}
}
for (key, target) in [("drop", &mut transform.drop), ("keep", &mut transform.keep)] {
if let Some(ConfigValue::List(names)) = map.get(key) {
for name in names {
if let Some(name) = name.as_text() {
target.push(name.to_string());
}
}
}
}
transform
}
fn parse_static_content(reader: &ConfigReader) -> Result<StaticContent, AppError> {
let mut result = StaticContent::default();
if let Some(ConfigValue::List(pages)) = reader
.get_map()
.get_element("static-content.no-cache-pages")
{
let list: Vec<String> = pages
.iter()
.filter_map(|v| v.as_text().map(str::to_string))
.collect();
if valid_patterns(&list) && !list.is_empty() {
result.no_cache_pages = list;
} else {
return Err(AppError::new(
400,
"static-content.no-cache-pages has invalid syntax",
));
}
}
let map = reader.get_map();
if map.key_exists("static-content.filter") {
let Some(ConfigValue::List(paths)) = map.get_element("static-content.filter.path") else {
return Err(AppError::new(
400,
"static-content.filter.path must be a list",
));
};
let path_list: Vec<String> = paths
.iter()
.filter_map(|v| v.as_text().map(str::to_string))
.collect();
let service = map
.get_element("static-content.filter.service")
.and_then(|v| v.as_text())
.map(str::to_string)
.ok_or_else(|| AppError::new(400, "static-content.filter.service is required"))?;
let exclusion_list: Vec<String> = match map.get_element("static-content.filter.exclusion") {
Some(ConfigValue::List(items)) => items
.iter()
.filter_map(|v| v.as_text().map(str::to_string))
.collect(),
_ => Vec::new(),
};
if path_list.is_empty() || !valid_patterns(&path_list) || !valid_patterns(&exclusion_list) {
return Err(AppError::new(
400,
"static-content.filter path/exclusion has invalid syntax",
));
}
log::info!("static-content.filter loaded: {path_list:?} -> {service}, exclusion {exclusion_list:?}");
result.filter = Some(SimpleHttpFilter {
path_list,
exclusion_list,
service,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn table(yaml: &str) -> Result<RoutingTable, AppError> {
let dir = std::env::temp_dir().join(format!("pc-rest-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("rest.yaml");
std::fs::write(&file, yaml).unwrap();
let reader = ConfigReader::load(&format!("file:{}", file.display()))
.map_err(|e| AppError::new(400, e.to_string()))?;
let result = RoutingTable::load(&reader);
std::fs::remove_dir_all(&dir).ok();
result
}
const VALID: &str = r#"
rest:
- service: "greeting.api"
methods: ['GET']
url: "/api/greeting/{user}"
timeout: 10s
cors: cors_1
headers: header_1
tracing: true
- service: "catch.all"
methods: ['GET', 'POST']
url: "/api/files/*"
- service: "exact.match"
methods: ['GET']
url: "/api/greeting/system"
cors:
- id: cors_1
options:
- "Access-Control-Allow-Origin: *"
- "Access-Control-Allow-Methods: GET, POST, OPTIONS"
headers:
- "Access-Control-Allow-Origin: *"
headers:
- id: header_1
request:
drop: ['x-secret']
response:
add: ["x-served-by: mercury"]
"#;
#[test]
fn parses_valid_rest_yaml() {
let table = table(VALID).unwrap();
assert_eq!(table.routes().len(), 3);
let route = &table.routes()[0];
assert_eq!(route.service, "greeting.api");
assert_eq!(route.timeout, Duration::from_secs(10));
assert!(route.tracing);
assert!(route.cors.is_some());
assert!(route.headers.is_some());
}
#[test]
fn retain_available_drops_only_unregistered_function_routes() {
let mut t = table(
"rest:\n - service: keep.me\n methods: ['GET']\n url: /a\n - service: drop.me\n methods: ['GET', 'POST']\n url: /b\n",
)
.unwrap();
let skipped = t.retain_available(|service| service == "keep.me");
assert_eq!(
skipped,
vec![(
"[GET, POST]".to_string(),
"/b".to_string(),
"drop.me".to_string()
)]
);
let left: Vec<&str> = t.routes().iter().map(|r| r.service.as_str()).collect();
assert_eq!(left, vec!["keep.me"]);
}
#[test]
fn match_precedence_exact_then_param_then_wildcard() {
let table = table(VALID).unwrap();
let hit = table.find("GET", "/api/greeting/system").unwrap();
assert_eq!(hit.info.service, "exact.match");
let hit = table.find("GET", "/API/Greeting/Eric").unwrap();
assert_eq!(hit.info.service, "greeting.api");
assert_eq!(hit.path_params["user"], "Eric");
let hit = table.find("POST", "/api/files/a/b/c").unwrap();
assert_eq!(hit.info.service, "catch.all");
assert!(table.find("DELETE", "/api/greeting/eric").is_none());
assert!(table.find("OPTIONS", "/api/greeting/eric").is_some());
assert!(table.find("GET", "/api/unknown").is_none());
}
#[test]
fn parser_invariants_are_enforced() {
assert!(table("rest:\n - service: x.y\n methods: ['FETCH']\n url: /a\n").is_err());
assert!(table(
"rest:\n - service: x.y\n methods: ['GET']\n url: /a\n cors: nope\n"
)
.is_err());
assert!(table(
"rest:\n - service: 'https://example.com'\n methods: ['GET']\n url: /a\n"
)
.is_err());
assert!(
table("rest:\n - service: x.y\n methods: ['GET']\n url: '/a/*/b'\n").is_ok()
);
assert!(table(
"rest:\n - service: x.y\n methods: ['GET']\n url: /a\ncors:\n - id: c1\n options:\n - 'X-Other: 1'\n"
)
.is_err());
}
#[test]
fn timeout_parse_and_clamp() {
assert_eq!(parse_timeout(Some("10s")), Duration::from_secs(10));
assert_eq!(parse_timeout(Some("2m")), Duration::from_secs(120));
assert_eq!(parse_timeout(Some("1500ms")), Duration::from_millis(1500));
assert_eq!(parse_timeout(Some("500ms")), MIN_TIMEOUT); assert_eq!(parse_timeout(None), DEFAULT_TIMEOUT);
assert_eq!(parse_timeout(Some("0s")), MIN_TIMEOUT); assert_eq!(parse_timeout(Some("30m")), MAX_TIMEOUT); assert_eq!(parse_timeout(Some("garbage")), DEFAULT_TIMEOUT);
}
#[test]
fn header_transform_keep_drop_add() {
let transform = HeaderTransform {
add: vec![("x-served-by".into(), "mercury".into())],
drop: vec!["X-Secret".into()],
keep: vec![],
};
let mut headers: HashMap<String, String> = HashMap::from([
("x-secret".into(), "shh".into()),
("accept".into(), "*/*".into()),
]);
transform.apply(&mut headers);
assert!(!headers.contains_key("x-secret"));
assert_eq!(headers["x-served-by"], "mercury");
assert_eq!(headers["accept"], "*/*");
let keep_only = HeaderTransform {
keep: vec!["Accept".into()],
..HeaderTransform::default()
};
let mut headers: HashMap<String, String> = HashMap::from([
("accept".into(), "*/*".into()),
("x-noise".into(), "1".into()),
]);
keep_only.apply(&mut headers);
assert_eq!(headers.len(), 1);
assert!(headers.contains_key("accept"));
}
}