Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Checking a resource

Transreption ended on a rule: the format is a property of the route, not of the resource. Follow it one step further. If a route reaches Turtle, the resource has a Turtle face, whatever it was written in, and anything with a Turtle face can be checked: does it parse, and does what it says fit the shapes it is supposed to fit. This chapter calls that a CHECK. The RDF Toolbox (ikigai-toolbox) is the page that does it in a browser, and this chapter builds what the page reads and how it reads it.

The code is crates/check-resource. Unlike the other chapters of this part, it does not chain onto Part I’s space: its host holds two players of tic-tac-toe, a transreptor, the shapes a player must fit, and two published spaces beside them. Nothing in it starts a server, so there is nothing to stop afterwards.

/// The chapter's host: its own doors, then the toolbox's names, then the shapes check.
pub fn space() -> Fallback {
    let ours = EndpointSpace::new()
        .bind(Exact::new(PLAYER_X), player("player-x", X_TRIPLES))
        .bind(Exact::new(PLAYER_O), player("player-o", O_TRIPLES))
        .bind(Exact::new(SHAPES), shapes())
        .bind(Exact::new(TO_TURTLE), to_turtle());
    Fallback::new(vec![
        Arc::new(ours) as Arc<dyn Space>,
        Arc::new(ikigai_toolbox::space()) as Arc<dyn Space>,
        Arc::new(ikigai_shacl::space()) as Arc<dyn Space>,
    ])
}

A resource with no Turtle in it

Each player answers N-Triples, one triple per line, and nothing else. Reading one requires a scope, declared the way Multi-verb endpoints declared the counter’s write: the kernel enforces the requires, so the endpoint’s code holds no check of its own.

/// The two players, as N-Triples. O carries one property the shapes do not name.
const X_TRIPLES: &str = r#"<urn:iki:tutorial:check:player:x> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/ttt#Player> .
<urn:iki:tutorial:check:player:x> <http://example.org/ttt#name> "Ada" .
<urn:iki:tutorial:check:player:x> <http://example.org/ttt#mark> "X" .
"#;
const O_TRIPLES: &str = r#"<urn:iki:tutorial:check:player:o> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/ttt#Player> .
<urn:iki:tutorial:check:player:o> <http://example.org/ttt#name> "Grace" .
<urn:iki:tutorial:check:player:o> <http://example.org/ttt#mark> "O" .
<urn:iki:tutorial:check:player:o> <http://example.org/ttt#wins> "3" .
"#;

/// A player: a graph in N-Triples, readable only by a caller holding [`READ`]. Nothing in
/// it knows Turtle exists.
fn player(id: &'static str, triples: &'static str) -> FnEndpoint {
    FnEndpoint::new(id, move |_: &Invocation<'_>| {
        Ok(Representation::new(ReprType::new(N_TRIPLES), triples.as_bytes().to_vec()).cacheable())
    })
    .with_description(
        Description::new(id)
            .title("A player")
            .summary("One player of tic-tac-toe, as an RDF graph in N-Triples.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(N_TRIPLES)
            .requires(READ),
    )
}

The Turtle face comes from a transreptor, declared lossless because every triple survives the trip. It is the kind of endpoint Transreption wrote, in the direction that reaches the pivot:

/// A transreptor from N-Triples to Turtle: the same triples, written with prefixes.
/// Lossless (every triple survives), so it is declared with no `.lossy()`.
pub fn to_turtle() -> FnEndpoint {
    FnEndpoint::new("to-turtle", |inv: &Invocation<'_>| {
        let content = inv.inline_str("content")?;
        let invalid = |detail: String| Error::InvalidArgument {
            name: "content".into(),
            detail,
        };
        let mut writer = oxttl::TurtleSerializer::new()
            .with_prefix("ttt", "http://example.org/ttt#")
            .map_err(|e| Error::Endpoint(e.to_string()))?
            .for_writer(Vec::new());
        for triple in oxttl::NTriplesParser::new().for_slice(content.as_bytes()) {
            let triple = triple.map_err(|e| invalid(e.to_string()))?;
            writer
                .serialize_triple(&triple)
                .map_err(|e| Error::Endpoint(e.to_string()))?;
        }
        let turtle = writer
            .finish()
            .map_err(|e| Error::Endpoint(e.to_string()))?;
        Ok(Representation::new(ReprType::new(TURTLE), turtle).cacheable())
    })
    .with_description(
        Description::new("to-turtle")
            .title("N-Triples to Turtle")
            .summary("The same triples, written as Turtle.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ArgSpec::new("content").summary("the N-Triples document"))
            .input(
                ArgSpec::new("as")
                    .summary("the target type; only text/turtle")
                    .optional(),
            )
            .output(TURTLE)
            .transreptor([N_TRIPLES], [TURTLE]),
    )
}

The check reads as you

The toolbox reads a resource through one name, urn:toolbox:input:{iri}: the resource sourced with as=text/turtle, and when it answers in another RDF syntax, carried to Turtle by whatever transreptor plan the kernel finds. The name declares no capability of its own. It issues that read under the caller’s capability, so a check never shows a reader more than they could read directly, and the toolbox is not a way around a scope.

/// A resource's Turtle face, as the toolbox page reads it: `urn:toolbox:input:{iri}`,
/// issued under the caller's own capability.
pub async fn turtle_face(kernel: &Kernel, iri: &str, caller: &Capability) -> Result<String> {
    let input = Iri::parse(format!("urn:toolbox:input:{iri}"))
        .map_err(|e| Error::Endpoint(format!("`{iri}` makes no input name: {e}")))?;
    let face = kernel
        .issue(Request::new(Verb::Source, input), caller)
        .await?;
    String::from_utf8(face.bytes).map_err(|e| Error::Endpoint(format!("not UTF-8: {e}")))
}
#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{Capability, Error};
use check_resource::{kernel, turtle_face, PLAYER_X, READ};

let kernel = kernel();

// A reader holding the scope gets the player's Turtle face: N-Triples in, Turtle out,
// and nothing in the player knows Turtle exists.
let reader = Capability::scoped([READ]);
let face = block_on(turtle_face(&kernel, PLAYER_X, &reader)).unwrap();
assert_eq!(
    face,
    "@prefix ttt: <http://example.org/ttt#> .\n\
     <urn:iki:tutorial:check:player:x> a ttt:Player ;\n\
     \tttt:name \"Ada\" ;\n\
     \tttt:mark \"X\" .\n"
);

// A caller without it is refused, and the refusal names the player, not the toolbox.
let stranger = Capability::scoped(["urn:cap:tutorial:counter:write"]);
let refused = block_on(turtle_face(&kernel, PLAYER_X, &stranger)).unwrap_err();
assert!(matches!(refused, Error::Denied(_)));
assert_eq!(
    refused.to_string(),
    "denied: capability does not grant `urn:cap:tutorial:players:read` \
     (declared by `urn:iki:tutorial:check:player:x`)"
);
}

This page runs the same two reads. Its kernel holds the chapter’s host beside the spaces the book’s other chapters run in, and cap narrows the page’s session the way it narrows a shell’s, until cap reset gives the authority back. Run it, then run it again: the reader’s face comes from the cache the second time, because the cache keys on the capability as well as the name.

narrowed — capability: urn:cap:tutorial:players:read
@prefix ttt: <http://example.org/ttt#> .
<urn:iki:tutorial:check:player:x> a ttt:Player ;
	ttt:name "Ada" ;
	ttt:mark "X" .
[computed]
reset to identity — capability: root (full authority)
narrowed — capability: urn:cap:tutorial:counter:write
error: denied: capability does not grant `urn:cap:tutorial:players:read` (declared by `urn:iki:tutorial:check:player:x`)
reset to identity — capability: root (full authority)

Shapes, in a paragraph

SHACL describes what a graph should look like, in a graph of its own. A node shape picks the nodes it applies to (here, sh:targetClass: every ttt:Player), and its property shapes constrain what hangs off each one: how many values a predicate may have, of which datatype, from which list. A shape is closed when it also says sh:closed true: then a node may carry only the predicates its property shapes name, and those listed in sh:ignoredProperties, and any other triple is a violation. An open shape lets a stray property through unremarked; a closed one turns it into a finding. That is why ikigai-layers requires sh:closed true on every node shape of a layer: an entry carries exactly what its layer declared, and a property nobody declared is refused rather than kept. The player shapes are closed the same way:

/// One closed node shape: a player has exactly one name and exactly one mark, X or O,
/// and nothing else but its type.
pub const PLAYER_SHAPES: &str = r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix sh:  <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix ttt: <http://example.org/ttt#> .

ttt:PlayerShape a sh:NodeShape ;
    sh:targetClass ttt:Player ;
    sh:closed true ;
    sh:ignoredProperties ( rdf:type ) ;
    sh:property [ sh:path ttt:name ; sh:datatype xsd:string ; sh:minCount 1 ; sh:maxCount 1 ] ;
    sh:property [ sh:path ttt:mark ; sh:in ( "X" "O" ) ; sh:minCount 1 ; sh:maxCount 1 ] .
"#;

Checking data against shapes

The toolbox page takes a second input for this (shapes=), read exactly like the first, and checks one against the other. The page does the checking in your browser tab, with turtle-lsp compiled to WebAssembly, and no Rust listing can run a browser tab. So here the shapes check runs on the host instead, through urn:shacl:validate from ikigai-shacl. The inputs are the same and are read the same way, as Turtle faces under one caller’s capability, but the engine is a different one:

/// CHECK `iri` against the shapes at `shapes`: both read as Turtle faces under `caller`'s
/// capability, the way the check page reads its two inputs, then `urn:shacl:validate`.
pub async fn check(
    kernel: &Kernel,
    iri: &str,
    shapes: &str,
    caller: &Capability,
) -> Result<Report> {
    let data = turtle_face(kernel, iri, caller).await?;
    let shapes = turtle_face(kernel, shapes, caller).await?;
    let validate = Request::new(
        Verb::Source,
        Iri::parse("urn:shacl:validate").expect("an IRI"),
    )
    .with_arg("data", ArgRef::Inline(data.into_bytes()))
    .with_arg("shapes", ArgRef::Inline(shapes.into_bytes()))
    .with_arg("as", ArgRef::Inline(b"application/json".to_vec()));
    let report = kernel.issue(validate, caller).await?;
    serde_json::from_slice(&report.bytes).map_err(|e| Error::Endpoint(format!("report: {e}")))
}
#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::Capability;
use check_resource::{check, kernel, PLAYER_O, PLAYER_X, READ, SHAPES};

let kernel = kernel();
let reader = Capability::scoped([READ]);

// X has a name, a mark and a type, and nothing else: it conforms.
let x = block_on(check(&kernel, PLAYER_X, SHAPES, &reader)).unwrap();
assert!(x.conforms);

// O carries `ttt:wins`, which the closed shape does not name: one violation.
let o = block_on(check(&kernel, PLAYER_O, SHAPES, &reader)).unwrap();
assert!(!o.conforms);
let found = &o.outcome().violations[..];
assert_eq!(found.len(), 1);
assert_eq!(found[0].focus_node, PLAYER_O);
assert_eq!(found[0].path.as_deref(), Some("http://example.org/ttt#wins"));
assert_eq!(found[0].component, "http://www.w3.org/ns/shacl#ClosedConstraintComponent");
}

The same check runs in this page, written the way a shell writes it: the Turtle face piped into urn:shacl:validate, where it fills data, the one required input left unnamed. X conforms. O’s report is the graph the paragraph below describes, and its one result names the property: sh:resultPath ttt:wins, from sh:ClosedConstraintComponent. The toolbox’s own engine checks the same two players further down, in The page itself.

{
  "conforms": true,
  "violations": []
}
[2 computed]
@prefix sh: <http://www.w3.org/ns/shacl#> .
<urn:ikigai:shacl:report:b3:1258eb4246e33211b9d99fd84328c8723a8aae4c2627e1c1973fca3041184544:report> a sh:ValidationReport ;
	sh:conforms false ;
	sh:result <urn:ikigai:shacl:report:b3:1258eb4246e33211b9d99fd84328c8723a8aae4c2627e1c1973fca3041184544:result.0> .
<urn:ikigai:shacl:report:b3:1258eb4246e33211b9d99fd84328c8723a8aae4c2627e1c1973fca3041184544:result.0> a sh:ValidationResult ;
	sh:focusNode <urn:iki:tutorial:check:player:o> ;
	sh:resultMessage "Closed constraint not satisfied" ;
	sh:resultPath <http://example.org/ttt#wins> ;
	sh:resultSeverity sh:Violation ;
	sh:sourceConstraintComponent sh:ClosedConstraintComponent ;
	sh:sourceShape <http://example.org/ttt#PlayerShape> ;
	sh:value "3" .
[2 computed]

The report is data, like every other answer in this book: as=application/json gives the structure above, and the default, text/turtle, is the same findings as a SHACL validation report graph you can query.

ikigai-shacl is linked here with default-features = false, which builds its subset validator: closed shapes and the common constraints, with no network stack. A term outside the subset is refused by name rather than skipped. The default build links rudof and covers all of SHACL Core.

The page itself

On a host that serves the toolbox, the check is one link. urn:toolbox:check:{iri} is the page checking that resource, shapes= adds the shapes, and a host routes /toolbox/check/{iri} to it, so the check of player O against the player shapes is:

/toolbox/check/urn%3Aiki%3Atutorial%3Acheck%3Aplayer%3Ao?shapes=urn%3Aiki%3Atutorial%3Acheck%3Ashapes#shacl

The page reads both inputs through urn:toolbox:input:{iri}, as your own browser session, so what you saw above is what it sees. What the page needs that this crate does not have is the build: the page, its scripts and the 8.3 MB engine, which ikigai-toolbox’s build.sh writes to a directory a host binds at urn:iki:toolbox:dist:{path}. This crate binds no build, so asking it for the page fails on the first file the page is composed from:

#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{Capability, Error, Iri, Request, Verb};

let page = Iri::parse("urn:toolbox:check:urn:iki:tutorial:check:player:o").unwrap();
let err = block_on(check_resource::kernel().issue(Request::new(Verb::Source, page), &Capability::root()))
    .unwrap_err();
assert!(matches!(err, Error::Unresolved(ref name) if name.as_str() == "urn:iki:toolbox:dist:page.template.html"));
}

This book does bind one. It publishes a build of ikigai-toolbox 0.1.0 beside its pages, in toolbox/, and the page’s kernel binds urn:iki:toolbox:dist:{path} to it: a door that asks the browser for toolbox/{path} when, and only when, a name needs one of its files.

/// Where the toolbox reads its build, `urn:iki:toolbox:dist:{path}` (`ikigai_toolbox::DIST`):
/// in the page, the published copy of `ikigai-toolbox`'s `build.sh` output, which pages.yml
/// puts in `toolbox/` beside the book's root and which the browser fetches only when a name
/// asks for one of its files. So `urn:toolbox:check:{iri}` composes in the page, from the
/// build's own template, and nothing is fetched until a reader opens a check.
///
/// The kernel's futures must be `Send` and a browser fetch is not, so the fetch runs as a task
/// of the page's own (`spawn_local`) and hands its answer back over a channel, which is.
#[cfg(target_family = "wasm")]
fn dist_space() -> Arc<dyn Space> {
    use ikigai_core::{AsyncFnEndpoint, Description, EndpointSpace, InvokeFuture, UriTemplate};
    let fetched = AsyncFnEndpoint::new(
        "book-toolbox-dist",
        |inv: &ikigai_core::Invocation<'_>| -> InvokeFuture<'_> {
            let path = inv.bindings.get("path").unwrap_or_default().to_string();
            let (answer, answered) = futures::channel::oneshot::channel();
            wasm_bindgen_futures::spawn_local(async move {
                let _ = answer.send(fetch_published(&path).await);
            });
            Box::pin(async move {
                answered
                    .await
                    .unwrap_or_else(|_| Err(Error::Endpoint("the fetch was dropped".into())))
            })
        },
    )
    .with_description(
        Description::new("book-toolbox-dist")
            .title("The toolbox build this book publishes")
            .summary(
                "One file of the RDF Toolbox build published beside this book (`toolbox/`), \
                 fetched by the browser when it is asked for.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output("application/octet-stream"),
    );
    Arc::new(EndpointSpace::new().bind(
        UriTemplate::parse("urn:iki:toolbox:dist:{path}").expect("a constant template"),
        fetched,
    ))
}

/// Built natively there is no published build to fetch, so nothing is bound there: a check
/// page fails on its template exactly as the chapter's own host's does.
#[cfg(not(target_family = "wasm"))]
fn dist_space() -> Arc<dyn Space> {
    Arc::new(ikigai_core::EndpointSpace::new())
}

/// The bytes of `toolbox/{path}` beside the book's root, as the browser fetches them. A name
/// that is not one plain file name is not found without a request: the toolbox asks only for
/// the files on its allowlist, and nothing else under the site is the dist's to serve.
#[cfg(target_family = "wasm")]
async fn fetch_published(path: &str) -> Result<Representation, Error> {
    use js_sys::{Function, Promise, Reflect, Uint8Array};
    use wasm_bindgen_futures::JsFuture;
    #[wasm_bindgen]
    extern "C" {
        #[wasm_bindgen(js_name = fetch)]
        fn fetch_url(url: &str) -> Promise;
    }
    let plain = !path.is_empty()
        && !path.starts_with('.')
        && path
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
    if !plain {
        return Err(Error::NotFound(format!(
            "urn:iki:toolbox:dist:{path}: not a file of the published build"
        )));
    }
    let failed = |e: JsValue| Error::Endpoint(format!("toolbox/{path}: {e:?}"));
    // Where the build is published, as `js/toolbox.js` says (`window.ikigaiToolboxBase`, the
    // absolute path of `toolbox/` beside the book's root). mdbook's `path_to_root` would do,
    // but it is a `const` in the page's head, which is not a property of the global object.
    let base = Reflect::get(&js_sys::global(), &JsValue::from_str("ikigaiToolboxBase"))
        .ok()
        .and_then(|v| v.as_string())
        .ok_or_else(|| {
            Error::NotFound(format!(
                "urn:iki:toolbox:dist:{path}: this page does not say where the toolbox build is \
                 published (js/toolbox.js sets window.ikigaiToolboxBase)"
            ))
        })?;
    let url = format!("{base}{path}");
    let response = JsFuture::from(fetch_url(&url)).await.map_err(failed)?;
    let field = |name: &str| Reflect::get(&response, &JsValue::from_str(name));
    if !field("ok").map_err(failed)?.as_bool().unwrap_or(false) {
        let status = field("status").map_err(failed)?.as_f64().unwrap_or(0.0);
        return Err(Error::NotFound(format!("{url}: HTTP {status}")));
    }
    let read: Function = field("arrayBuffer")
        .map_err(failed)?
        .dyn_into()
        .map_err(failed)?;
    let pending: Promise = read
        .call0(&response)
        .map_err(failed)?
        .dyn_into()
        .map_err(failed)?;
    let buffer = JsFuture::from(pending).await.map_err(failed)?;
    Ok(Representation::new(
        ikigai_core::ReprType::new("application/octet-stream"),
        Uint8Array::new(&buffer).to_vec(),
    ))
}

So the page is a host too. Pressing a “Check … here” button below asks this page’s kernel for urn:toolbox:check:{iri} with shapes=, which composes the toolbox page from the build’s own template. The page then does what a server’s routing would do: it points the composed page’s /toolbox/… files at the published build, and answers its two inputs, /toolbox/input/{iri}, with urn:toolbox:input:{iri} from the same kernel. The toolbox runs in a panel that opens in place, and its engine checks the two Turtle faces in your tab. Nothing of the toolbox is loaded until you press one: the first press fetches its scripts and the 8.3 MB engine, and Close (or Escape) puts focus back on the button.

Check player O against the player shapes in the RDF Toolbox, in a new tab, on copies of the two Turtle faces published with this page.

Check player X against the player shapes in the RDF Toolbox, in a new tab, on copies of the two Turtle faces published with this page.

The SHACL tab of O’s check shows the same finding as the cell above, worded by turtle-lsp. In the page, you hold the root of your own kernel, so the panel reads both players. Without the kernel (it did not load, or scripts are off), each button is a link instead, and the toolbox opens in a new tab on copies of the Turtle faces published beside this chapter. A test holds those copies equal to what the kernel answers a reader who holds the scope, but a copy is not a read: nothing checks your capability on a file anyone can download.

gonk, the host The gonk Book is about, serves the page at /toolbox when its configuration names a build (gonk.toolbox.dist).

A CHECK is not urn:kernel:validate

The kernel has a name that sounds like this chapter and is not: urn:kernel:validate. It checks a request against the contract of the action it would invoke, before anything runs: required inputs present, values from the declared lists, no unknown arguments, the capability sufficient. A CHECK reads a resource that already exists and asks whether what it says fits a set of shapes. One asks “may this call run as written?”, the other “is this data what it should be?”. Both answer in SHACL’s report vocabulary, which is where the confusion starts: the focus of a urn:kernel:validate result is an action’s contract, and the focus of a CHECK result is a node in your data. The machine client, later in the book, puts urn:kernel:validate to work as the last step before an agent’s call runs. Here it is pre-flighting a call to urn:shacl:validate that forgot its data and asked for a face the endpoint does not have:

#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let preflight = Request::new(Verb::Source, Iri::parse("urn:kernel:validate").unwrap())
    .with_arg("endpoint", ArgRef::Inline(b"urn:shacl:validate".to_vec()))
    .with_arg("verb", ArgRef::Inline(b"source".to_vec()))
    .with_arg("args", ArgRef::Inline(b"shapes=urn:iki:tutorial:check:shapes&as=text/csv".to_vec()));
let report = block_on(check_resource::kernel().issue(preflight, &Capability::root())).unwrap();
let report = String::from_utf8(report.bytes).unwrap();

assert!(report.contains("sh:conforms false"));
assert!(report.contains("required input `data` is missing"));
assert!(report.contains(
    "`text/csv` is not an accepted value of `as` (one of: text/turtle, application/json)"
));
}

Nothing was validated against shapes there, and no player was read: the request never ran.

What not to assume

  • Three places run a check here, and they are not the same. The listings run under mdbook test, on the host side. The cells run in your tab, in the page’s kernel, with ikigai-shacl’s subset. The panel runs the toolbox page and its engine in your tab, served by the page as its host.
  • Two engines, one question. The page’s SHACL engine is turtle-lsp’s; the listings’ is ikigai-shacl’s subset. Both check SHACL Core’s closed shapes, but they are different code, and their messages differ even where their findings agree.
  • A check sees what you may read. A reader who cannot read a resource cannot check it, and the refusal is the resource’s, as above. That is the design, not a gap.

A caller holding some other scope checks nothing, and the refusal comes from the player, before any shapes are read:

narrowed — capability: urn:cap:tutorial:counter:write
error: denied: capability does not grant `urn:cap:tutorial:players:read` (declared by `urn:iki:tutorial:check:player:o`)
reset to identity — capability: root (full authority)