use ikigai_core::{ArgRef, Capability, Iri, Kernel, Request, Verb};
use ikigai_web::{CorsPolicy, Route, RouteTable};
const ROUTE_QUERY: &str = r#"
PREFIX ik: <https://ikigai-rs.dev/ns#>
SELECT ?match ?target ?order ?csp ?corsCredentials ?corsMaxAge
(GROUP_CONCAT(DISTINCT ?cap; SEPARATOR="\n") AS ?caps)
(GROUP_CONCAT(DISTINCT ?corsOrigin; SEPARATOR="\n") AS ?corsOrigins)
(GROUP_CONCAT(DISTINCT ?corsMethod; SEPARATOR="\n") AS ?corsMethods)
(GROUP_CONCAT(DISTINCT ?corsHeader; SEPARATOR="\n") AS ?corsHeaders)
WHERE {
?route a ik:Route ;
ik:match ?match ;
ik:target ?target .
OPTIONAL { ?route ik:order ?order }
OPTIONAL { ?route ik:csp ?csp }
OPTIONAL { ?route ik:cap ?cap }
OPTIONAL {
?route ik:cors ?cors .
OPTIONAL { ?cors ik:corsOrigin ?corsOrigin }
OPTIONAL { ?cors ik:corsMethod ?corsMethod }
OPTIONAL { ?cors ik:corsHeader ?corsHeader }
OPTIONAL { ?cors ik:corsCredentials ?corsCredentials }
OPTIONAL { ?cors ik:corsMaxAge ?corsMaxAge }
}
}
GROUP BY ?route ?match ?target ?order ?csp ?corsCredentials ?corsMaxAge
ORDER BY ?order
"#;
pub fn watch_path(routes_iri: &str, file_root: &std::path::Path) -> Option<std::path::PathBuf> {
routes_iri
.strip_prefix("urn:file:")
.map(|rel| file_root.join(rel.trim_start_matches('/')))
}
pub async fn load_route_table(
kernel: &Kernel,
routes_iri: &str,
cap: &Capability,
) -> Result<RouteTable, String> {
let iri = Iri::parse(routes_iri).map_err(|e| format!("bad routes IRI `{routes_iri}`: {e}"))?;
let peek = kernel
.issue(Request::new(Verb::Source, iri), cap)
.await
.map_err(|e| format!("resolving {routes_iri}: {e}"))?;
if looks_like_json(&peek.bytes) {
return parse_json_routes(&peek.bytes);
}
if routes_iri.ends_with(".yaml") || routes_iri.ends_with(".yml") {
return parse_yaml_routes(&peek.bytes);
}
let select = Iri::parse("urn:sparql:select").map_err(|e| e.to_string())?;
let request = Request::new(Verb::Source, select)
.with_arg("query", ArgRef::Inline(ROUTE_QUERY.as_bytes().to_vec()))
.with_arg("graph", ArgRef::Inline(routes_iri.as_bytes().to_vec()));
let repr = kernel
.issue(request, cap)
.await
.map_err(|e| format!("querying {routes_iri}: {e}"))?;
parse_route_solutions(&repr.bytes)
}
fn looks_like_json(bytes: &[u8]) -> bool {
bytes
.iter()
.find(|b| !b.is_ascii_whitespace())
.map(|b| *b == b'{')
.unwrap_or(false)
}
pub fn parse_json_routes(bytes: &[u8]) -> Result<RouteTable, String> {
let v: serde_json::Value =
serde_json::from_slice(bytes).map_err(|e| format!("route JSON: {e}"))?;
routes_from_value(&v)
}
pub fn parse_yaml_routes(bytes: &[u8]) -> Result<RouteTable, String> {
let v: serde_json::Value =
serde_yaml::from_slice(bytes).map_err(|e| format!("route YAML: {e}"))?;
routes_from_value(&v)
}
fn routes_from_value(v: &serde_json::Value) -> Result<RouteTable, String> {
let arr = v
.get("routes")
.and_then(|r| r.as_array())
.ok_or_else(|| "route JSON: missing a `routes` array".to_string())?;
let strs = |val: &serde_json::Value, k: &str| -> Vec<String> {
val.get(k)
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
};
let mut ordered: Vec<(i64, Route)> = Vec::new();
for r in arr {
let (pattern, iri_template) = match (
r.get("match").and_then(|x| x.as_str()),
r.get("target").and_then(|x| x.as_str()),
) {
(Some(m), Some(t)) => (m.to_string(), t.to_string()),
_ => continue,
};
let cors = r.get("cors").filter(|c| c.is_object()).and_then(|c| {
let origins = strs(c, "origin");
let methods = strs(c, "method");
let headers = strs(c, "header");
let credentials = c
.get("credentials")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let max_age = c.get("maxAge").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let empty = origins.is_empty()
&& methods.is_empty()
&& headers.is_empty()
&& !credentials
&& max_age == 0;
(!empty).then_some(CorsPolicy {
allowed_origins: origins,
allowed_methods: methods,
allowed_headers: headers,
allow_credentials: credentials,
max_age,
})
});
let caps = strs(r, "cap");
let order = r.get("order").and_then(|x| x.as_i64()).unwrap_or(0);
ordered.push((
order,
Route {
pattern,
iri_template,
cap: (!caps.is_empty()).then_some(caps),
cors,
csp: r.get("csp").and_then(|x| x.as_str()).map(String::from),
},
));
}
ordered.sort_by_key(|(o, _)| *o);
Ok(RouteTable::new(
ordered.into_iter().map(|(_, r)| r).collect(),
))
}
pub fn parse_route_solutions(json: &[u8]) -> Result<RouteTable, String> {
let v: serde_json::Value =
serde_json::from_slice(json).map_err(|e| format!("route results are not JSON: {e}"))?;
let bindings = v
.get("results")
.and_then(|r| r.get("bindings"))
.and_then(|b| b.as_array())
.ok_or_else(|| "route results: missing results.bindings".to_string())?;
let mut routes = Vec::new();
for b in bindings {
let val = |k: &str| {
b.get(k)
.and_then(|x| x.get("value"))
.and_then(|x| x.as_str())
};
let list = |k: &str| -> Vec<String> {
val(k)
.map(|s| {
s.split('\n')
.filter(|t| !t.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
};
let (pattern, iri_template) = match (val("match"), val("target")) {
(Some(m), Some(t)) => (m.to_string(), t.to_string()),
_ => continue,
};
let origins = list("corsOrigins");
let methods = list("corsMethods");
let headers = list("corsHeaders");
let credentials = val("corsCredentials") == Some("true");
let max_age = val("corsMaxAge")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
let cors = if !origins.is_empty()
|| !methods.is_empty()
|| !headers.is_empty()
|| credentials
|| max_age > 0
{
Some(CorsPolicy {
allowed_origins: origins,
allowed_methods: methods,
allowed_headers: headers,
allow_credentials: credentials,
max_age,
})
} else {
None
};
let caps = list("caps");
routes.push(Route {
pattern,
iri_template,
cap: if caps.is_empty() { None } else { Some(caps) },
cors,
csp: val("csp").map(String::from),
});
}
Ok(RouteTable::new(routes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_route_with_cap_csp_and_cors() {
let json = br#"{
"head": {"vars": ["match","target","csp","caps","corsOrigins","corsMaxAge"]},
"results": {"bindings": [
{
"match": {"type":"literal","value":"/book/{host}"},
"target": {"type":"literal","value":"urn:schedule:{host}"},
"csp": {"type":"literal","value":"default-src 'self'"},
"caps": {"type":"literal","value":"urn:cap:personal:calendar:read:freebusy"},
"corsOrigins": {"type":"literal","value":"https://sletten.com"},
"corsMaxAge": {"type":"literal","value":"600"}
}
]}
}"#;
let table = parse_route_solutions(json).unwrap();
assert_eq!(table.routes.len(), 1);
let r = &table.routes[0];
assert_eq!(r.pattern, "/book/{host}");
assert_eq!(r.iri_template, "urn:schedule:{host}");
assert_eq!(
r.cap.as_deref(),
Some(&["urn:cap:personal:calendar:read:freebusy".to_string()][..])
);
assert_eq!(r.csp.as_deref(), Some("default-src 'self'"));
let cors = r.cors.as_ref().expect("cors");
assert_eq!(cors.allowed_origins, vec!["https://sletten.com"]);
assert_eq!(cors.max_age, 600);
}
#[test]
fn multivalued_caps_split_on_newline() {
let json = br#"{"head":{"vars":[]},"results":{"bindings":[
{"match":{"type":"literal","value":"/a"},"target":{"type":"literal","value":"urn:a"},
"caps":{"type":"literal","value":"urn:cap:x\nurn:cap:y"}}
]}}"#;
let table = parse_route_solutions(json).unwrap();
assert_eq!(
table.routes[0].cap.as_deref(),
Some(&["urn:cap:x".to_string(), "urn:cap:y".to_string()][..])
);
}
#[test]
fn a_route_without_cors_or_cap_is_bare() {
let json = br#"{"head":{"vars":[]},"results":{"bindings":[
{"match":{"type":"literal","value":"/a"},"target":{"type":"literal","value":"urn:a"}}
]}}"#;
let table = parse_route_solutions(json).unwrap();
let r = &table.routes[0];
assert!(r.cap.is_none() && r.cors.is_none() && r.csp.is_none());
}
#[test]
fn no_bindings_is_an_empty_table() {
let json = br#"{"head":{"vars":[]},"results":{"bindings":[]}}"#;
assert_eq!(parse_route_solutions(json).unwrap().routes.len(), 0);
}
#[test]
fn json_route_file_parses_directly() {
let json = br#"{
"routes": [
{ "id": "api", "order": 20, "match": "/api/{what}", "target": "urn:kernel:{what}",
"cap": ["urn:cap:a", "urn:cap:b"],
"cors": { "origin": ["https://app.example"], "maxAge": 600 } },
{ "id": "home", "order": 10, "match": "/", "target": "urn:host:info" }
]
}"#;
let table = parse_json_routes(json).unwrap();
assert_eq!(table.routes.len(), 2);
assert_eq!(table.routes[0].pattern, "/");
assert_eq!(table.routes[1].pattern, "/api/{what}");
let api = &table.routes[1];
assert_eq!(api.iri_template, "urn:kernel:{what}");
assert_eq!(
api.cap.as_deref(),
Some(&["urn:cap:a".to_string(), "urn:cap:b".to_string()][..])
);
let cors = api.cors.as_ref().unwrap();
assert_eq!(cors.allowed_origins, vec!["https://app.example"]);
assert_eq!(cors.max_age, 600);
assert!(table.routes[0].cap.is_none() && table.routes[0].cors.is_none());
}
#[test]
fn yaml_route_file_parses_to_the_same_table() {
let yaml = br#"
routes:
- id: api
order: 20
match: "/api/{what}"
target: "urn:kernel:{what}"
cap: [urn:cap:a, urn:cap:b]
cors:
origin: [https://app.example]
maxAge: 600
- id: home
order: 10
match: "/"
target: urn:host:info
"#;
let table = parse_yaml_routes(yaml).unwrap();
assert_eq!(table.routes.len(), 2);
assert_eq!(table.routes[0].pattern, "/"); let api = &table.routes[1];
assert_eq!(api.iri_template, "urn:kernel:{what}");
assert_eq!(
api.cap.as_deref(),
Some(&["urn:cap:a".to_string(), "urn:cap:b".to_string()][..])
);
assert_eq!(api.cors.as_ref().unwrap().max_age, 600);
}
#[test]
fn looks_like_json_splits_the_formats() {
assert!(looks_like_json(b" \n { \"routes\": [] }"));
assert!(!looks_like_json(
b"@prefix ik: <https://ikigai-rs.dev/ns#> ."
));
assert!(!looks_like_json(b"# a comment\n@prefix ik: <...> ."));
assert!(!looks_like_json(b""));
}
#[test]
fn watch_path_only_resolves_urn_file_resources() {
let root = std::path::Path::new("/root");
assert_eq!(
watch_path("urn:file:web/routes.ttl", root),
Some(std::path::PathBuf::from("/root/web/routes.ttl"))
);
assert_eq!(watch_path("urn:web:routes", root), None);
assert_eq!(watch_path("https://example.com/routes.ttl", root), None);
}
}