Transreption
Resolution said a resource does not have a format: it has whatever formats the kernel can reach from what it has. This chapter is where you find out what “reach” means, by using a transreptor and then writing one.
The code is crates/building-endpoints, this part’s crate. Its host chains onto Part I’s
space, so everything you built there is bound here too, plus the four endpoints these
chapters add:
/// This part's space: Part I's bindings, the published SPARQL face, and the four
/// endpoints these chapters build. `home` is where [`config`] looks for its file — a
/// test hands it a directory of its own; [`kernel`] hands it this machine's.
pub fn space(home: Option<PathBuf>) -> Fallback {
let ours: EndpointSpace = hello_camel::space()
.bind(
Exact::new("urn:iki:tutorial:markdown"),
transreption::markdown(),
)
.bind(
Exact::new("urn:iki:tutorial:counter"),
multi_verb::counter(),
)
.bind(Exact::new("urn:iki:tutorial:stamp"), hermetic::stamp())
.bind(
Exact::new("urn:iki:tutorial:banner"),
config::banner(home, Some("yours")),
);
// Two spaces, first match wins: ours, then `urn:sparql:*` from the published crate.
Fallback::new(vec![
Arc::new(ours) as Arc<dyn Space>,
Arc::new(ikigai_sparql::space()) as Arc<dyn Space>,
])
}
Using one
The ikigai CLI links a transreptor between RDF syntaxes, urn:rdf:transrept. It
declares what it converts — describe it and look for the transrepts: line — and it is
driven the way every transreptor is driven: content in through the pipe, the target type
in as:
ikigai --plain -c 'source urn:kernel:catalog | urn:rdf:transrept as=application/ld+json'
The catalog came out as Turtle and went in as Turtle; what came back is the same graph as
JSON-LD. Nothing about urn:kernel:catalog knows JSON-LD exists. That is the shape:
the format is a property of the route, not of the resource.
What a transreptor declares
An ordinary endpoint’s description says what it does. A transreptor’s says, in addition,
what it converts between — Description::transreptor(from, to) — and that declaration
is what the kernel selects on. Two rules follow from how selection works, and they are
worth knowing before you write one:
- A transreptor is auto-invocable when its only required inputs are
contentandas. Those are exactly the two the kernel supplies when it drives a step. Declare a third required input — a stylesheet, say — and it is still a transreptor, still callable by hand, and never chosen automatically. - Routing is one hop, or two through Turtle. If no transreptor goes directly from
what you have to what you want, the kernel looks for one that reaches
text/turtleand one that leaves it. Turtle is the pivot, which is why the vocabulary renders to Turtle first and everything else second.
Writing one
The endpoint below turns the ikigai description vocabulary — the Turtle every Meta
answers with — into Markdown. It parses real triples rather than lines, so it works on
one endpoint’s description or a whole catalog, and it knows nothing about which:
/// Render the ikigai description vocabulary as Markdown: one section per endpoint, its
/// summary, and a bullet per declared input.
///
/// The input is parsed as RDF — real triples, not lines — so this works on any Turtle
/// carrying the vocabulary, whether it came from one `Meta` or a whole catalog. The
/// `content` argument is the document; a transreptor reads its input from there because
/// that is where the kernel puts it when it drives a transreption step.
pub fn markdown_impl(inv: &Invocation<'_>) -> Result<Representation> {
let turtle = inv.inline_arg("content")?;
let triples: Vec<Triple> = oxttl::TurtleParser::new()
.for_slice(turtle)
.collect::<std::result::Result<_, _>>()
.map_err(|e| Error::Endpoint(format!("not Turtle: {e}")))?;
// Every triple, grouped by subject and then by predicate — stable order, so the
// output is diffable and a test can assert on it.
let mut by_subject: BTreeMap<String, BTreeMap<String, Vec<Term>>> = BTreeMap::new();
for triple in triples {
let subject = match triple.subject {
NamedOrBlankNode::NamedNode(node) => node.as_str().to_string(),
other => other.to_string(),
};
by_subject
.entry(subject)
.or_default()
.entry(triple.predicate.as_str().to_string())
.or_default()
.push(triple.object);
}
let literal = |node: &BTreeMap<String, Vec<Term>>, property: &str| -> Option<String> {
node.get(&format!("{IK}{property}"))
.and_then(|terms| terms.first())
.and_then(|term| match term {
Term::Literal(literal) => Some(literal.value().to_string()),
_ => None,
})
};
let mut out = String::new();
for (iri, node) in &by_subject {
let is_endpoint = node
.get(RDF_TYPE)
.is_some_and(|types| types.iter().any(|t| t.to_string().contains("ns#Endpoint")));
if !is_endpoint {
continue;
}
let id = literal(node, "id").unwrap_or_else(|| iri.clone());
let title = literal(node, "title").unwrap_or_else(|| id.clone());
out.push_str(&format!("## {title} (`{id}`)\n\n"));
if let Some(summary) = literal(node, "summary") {
out.push_str(&format!("{summary}\n\n"));
}
// Inputs are nodes of their own; follow the `ik:input` edges to them.
let inputs = node.get(&format!("{IK}input")).into_iter().flatten();
let mut listed = false;
for input in inputs {
let Term::NamedNode(input_iri) = input else {
continue;
};
let Some(input_node) = by_subject.get(input_iri.as_str()) else {
continue;
};
let name = literal(input_node, "inputName").unwrap_or_default();
let required = literal(input_node, "required").as_deref() == Some("true");
let summary = literal(input_node, "summary").unwrap_or_default();
out.push_str(&format!(
"- `{name}`{} — {summary}\n",
if required { " (required)" } else { "" }
));
listed = true;
}
if listed {
out.push('\n');
}
}
Ok(Representation::new(text_markdown_utf8(), out.into_bytes()).cacheable())
}
And its description, which is the half that makes it reachable:
/// `markdown`: a transreptor from `text/turtle` to `text/markdown`.
///
/// The description is the interesting half. `.transreptor(from, to)` is what makes the
/// kernel *select* this endpoint when something asks for Markdown and has Turtle; the
/// two inputs are `content` and `as`, and both are what the kernel supplies when it drives
/// a step — an auto-invocable transreptor is one whose required inputs are exactly
/// those. Declare a third required input and it is still a transreptor, but the kernel
/// will not pick it up on its own.
pub fn markdown() -> FnEndpoint {
FnEndpoint::new("markdown", markdown_impl).with_description(
Description::new("markdown")
.title("Turtle to Markdown")
.summary("Renders the ikigai description vocabulary as Markdown.")
.verb(Verb::Source)
.verb(Verb::Meta)
.input(ArgSpec::new("content").summary("the Turtle document to render"))
.input(
ArgSpec::new("as")
.summary("the target type; only text/markdown")
.optional(),
)
.output("text/markdown;charset=utf-8")
.transreptor(["text/turtle"], ["text/markdown"]),
)
}
The kernel finds it
Bound at urn:iki:tutorial:markdown, the transreptor is found by what it declared, not by
its name:
/// The kernel finds the transreptor by its declaration, not by its name.
#[test]
fn the_kernel_selects_the_transreptor_from_its_declaration() {
let kernel = kernel_in(None);
let plan = kernel
.select_transreptor("text/turtle", "text/markdown")
.expect("a route from Turtle to Markdown exists now");
assert_eq!(plan.len(), 1, "one hop");
assert_eq!(plan[0].endpoint, "urn:iki:tutorial:markdown");
assert_eq!(plan[0].to, "text/markdown");
// ...and no route to a type nothing produces.
assert!(kernel
.select_transreptor("text/turtle", "application/pdf")
.is_none());
}
And then the payoff. camel-case has never heard of Markdown. Ask for its description
as Markdown and the kernel renders Turtle, finds the route, and runs it:
/// `Meta … as=text/markdown` on an endpoint that knows nothing about Markdown. The
/// renderer emits Turtle; the kernel finds the route; the answer is Markdown.
#[test]
fn meta_reaches_markdown_through_the_transreptor() {
let kernel = kernel_in(None);
let request = Request::new(
Verb::Meta,
Iri::parse("urn:iki:tutorial:camel-case").expect("iri"),
)
.with_arg("as", ArgRef::Inline(b"text/markdown".to_vec()));
let repr = block_on(kernel.issue(request, &Capability::root())).expect("resolves");
let markdown = String::from_utf8(repr.bytes).expect("utf-8");
assert_eq!(repr.repr_type.media_type, "text/markdown");
assert!(
markdown.starts_with("## Camel-case (`camel-case`)\n"),
"{markdown}"
);
assert!(
markdown.contains("- `in` (required) — the text to camel-case"),
"{markdown}"
);
}
cargo run -p building-endpoints -- --markdown
## Camel-case (`camel-case`)
Camel-cases the UTF-8 text supplied in the `in` argument.
- `in` (required) — the text to camel-case
Look at what was not written: no as=text/markdown branch in camel-case, no registry of
formats, no call from the requester to the transreptor. A new format became reachable
from every endpoint in the host by binding one endpoint that declared what it converts.
⚠ What the kernel routes today is
Meta. ASourcewithas=reaches the endpoint withasas an ordinary argument — an endpoint that offers faces (text and Turtle, say) reads it and answers, and one that does not ignores it. Routing aSourcethrough a transreptor chain is what the pipe does explicitly:source X | urn:rdf:transrept as=…. If you expectedsource urn:iki:tutorial:camel-case as=text/markdownto produce Markdown, that is the chapter’s one disappointment, and it is stated here so it is not discovered at a keyboard.
Try it
#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};
let kernel = building_endpoints::kernel_in(None);
// The transreptor, driven by hand, exactly as the kernel drives it: content plus as.
let turtle = "@prefix ik: <https://ikigai-rs.dev/ns#> .
<urn:x> a ik:Endpoint ; ik:id \"x\" ; ik:title \"Example\" ; ik:summary \"Made up.\" .";
let request = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:markdown").unwrap())
.with_arg("content", ArgRef::Inline(turtle.as_bytes().to_vec()))
.with_arg("as", ArgRef::Inline(b"text/markdown".to_vec()));
let repr = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "## Example (`x`)\n\nMade up.\n\n");
}