use async_trait::async_trait;
use ikigai_core::{
ArgRef, ArgSpec, Description, Endpoint, Error, Invocation, Iri, ReprType, Representation,
Request, Result, Verb,
};
pub const RESOURCE: &str = "urn:iki:foaf";
pub const STYLESHEET: &str = "urn:file:foaf.xsl";
pub const FRAGMENT_STYLESHEET: &str = "urn:file:foaf-fragment.xsl";
pub const CONTEXT: &str = "urn:file:foaf.context.jsonld";
const XSD_ANY_URI: &str = "http://www.w3.org/2001/XMLSchema#anyURI";
const XSD_BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
const RDF_XML: &str = "application/rdf+xml";
const HTML: &str = "text/html";
const JSON_LD: &str = "application/ld+json";
pub const FACES: &[&str] = &[
HTML,
RDF_XML,
JSON_LD,
"text/turtle",
"application/n-triples",
"application/n-quads",
"application/trig",
];
pub const FORMATS: &[&str] = &[
"html", "rdfxml", "jsonld", "turtle", "ntriples", "nquads", "trig",
];
pub struct Foaf;
#[async_trait]
impl Endpoint for Foaf {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
let src = inv.inline_str("src")?;
let face = requested_face(inv)?;
let fragment = flag(inv, "fragment")?;
let doc = inv
.issue(
Request::new(Verb::Source, iri("urn:httpGet"))
.with_arg("url", ArgRef::Inline(src.as_bytes().to_vec())),
)
.await?;
let repr = match face {
RDF_XML => Representation::new(ReprType::new(RDF_XML), doc.bytes),
HTML => {
let stylesheet = if fragment {
FRAGMENT_STYLESHEET
} else {
STYLESHEET
};
let styled = inv
.issue(
Request::new(Verb::Source, iri("urn:xslt:transform"))
.with_arg("content", ArgRef::Inline(doc.bytes))
.with_arg("stylesheet", ArgRef::Inline(stylesheet.as_bytes().to_vec()))
.with_arg("as", ArgRef::Inline(HTML.as_bytes().to_vec())),
)
.await?;
Representation::new(styled.repr_type, styled.bytes).depends_on(stylesheet)
}
JSON_LD => {
let expanded = transrept(inv, doc.bytes, JSON_LD).await?;
let compacted = inv
.issue(
Request::new(Verb::Source, iri("urn:jsonld:compact"))
.with_arg("content", ArgRef::Inline(expanded.bytes))
.with_arg("context", ArgRef::Inline(CONTEXT.as_bytes().to_vec())),
)
.await?;
Representation::new(compacted.repr_type, compacted.bytes).depends_on(CONTEXT)
}
other => {
let out = transrept(inv, doc.bytes, other).await?;
Representation::new(out.repr_type, out.bytes)
}
};
Ok(repr.cacheable())
}
fn name(&self) -> &str {
"foaf"
}
fn describe(&self) -> Description {
let mut description = Description::new("foaf")
.title("FOAF document, negotiated")
.summary(
"Render one FOAF (RDF/XML) document as a page, as JSON-LD, or as any RDF \
syntax — `Accept` selects the face; with no preference the document itself \
is returned. The source must be under a granted `urn:cap:net:` host.",
)
.verb(Verb::Source)
.verb(Verb::Meta)
.input(
ArgSpec::new("src")
.summary("the RDF document to render")
.class(XSD_ANY_URI),
)
.input(
ArgSpec::new("format")
.class(XSD_STRING)
.summary(
"the face, for a plain link (the adapter reserves `as`, so `?as=` is \
dropped): html, rdfxml, jsonld, turtle, ntriples, nquads or trig. \
When present it wins; `Accept` decides only when it is absent",
)
.one_of(FORMATS.iter().copied())
.optional(),
)
.input(
ArgSpec::new("fragment")
.summary(
"HTML face only: emit just `<main id=\"main\">` (for transclusion) \
instead of a whole page",
)
.class(XSD_BOOLEAN)
.default_value("false"),
)
.requires("urn:cap:net:*");
for face in FACES {
description = description.output(*face);
}
description
}
}
fn requested_face(inv: &Invocation<'_>) -> Result<&'static str> {
if let Some(format) = inv
.inline_str("format")
.ok()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
{
return FORMATS
.iter()
.position(|name| *name == format)
.map(|i| FACES[i])
.ok_or_else(|| Error::InvalidArgument {
name: "format".to_string(),
detail: format!(
"`{format}` is not a face of this document; the formats are {}",
FORMATS.join(", ")
),
});
}
let asked = inv
.inline_str("as")
.ok()
.map(|s| media_base(s).to_ascii_lowercase())
.unwrap_or_default();
if asked.is_empty() || asked == "*/*" {
return Ok(RDF_XML);
}
FACES
.iter()
.copied()
.find(|face| *face == asked)
.ok_or_else(|| Error::InvalidArgument {
name: "as".to_string(),
detail: format!(
"`{asked}` is not a face of this document; the faces are {}",
FACES.join(", ")
),
})
}
fn media_base(media: &str) -> &str {
media.split(';').next().unwrap_or(media).trim()
}
fn flag(inv: &Invocation<'_>, name: &str) -> Result<bool> {
let Ok(raw) = inv.inline_str(name) else {
return Ok(false);
};
match raw.trim().to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" | "off" => Ok(false),
"1" | "true" | "yes" | "on" => Ok(true),
other => Err(Error::InvalidArgument {
name: name.to_string(),
detail: format!("`{other}` is not a boolean (use true or false)"),
}),
}
}
async fn transrept(inv: &Invocation<'_>, bytes: Vec<u8>, as_type: &str) -> Result<Representation> {
inv.issue(
Request::new(Verb::Source, iri("urn:rdf:transrept"))
.with_arg("content", ArgRef::Inline(bytes))
.with_arg("as", ArgRef::Inline(as_type.as_bytes().to_vec())),
)
.await
}
fn iri(s: &str) -> Iri {
Iri::parse(s).expect("a constant IRI")
}
#[cfg(test)]
mod tests {
use super::*;
use futures::executor::block_on;
use ikigai_core::{Capability, EndpointSpace, Exact, Fallback, Kernel, Space, SystemClock};
use ikigai_http::{HttpRequest, HttpResponse, HttpTransport};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
const IDENTIFIER: &str = "https://w3id.org/people/bsletten";
const DOCUMENT: &str = "https://www.bosatsu.net/foaf/brian.rdf";
const ESCAPE: &str = "https://w3id.org/people/escape";
const FIXTURE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:foaf="http://xmlns.com/foaf/0.1/">
<foaf:Person rdf:about="https://w3id.org/people/bsletten">
<foaf:name>Brian Sletten</foaf:name>
<foaf:homepage rdf:resource="https://www.bosatsu.net/"/>
</foaf:Person>
</rdf:RDF>
"#;
fn page_xsl(heading: &str) -> String {
format!(
r#"<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:foaf="http://xmlns.com/foaf/0.1/">
<xsl:template match="/"><html lang="en"><body><{heading}><xsl:value-of select="//foaf:name"/></{heading}></body></html></xsl:template>
</xsl:stylesheet>"#
)
}
const FRAGMENT_XSL: &str = r#"<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:foaf="http://xmlns.com/foaf/0.1/">
<xsl:template match="/"><main id="main"><h1><xsl:value-of select="//foaf:name"/></h1></main></xsl:template>
</xsl:stylesheet>"#;
const CONTEXT_JSON: &str = r#"{
"@context": {
"foaf": "http://xmlns.com/foaf/0.1/",
"name": "foaf:name",
"homepage": { "@id": "foaf:homepage", "@type": "@id" }
}
}"#;
struct Web {
fetches: AtomicUsize,
}
#[async_trait]
impl HttpTransport for Web {
async fn send(&self, request: HttpRequest) -> std::result::Result<HttpResponse, String> {
self.fetches.fetch_add(1, Ordering::SeqCst);
let redirect = |to: &str| HttpResponse {
status: 302,
headers: vec![("location".to_string(), to.to_string())],
body: Vec::new(),
};
Ok(match request.url.as_str() {
IDENTIFIER => redirect(DOCUMENT),
ESCAPE => redirect("https://example.com/x"),
DOCUMENT => HttpResponse {
status: 200,
headers: vec![
("content-type".to_string(), "text/xml".to_string()),
("cache-control".to_string(), "max-age=3600".to_string()),
],
body: FIXTURE.as_bytes().to_vec(),
},
_ => HttpResponse {
status: 404,
headers: Vec::new(),
body: b"no such page".to_vec(),
},
})
}
}
fn workspace(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ikigai-foaf-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
std::fs::write(root.join("foaf.xsl"), page_xsl("h1")).unwrap();
std::fs::write(root.join("foaf-fragment.xsl"), FRAGMENT_XSL).unwrap();
std::fs::write(root.join("foaf.context.jsonld"), CONTEXT_JSON).unwrap();
root
}
fn kernel(root: &Path) -> (Arc<Kernel>, Arc<Web>) {
let web = Arc::new(Web {
fetches: AtomicUsize::new(0),
});
let spaces: Vec<Arc<dyn Space>> = vec![
Arc::new(EndpointSpace::new().bind(Exact::new(RESOURCE), Foaf)),
Arc::new(ikigai_http::space(
Arc::clone(&web) as Arc<dyn HttpTransport>
)),
Arc::new(ikigai_rdf::space()),
Arc::new(ikigai_xslt::space()),
Arc::new(ikigai_jsonld::space()),
Arc::new(ikigai_fs::cacheable_space(root)),
];
let kernel = Kernel::new(Arc::new(Fallback::new(spaces)))
.with_clock(Arc::new(SystemClock))
.with_aliases(crate::base_alias_table());
(Arc::new(kernel), web)
}
fn ceiling(root: &Path) -> Capability {
Capability::scoped([
"urn:cap:net:w3id.org/people".to_string(),
"urn:cap:net:www.bosatsu.net/foaf".to_string(),
format!("urn:cap:fs:read:{}", root.display()),
])
}
fn request(src: &str, args: &[(&str, &str)]) -> Request {
request_at(RESOURCE, src, args)
}
fn request_at(target: &str, src: &str, args: &[(&str, &str)]) -> Request {
let mut request = Request::new(Verb::Source, iri(target))
.with_arg("src", ArgRef::Inline(src.as_bytes().to_vec()));
for (name, value) in args {
request = request.with_arg(*name, ArgRef::Inline(value.as_bytes().to_vec()));
}
request
}
fn resolve(kernel: &Kernel, cap: &Capability, args: &[(&str, &str)]) -> Result<Representation> {
block_on(kernel.issue(request(IDENTIFIER, args), cap))
}
fn text(repr: &Representation) -> &str {
std::str::from_utf8(&repr.bytes).unwrap()
}
#[test]
fn the_default_face_is_the_document_itself() {
let root = workspace("default");
let (kernel, web) = kernel(&root);
let cap = ceiling(&root);
for args in [
&[][..],
&[("as", "*/*")][..],
&[("as", "application/rdf+xml")][..],
] {
let repr = resolve(&kernel, &cap, args).unwrap();
assert_eq!(
repr.repr_type.to_string(),
"application/rdf+xml",
"{args:?}"
);
assert_eq!(text(&repr), FIXTURE, "{args:?}");
}
assert_eq!(web.fetches.load(Ordering::SeqCst), 2);
}
#[test]
fn the_html_face_is_the_stylesheet_over_the_document() {
let root = workspace("html");
let (kernel, _) = kernel(&root);
let cap = ceiling(&root);
let page = resolve(&kernel, &cap, &[("as", "text/html")]).unwrap();
assert_eq!(page.repr_type.to_string(), "text/html;charset=utf-8");
assert!(
text(&page).contains("<h1>Brian Sletten</h1>"),
"{}",
text(&page)
);
assert!(
text(&page).contains("<html"),
"a whole page: {}",
text(&page)
);
let same = resolve(&kernel, &cap, &[("as", "text/html;q=0.9")]).unwrap();
assert_eq!(same.bytes, page.bytes);
}
#[test]
fn fragment_selects_the_transclusion_stylesheet() {
let root = workspace("fragment");
let (kernel, _) = kernel(&root);
let cap = ceiling(&root);
let main = resolve(&kernel, &cap, &[("as", "text/html"), ("fragment", "1")]).unwrap();
assert!(text(&main).starts_with("<main id="), "{}", text(&main));
assert!(
!text(&main).contains("<html"),
"only the fragment: {}",
text(&main)
);
let err =
resolve(&kernel, &cap, &[("as", "text/html"), ("fragment", "maybe")]).unwrap_err();
assert!(
matches!(&err, Error::InvalidArgument { name, .. } if name == "fragment"),
"{err:?}"
);
}
#[test]
fn the_jsonld_face_is_compacted_against_the_context() {
let root = workspace("jsonld");
let (kernel, _) = kernel(&root);
let cap = ceiling(&root);
let repr = resolve(&kernel, &cap, &[("as", "application/ld+json")]).unwrap();
assert!(
repr.repr_type
.to_string()
.starts_with("application/ld+json"),
"{}",
repr.repr_type
);
let body = text(&repr);
let doc: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(doc["name"], "Brian Sletten", "{body}");
assert_eq!(doc["homepage"], "https://www.bosatsu.net/", "{body}");
assert_eq!(doc["@id"], IDENTIFIER, "{body}");
assert!(doc.get("@context").is_some(), "{body}");
assert!(
!body.contains("http://xmlns.com/foaf/0.1/name"),
"not compacted: {body}"
);
assert!(
body.find("\"@context\"").unwrap() < body.find("\"@id\"").unwrap(),
"@context first: {body}"
);
}
#[test]
fn every_declared_face_is_reachable() {
let root = workspace("faces");
let (kernel, _) = kernel(&root);
let cap = ceiling(&root);
for face in FACES {
let repr = resolve(&kernel, &cap, &[("as", face)]).unwrap();
assert!(
repr.repr_type.to_string().starts_with(face),
"{face}: got {}",
repr.repr_type
);
assert!(
text(&repr).contains("Brian Sletten"),
"{face}: {}",
text(&repr)
);
}
let turtle = resolve(&kernel, &cap, &[("as", "text/turtle")]).unwrap();
assert!(
text(&turtle).contains("<https://w3id.org/people/bsletten>"),
"{}",
text(&turtle)
);
let nt = resolve(&kernel, &cap, &[("as", "application/n-triples")]).unwrap();
assert!(
text(&nt).contains("<http://xmlns.com/foaf/0.1/name> \"Brian Sletten\""),
"{}",
text(&nt)
);
let outputs = Foaf.describe().outputs;
assert_eq!(
outputs,
FACES.iter().map(|s| s.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn format_chooses_a_face_for_a_plain_link_and_wins_over_accept() {
let root = workspace("format");
let (kernel, _) = kernel(&root);
let cap = ceiling(&root);
for (name, face) in FORMATS.iter().zip(FACES) {
let repr = resolve(&kernel, &cap, &[("format", name)]).unwrap();
assert!(
repr.repr_type.to_string().starts_with(face),
"format={name}: got {}",
repr.repr_type
);
}
let turtle = resolve(&kernel, &cap, &[("as", "text/html"), ("format", "turtle")]).unwrap();
assert_eq!(turtle.repr_type.to_string(), "text/turtle;charset=utf-8");
let page = resolve(&kernel, &cap, &[("as", "text/html"), ("format", "")]).unwrap();
assert!(page.repr_type.to_string().starts_with("text/html"));
let err = resolve(&kernel, &cap, &[("format", "pdf")]).unwrap_err();
match &err {
Error::InvalidArgument { name, detail } => {
assert_eq!(name, "format");
assert!(detail.contains("turtle"), "{detail}");
}
other => panic!("expected InvalidArgument (→ 400), got {other:?}"),
}
}
#[test]
fn a_face_the_chain_does_not_reach_is_a_typed_bad_request() {
let root = workspace("unreachable");
let (kernel, _) = kernel(&root);
let cap = ceiling(&root);
let err = resolve(&kernel, &cap, &[("as", "image/png")]).unwrap_err();
match &err {
Error::InvalidArgument { name, detail } => {
assert_eq!(name, "as");
for face in FACES {
assert!(detail.contains(face), "names every face: {detail}");
}
}
other => panic!("expected InvalidArgument (→ 400), got {other:?}"),
}
}
#[test]
fn a_source_outside_the_granted_hosts_is_denied_before_any_fetch() {
let root = workspace("denied");
let (kernel, web) = kernel(&root);
let cap = ceiling(&root);
let err = block_on(kernel.issue(request("https://example.com/x", &[]), &cap)).unwrap_err();
assert!(matches!(err, Error::Denied(_)), "{err:?}");
assert_eq!(
web.fetches.load(Ordering::SeqCst),
0,
"denied at the floor, not after I/O"
);
let err =
block_on(kernel.issue(request("https://w3id.org/other/x", &[]), &cap)).unwrap_err();
assert!(matches!(err, Error::Denied(_)), "{err:?}");
assert_eq!(web.fetches.load(Ordering::SeqCst), 0);
}
#[test]
fn a_redirect_out_of_the_allowlist_is_denied_at_that_hop() {
let root = workspace("redirect");
let (kernel, web) = kernel(&root);
let cap = ceiling(&root);
let err = block_on(kernel.issue(request(ESCAPE, &[]), &cap)).unwrap_err();
assert!(matches!(err, Error::Denied(_)), "{err:?}");
assert_eq!(web.fetches.load(Ordering::SeqCst), 1);
}
#[test]
fn a_missing_src_is_a_typed_bad_request() {
let root = workspace("missing");
let (kernel, _) = kernel(&root);
let err =
block_on(kernel.issue(Request::new(Verb::Source, iri(RESOURCE)), &ceiling(&root)))
.unwrap_err();
assert!(
matches!(err, Error::MissingArgument(ref name) if name == "src"),
"{err:?}"
);
}
#[test]
fn the_old_spelling_and_the_canonical_name_are_one_cache_entry() {
let root = workspace("alias");
let (kernel, web) = kernel(&root);
let cap = ceiling(&root);
let canonical = block_on(kernel.issue(request(IDENTIFIER, &[]), &cap)).unwrap();
let after_first = web.fetches.load(Ordering::SeqCst);
assert_eq!(after_first, 2);
let old = block_on(kernel.issue(request_at("urn:foaf", IDENTIFIER, &[]), &cap)).unwrap();
assert_eq!(old.bytes, canonical.bytes);
assert_eq!(
web.fetches.load(Ordering::SeqCst),
after_first,
"the old spelling hit the canonical name's cache entry: another fetch would mean \
two entries, hence two thread sets and a cut that reaches only one"
);
assert!(kernel.is_cached(&request(IDENTIFIER, &[]), &cap));
}
#[test]
fn without_the_alias_the_old_spelling_is_unresolved() {
let web = Arc::new(Web {
fetches: AtomicUsize::new(0),
});
let spaces: Vec<Arc<dyn Space>> = vec![
Arc::new(EndpointSpace::new().bind(Exact::new(RESOURCE), Foaf)),
Arc::new(ikigai_http::space(web as Arc<dyn HttpTransport>)),
];
let bare = Kernel::new(Arc::new(Fallback::new(spaces)));
let err =
block_on(bare.issue(request_at("urn:foaf", IDENTIFIER, &[]), &Capability::root()))
.unwrap_err();
assert!(matches!(err, Error::Unresolved(_)), "{err:?}");
}
#[test]
fn without_a_net_grant_the_door_refuses() {
let root = workspace("floor");
let (kernel, web) = kernel(&root);
let cap = Capability::scoped([format!("urn:cap:fs:read:{}", root.display())]);
let err = resolve(&kernel, &cap, &[]).unwrap_err();
assert!(matches!(err, Error::Denied(_)), "{err:?}");
assert_eq!(web.fetches.load(Ordering::SeqCst), 0);
}
#[test]
fn the_html_face_recomputes_when_the_stylesheet_thread_is_cut() {
let root = workspace("cut");
let (kernel, web) = kernel(&root);
let cap = ceiling(&root);
let html = &[("as", "text/html")][..];
let first = resolve(&kernel, &cap, html).unwrap();
assert!(text(&first).contains("<h1>Brian Sletten</h1>"));
assert!(
kernel.is_cached(&request(IDENTIFIER, html), &cap),
"cached under the document's max-age"
);
std::fs::write(root.join("foaf.xsl"), page_xsl("h2")).unwrap();
let stale = resolve(&kernel, &cap, html).unwrap();
assert_eq!(
stale.bytes, first.bytes,
"no cut yet, so still the cached page"
);
kernel.cut(STYLESHEET);
let fresh = resolve(&kernel, &cap, html).unwrap();
assert!(
text(&fresh).contains("<h2>Brian Sletten</h2>"),
"{}",
text(&fresh)
);
assert_eq!(web.fetches.load(Ordering::SeqCst), 2);
}
#[test]
fn the_description_carries_the_class_and_the_family_grant() {
let d = Foaf.describe();
let src = d
.inputs
.iter()
.find(|i| i.name == "src")
.expect("src declared");
assert!(src.required);
assert_eq!(src.class.as_deref(), Some(XSD_ANY_URI));
let fragment = d
.inputs
.iter()
.find(|i| i.name == "fragment")
.expect("fragment declared");
assert!(!fragment.required);
assert_eq!(fragment.class.as_deref(), Some(XSD_BOOLEAN));
let format = d
.inputs
.iter()
.find(|i| i.name == "format")
.expect("format declared");
assert!(!format.required);
assert_eq!(
format.one_of,
FORMATS.iter().map(|s| s.to_string()).collect::<Vec<_>>()
);
assert_eq!(FORMATS.len(), FACES.len(), "one name per face, by position");
assert!(
d.inputs.iter().all(|i| i.name != "as"),
"`as` is the adapter's"
);
assert_eq!(d.requires, vec!["urn:cap:net:*".to_string()]);
assert!(d.verbs.contains(&Verb::Source));
}
}