use serde::Deserialize;
pub const CLIENT_ROUTES_MANIFEST: &str = "client-routes.json";
const UUID_PARAMETER: &str = "{uuid}";
const NAME_PARAMETER: &str = "{name}";
const HASH_PARAMETER: &str = "{hash}";
#[derive(Clone, Debug, PartialEq, Eq)]
enum RouteSegment {
Literal(String),
Uuid,
Name,
Hash,
}
#[derive(Deserialize)]
struct ClientRoutesManifest {
routes: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct ClientRoutes {
patterns: Vec<Vec<RouteSegment>>,
}
impl ClientRoutes {
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
let manifest: ClientRoutesManifest = serde_json::from_slice(bytes)
.map_err(|error| format!("`{CLIENT_ROUTES_MANIFEST}` is not readable: {error}"))?;
if manifest.routes.is_empty() {
return Err(format!("`{CLIENT_ROUTES_MANIFEST}` declares no routes"));
}
let mut patterns = Vec::with_capacity(manifest.routes.len());
for route in &manifest.routes {
patterns.push(parse_pattern(route)?);
}
Ok(Self { patterns })
}
#[must_use]
pub fn matches(&self, path: &str) -> bool {
let requested: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
self.patterns
.iter()
.any(|pattern| pattern_matches(pattern, &requested))
}
}
fn parse_pattern(route: &str) -> Result<Vec<RouteSegment>, String> {
route
.split('/')
.filter(|part| !part.is_empty())
.map(|part| {
if part == UUID_PARAMETER {
Ok(RouteSegment::Uuid)
} else if part == NAME_PARAMETER {
Ok(RouteSegment::Name)
} else if part == HASH_PARAMETER {
Ok(RouteSegment::Hash)
} else if part.starts_with('{') {
Err(format!(
"`{CLIENT_ROUTES_MANIFEST}` route `{route}` carries parameter shape `{part}`, \
which this server does not implement"
))
} else {
Ok(RouteSegment::Literal(part.to_owned()))
}
})
.collect()
}
fn pattern_matches(pattern: &[RouteSegment], requested: &[&str]) -> bool {
if pattern.len() != requested.len() {
return false;
}
pattern
.iter()
.zip(requested)
.all(|(segment, value)| match segment {
RouteSegment::Literal(literal) => literal == value,
RouteSegment::Uuid => uuid::Uuid::parse_str(value).is_ok(),
RouteSegment::Name => is_awl_identifier(value),
RouteSegment::Hash => is_content_hash(value),
})
}
fn is_awl_identifier(value: &str) -> bool {
let mut chars = value.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn is_content_hash(value: &str) -> bool {
value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
const MANIFEST: &str = r#"{
"routes": [
"/",
"/workflows",
"/workflows/{uuid}",
"/studio",
"/launch",
"/triage",
"/settings",
"/kit",
"/definition/{name}/{hash}"
]
}"#;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn client_routes_match_the_console() -> TestResult {
let routes = ClientRoutes::parse(MANIFEST.as_bytes())?;
let cases: [(&str, bool); 20] = [
("/", true),
("/workflows", true),
("/workflows/", true),
("/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910", true),
("/studio", true),
("/launch", true),
("/triage", true),
("/settings", true),
("/kit", true),
("/workflows/count", false),
("/workflows/not-a-uuid", false),
("/workflows/list/extra", false),
("/whoami", false),
("/events", false),
("/no-such-console-screen", false),
("/studio/deep/link", false),
(
"/definition/grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
true,
),
("/definition/grade/abc123", false),
(
"/definition/9grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
false,
),
(
"/definition/grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra",
false,
),
];
for (path, owned) in cases {
assert_eq!(routes.matches(path), owned, "{path}");
}
Ok(())
}
#[test]
fn an_unreadable_manifest_is_refused() {
let malformed = ClientRoutes::parse(b"not json");
assert!(malformed.is_err(), "malformed JSON must be refused");
let empty = ClientRoutes::parse(br#"{"routes": []}"#);
assert!(empty.is_err(), "an empty route set must be refused");
let unknown = ClientRoutes::parse(br#"{"routes": ["/runs/{slug}"]}"#);
assert!(
unknown.is_err(),
"an unimplemented parameter shape must be refused"
);
let message = unknown.err().unwrap_or_default();
assert!(
message.contains("{slug}"),
"the refusal must name the shape it cannot implement, got: {message}"
);
}
}