feat: power reference site with in-process WIP WASM
Add the public-document Worldspace, Rust Client/Host bridge, and WIP-backed tree and document rendering while preserving static fallback and navigation. Pin the Rust source and build tools, verify resource and trap boundaries, and cover dev and preview loading including same-origin generated module URLs.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "wip-site-worldspace"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wip-host = { path = "../../../wip-rs/crates/wip-host" }
|
||||
wip-interface = { path = "../../../wip-rs/crates/wip-interface" }
|
||||
wip-protocol = { path = "../../../wip-rs/crates/wip-protocol" }
|
||||
@@ -0,0 +1,123 @@
|
||||
# Public-document Worldspace
|
||||
|
||||
`wip-site-worldspace` is the site's immutable in-memory Host provider. It uses
|
||||
`wip-host`, `wip-interface` and `wip-protocol`; it does not depend on the filesystem
|
||||
server, HTTP, a browser, or a network runtime. The native and WASM callers use the
|
||||
same `Worldspace` instance API.
|
||||
|
||||
## Generated data
|
||||
|
||||
The static Astro endpoint `src/pages/worldspace.json.ts` emits
|
||||
`dist/worldspace.json` during the normal site build. It:
|
||||
|
||||
1. Reads `documents()` (the existing public-only content collection).
|
||||
2. Renders each document with Astro's existing Markdown renderer, including the
|
||||
configured document-link transform and syntax highlighting.
|
||||
3. Renders the existing `RootPage` and `DirectoryIndex` components for `/`, `/book`
|
||||
and any real public directory indexes. It does not copy their authored text.
|
||||
4. Passes rendered bodies to `createWorldspaceData` in
|
||||
`src/lib/worldspace-data.ts`, which uses `bookTree` and filters drafts again.
|
||||
|
||||
The input contract is:
|
||||
|
||||
```json
|
||||
{"nodes":[
|
||||
{"path":"/","name":"","displayPath":"/","title":"Worldspace Interface Protocol","href":"/","html":"..."},
|
||||
{"path":"/book","name":"book","displayPath":"/book","parent":"/","title":"Book","href":"/book/","html":"..."},
|
||||
{"path":"/book/1-concept.md","name":"1-concept.md","displayPath":"/1-concept.md","parent":"/book","title":"...","href":"/book/1-concept/","html":"..."},
|
||||
{"path":"/book/1-concept.md/1.1-interaction-abstraction.md","name":"1.1-interaction-abstraction.md","displayPath":"/1.1-interaction-abstraction.md","parent":"/book/1-concept.md","title":"...","href":"/book/1.1-interaction-abstraction/","html":"..."}
|
||||
]}
|
||||
```
|
||||
|
||||
`path` is the only WIP request identity. `parent` is a WIP path and is absent only
|
||||
for `/`. `displayPath` preserves the old explorer identity and selection state;
|
||||
`href` preserves the existing Web route (including `SITE_BASE`). The UI can keep
|
||||
its current `book/` label, but the actual Object name is `book`. No protocol name
|
||||
has a trailing slash. Nodes retain `bookTree` order. The provider does not sort
|
||||
children lexically. Every node, including home, book index, and a chapter that
|
||||
also has sections, has a readable body.
|
||||
|
||||
`html` is a trusted, build-rendered fragment, not a full page or an untrusted user
|
||||
submission. Consumers must retain the site's existing CSS, including home scoped
|
||||
styles and code-block themes. Do not inject raw JSON into a script element without
|
||||
escaping `<`; the generated endpoint is served as `application/json` instead.
|
||||
There is no runtime loading by path: unpublished or guessed draft paths cannot
|
||||
reach a source file. The Rust loader also rejects draft path segments, duplicate
|
||||
path/displayPath/URL identities, inconsistent parent/name/path relationships,
|
||||
missing parents, malformed input and unknown JSON fields.
|
||||
|
||||
## Rust API
|
||||
|
||||
```rust
|
||||
use wip_site_worldspace::{Worldspace, DocumentContent};
|
||||
use wip_site_worldspace::wip_protocol::{
|
||||
ObserveRequest, FetchInterfaceRequest, CallOperationRequest, Target, InterfaceTarget,
|
||||
};
|
||||
use wip_interface::WipType;
|
||||
|
||||
# fn example(json: &str) {
|
||||
let world = Worldspace::from_json(json).unwrap();
|
||||
let observed = world.observe(ObserveRequest { path: "/".into(), depth: 4 }).unwrap();
|
||||
let reference = observed.object.interfaces[0].clone();
|
||||
let interface = world.fetch_interface(FetchInterfaceRequest { interface: reference.clone() }).unwrap();
|
||||
let result = world.call_operation(CallOperationRequest {
|
||||
target: Target { path: "/book/1-concept.md".into(), validator: None },
|
||||
interface: InterfaceTarget {
|
||||
reference, scope_ref: interface.scope_ref, validator: interface.validator,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: Default::default(),
|
||||
}).unwrap();
|
||||
let document = DocumentContent::decode(result.result).unwrap();
|
||||
assert_eq!(document.path, "/book/1-concept.md");
|
||||
# }
|
||||
```
|
||||
|
||||
Alternatively use `Worldspace::new(WorldspaceData { nodes })`. Construction returns
|
||||
`Result<Worldspace, DataError>`. `observe(ObserveRequest)`,
|
||||
`fetch_interface(FetchInterfaceRequest)` and `call_operation(CallOperationRequest)`
|
||||
take requests by value and return canonical protocol response types or
|
||||
`ProtocolError`. The crate re-exports `wip_protocol` for bridge consumers.
|
||||
|
||||
The registered root-scoped Interface is `{scope:"/",name:"document"}`. Its only
|
||||
Operation, `read`, takes **no arguments** and returns the typed `DocumentContent`
|
||||
record `{path,title,href,html}`. Host-generated Descriptors, scope association,
|
||||
interface membership, request validation, argument/result validation and error
|
||||
classification remain on the normal Host path. The provider exposes no alternate
|
||||
body-fetch method. Documents are immutable, so no validators are advertised.
|
||||
|
||||
The recursive adapter calls `ObjectProvider::observe_object` for every node and
|
||||
validates the assembled response against the request. At the requested depth,
|
||||
`children` is `None`; above it, a visited leaf has `Some([])`. Unknown paths return
|
||||
`NotFound` and malformed paths return `InvalidRequest`. Requests above
|
||||
`MAX_DEPTH = 32` return `ResourceLimitExceeded` rather than silently truncating.
|
||||
Construction allows at most `MAX_NODES = 4096`, with hierarchy depth at most 32.
|
||||
`from_json` also caps input at 16 MiB. Together these bounds cap traversal work.
|
||||
|
||||
## Validation
|
||||
|
||||
From the site directory, with the configured Rust toolchain and dependencies:
|
||||
|
||||
```sh
|
||||
node --test tests/worldspace-data.test.mjs
|
||||
cargo test -p wip-site-worldspace
|
||||
npm run build
|
||||
WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
|
||||
cargo test -p wip-site-worldspace --test worldspace -- --include-ignored
|
||||
npm run check:links
|
||||
```
|
||||
|
||||
The explicit generated-data tests require `dist/worldspace.json`; they do not
|
||||
silently substitute fixtures when the artifact is absent. Native fixture tests
|
||||
exercise all three interactions, depth boundaries/order/empty sets, limits,
|
||||
identity and constructor errors, unknown paths/interfaces/operations, invalid
|
||||
arguments, validator failures, draft guesses, and JSON parsing. The generated
|
||||
native test invokes all three interactions against the actual build artifact.
|
||||
The JS post-build test compares every returned body and link set with the static
|
||||
page's article, including home and book index. Run it again with a prefixed
|
||||
`SITE_BASE` build to check deployment URLs.
|
||||
|
||||
Cargo dependency sourcing and the browser bridge are managed by the parent site
|
||||
workspace. The initial relative Rust dependencies require the companion `wip-rs`
|
||||
checkout beside `docs`; they must be replaced with the final reproducible source
|
||||
configuration before a standalone site checkout can build.
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Immutable, site-specific Worldspace. All three interactions use the ordinary
|
||||
//! Host publication boundary; only bounded recursive observation is site logic.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wip_host::{
|
||||
ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject, Scope,
|
||||
ScopeObservation, WipObject,
|
||||
};
|
||||
use wip_interface::{OperationResult, WipType, interface};
|
||||
pub use wip_protocol;
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
|
||||
InterfaceReference, ObjectObservation, ObserveRequest, ObserveResponse, ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
};
|
||||
|
||||
pub const DOCUMENT_INTERFACE: &str = "document";
|
||||
pub const MAX_DEPTH: u32 = 32;
|
||||
pub const MAX_NODES: usize = 4096;
|
||||
pub const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Build-generated input. Array order is also the direct-child observation order.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorldspaceData {
|
||||
pub nodes: Vec<DocumentNode>,
|
||||
}
|
||||
|
||||
/// Protocol identity, legacy UI identity and Web navigation are deliberately separate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DocumentNode {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub display_path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
pub title: String,
|
||||
pub href: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// A rendered public document fragment, including the root and directory indexes.
|
||||
#[derive(Debug, Clone, PartialEq, WipType)]
|
||||
pub struct DocumentContent {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub href: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// Read the site's published document without network or filesystem access.
|
||||
#[interface]
|
||||
pub trait Document {
|
||||
/// Returns the rendered HTML fragment and its existing Web URL.
|
||||
fn read(&self) -> OperationResult<DocumentContent>;
|
||||
}
|
||||
|
||||
/// Host-private implementation; no body lookup bypass is exposed to clients.
|
||||
pub struct SiteObject(Arc<DocumentNode>);
|
||||
|
||||
impl WipObject for SiteObject {
|
||||
fn name(&self) -> &str {
|
||||
&self.0.name
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<&str> {
|
||||
Some(&self.0.title)
|
||||
}
|
||||
}
|
||||
|
||||
impl Document for SiteObject {
|
||||
fn read(&self) -> OperationResult<DocumentContent> {
|
||||
Ok(DocumentContent {
|
||||
path: self.0.path.clone(),
|
||||
title: self.0.title.clone(),
|
||||
href: self.0.href.clone(),
|
||||
html: self.0.html.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DataError(String);
|
||||
|
||||
impl Display for DataError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl Error for DataError {}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> DataError {
|
||||
DataError(message.into())
|
||||
}
|
||||
|
||||
fn protocol_error(code: ProtocolErrorCode, message: impl Into<String>) -> ProtocolError {
|
||||
ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An immutable publication snapshot, shared by native and WASM callers.
|
||||
/// Construction is the only mutation boundary; later interactions require only `&self`.
|
||||
pub struct Worldspace {
|
||||
registry: InterfaceRegistry,
|
||||
objects: BTreeMap<String, PublishedObject<SiteObject>>,
|
||||
children: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl Worldspace {
|
||||
pub fn from_json(json: &str) -> Result<Self, DataError> {
|
||||
if json.len() > MAX_JSON_BYTES {
|
||||
return Err(invalid("document JSON exceeds the input byte limit"));
|
||||
}
|
||||
let data = serde_json::from_str(json).map_err(|error| invalid(error.to_string()))?;
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
pub fn new(data: WorldspaceData) -> Result<Self, DataError> {
|
||||
validate_data(&data)?;
|
||||
let scope = Scope::new("/").map_err(|error| invalid(error.to_string()))?;
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let document = registry
|
||||
.register(&scope, DOCUMENT_INTERFACE, DocumentDefinition, None)
|
||||
.map_err(|error| invalid(error.to_string()))?;
|
||||
// Every publication attests the same actual, immutable root Object.
|
||||
// Both fetch and calls need this association; a registry token alone is insufficient.
|
||||
let root = Arc::new(
|
||||
data.nodes
|
||||
.iter()
|
||||
.find(|node| node.path == "/")
|
||||
.unwrap()
|
||||
.clone(),
|
||||
);
|
||||
let current_scope = ScopeObservation::new(&scope, &SiteObject(Arc::clone(&root)));
|
||||
let mut objects = BTreeMap::new();
|
||||
let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for node in data.nodes {
|
||||
let path = node.path.clone();
|
||||
if let Some(parent) = &node.parent {
|
||||
children
|
||||
.entry(parent.clone())
|
||||
.or_default()
|
||||
.push(path.clone());
|
||||
}
|
||||
let object = SiteObject(if path == "/" {
|
||||
Arc::clone(&root)
|
||||
} else {
|
||||
Arc::new(node)
|
||||
});
|
||||
let publication = PublishedObject::new(object)
|
||||
.bind_scope(current_scope.clone())
|
||||
.expose(&document)
|
||||
.map_err(|error| invalid(error.to_string()))?;
|
||||
objects.insert(path, publication);
|
||||
}
|
||||
Ok(Self {
|
||||
registry,
|
||||
objects,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursive Host observations. Boundary children are unknown (`None`), not empty.
|
||||
/// A visited leaf above the boundary has a known empty child set (`Some(vec![])`).
|
||||
pub fn observe(&self, request: ObserveRequest) -> Result<ObserveResponse, ProtocolError> {
|
||||
request.validate().map_err(|error| {
|
||||
protocol_error(ProtocolErrorCode::InvalidRequest, error.to_string())
|
||||
})?;
|
||||
if request.depth > MAX_DEPTH {
|
||||
return Err(protocol_error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"observation depth exceeds the site limit",
|
||||
));
|
||||
}
|
||||
let response = self.observe_at(&request.path, request.depth)?;
|
||||
request.validate_response(&response).map_err(|_| {
|
||||
protocol_error(ProtocolErrorCode::Internal, "invalid provider observation")
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn observe_at(&self, path: &str, depth: u32) -> Result<ObjectObservation, ProtocolError> {
|
||||
let object =
|
||||
ObjectProvider::observe_object(self, path).map_err(|error| error.protocol_error())?;
|
||||
let children = if depth == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.children
|
||||
.get(path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|child| self.observe_at(child, depth - 1))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
)
|
||||
};
|
||||
Ok(ObjectObservation { object, children })
|
||||
}
|
||||
|
||||
pub fn fetch_interface(
|
||||
&self,
|
||||
request: FetchInterfaceRequest,
|
||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
ObjectProvider::fetch_interface(self, &self.registry, &request.interface)?
|
||||
.protocol_response()
|
||||
}
|
||||
|
||||
pub fn call_operation(
|
||||
&self,
|
||||
request: CallOperationRequest,
|
||||
) -> Result<CallOperationResponse, ProtocolError> {
|
||||
ObjectProvider::call_operation(self, request).map_err(|error| error.protocol_error())
|
||||
}
|
||||
|
||||
pub fn document_interface() -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: DOCUMENT_INTERFACE.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectProvider for Worldspace {
|
||||
type Object = SiteObject;
|
||||
|
||||
fn with_object<R>(
|
||||
&self,
|
||||
_context: &ExecutionContext,
|
||||
path: &str,
|
||||
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
match self.objects.get(path) {
|
||||
Some(publication) => ProviderResult::Found(operation(publication)),
|
||||
None => ProviderResult::NotFound,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_data(data: &WorldspaceData) -> Result<(), DataError> {
|
||||
if data.nodes.is_empty() || data.nodes.len() > MAX_NODES {
|
||||
return Err(invalid(
|
||||
"document node count must be between 1 and MAX_NODES",
|
||||
));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
let mut display_paths = HashSet::new();
|
||||
let mut hrefs = HashSet::new();
|
||||
for node in &data.nodes {
|
||||
wip_protocol::validate_path(&node.path).map_err(|error| invalid(error.to_string()))?;
|
||||
wip_protocol::validate_path(&node.display_path)
|
||||
.map_err(|error| invalid(error.to_string()))?;
|
||||
if !paths.insert(node.path.as_str())
|
||||
|| !display_paths.insert(node.display_path.as_str())
|
||||
|| !hrefs.insert(node.href.as_str())
|
||||
{
|
||||
return Err(invalid("duplicate path, displayPath or Web URL"));
|
||||
}
|
||||
if [&node.path, &node.display_path]
|
||||
.into_iter()
|
||||
.any(|path| path.split('/').any(|segment| segment == "draft"))
|
||||
{
|
||||
return Err(invalid("draft paths are not public documents"));
|
||||
}
|
||||
if node
|
||||
.path
|
||||
.split('/')
|
||||
.skip(1)
|
||||
.filter(|part| !part.is_empty())
|
||||
.count()
|
||||
> MAX_DEPTH as usize
|
||||
{
|
||||
return Err(invalid("document hierarchy exceeds the depth limit"));
|
||||
}
|
||||
if node.title.trim().is_empty()
|
||||
|| !node.href.starts_with('/')
|
||||
|| node.href.starts_with("//")
|
||||
{
|
||||
return Err(invalid(
|
||||
"documents require a title and a site-relative Web URL",
|
||||
));
|
||||
}
|
||||
if node.path == "/" {
|
||||
if !node.name.is_empty() || node.parent.is_some() || node.display_path != "/" {
|
||||
return Err(invalid(
|
||||
"root requires empty name, no parent and displayPath /",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let parent = node
|
||||
.parent
|
||||
.as_deref()
|
||||
.ok_or_else(|| invalid("non-root node needs a parent"))?;
|
||||
wip_protocol::validate_path(parent).map_err(|error| invalid(error.to_string()))?;
|
||||
if node.name.is_empty()
|
||||
|| node.name.contains('/')
|
||||
|| node.name == "."
|
||||
|| node.name == ".."
|
||||
{
|
||||
return Err(invalid("invalid object name"));
|
||||
}
|
||||
let expected = format!("{}/{}", parent.trim_end_matches('/'), node.name);
|
||||
if node.path != expected {
|
||||
return Err(invalid(
|
||||
"path must be formed from parent path and object name",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !paths.contains("/") {
|
||||
return Err(invalid("Worldspace must contain the root object"));
|
||||
}
|
||||
for node in &data.nodes {
|
||||
if node
|
||||
.parent
|
||||
.as_deref()
|
||||
.is_some_and(|parent| !paths.contains(parent))
|
||||
{
|
||||
return Err(invalid("document parent is not published"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_interface::WipType;
|
||||
use wip_site_worldspace::wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, InterfaceReference, InterfaceTarget,
|
||||
ObserveRequest, ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
use wip_site_worldspace::{
|
||||
DocumentContent, DocumentNode, MAX_DEPTH, MAX_NODES, Worldspace, WorldspaceData,
|
||||
};
|
||||
|
||||
fn node(path: &str, parent: Option<&str>, display: &str, href: &str) -> DocumentNode {
|
||||
DocumentNode {
|
||||
path: path.into(),
|
||||
name: if path == "/" {
|
||||
""
|
||||
} else {
|
||||
path.rsplit('/').next().unwrap()
|
||||
}
|
||||
.into(),
|
||||
display_path: display.into(),
|
||||
parent: parent.map(str::to_owned),
|
||||
title: format!("Title {display}"),
|
||||
href: href.into(),
|
||||
html: format!("<h1>{display}</h1>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn data() -> WorldspaceData {
|
||||
WorldspaceData {
|
||||
nodes: vec![
|
||||
node("/", None, "/", "/"),
|
||||
node("/book", Some("/"), "/book", "/book/"),
|
||||
node(
|
||||
"/book/1-chapter.md",
|
||||
Some("/book"),
|
||||
"/1-chapter.md",
|
||||
"/book/1-chapter/",
|
||||
),
|
||||
node(
|
||||
"/book/1-chapter.md/1.1-section.md",
|
||||
Some("/book/1-chapter.md"),
|
||||
"/1.1-section.md",
|
||||
"/book/1.1-section/",
|
||||
),
|
||||
// This intentional order differs from lexical order.
|
||||
node(
|
||||
"/book/10-last.md",
|
||||
Some("/book"),
|
||||
"/10-last.md",
|
||||
"/book/10-last/",
|
||||
),
|
||||
node(
|
||||
"/book/2-second.md",
|
||||
Some("/book"),
|
||||
"/2-second.md",
|
||||
"/book/2-second/",
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &str) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: Worldspace::document_interface(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_interactions_use_the_published_document_interface() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
let request = ObserveRequest {
|
||||
path: "/book/1-chapter.md".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let observation = world.observe(request.clone()).unwrap();
|
||||
request.validate_response(&observation).unwrap();
|
||||
assert_eq!(
|
||||
observation.object.interfaces,
|
||||
vec![Worldspace::document_interface()]
|
||||
);
|
||||
assert_eq!(
|
||||
observation.children.unwrap()[0].object.name,
|
||||
"1.1-section.md"
|
||||
);
|
||||
let fetch = FetchInterfaceRequest {
|
||||
interface: Worldspace::document_interface(),
|
||||
};
|
||||
let interface = world.fetch_interface(fetch.clone()).unwrap();
|
||||
interface.validate_for(&fetch).unwrap();
|
||||
assert_eq!(interface.descriptor.operations.len(), 1);
|
||||
assert_eq!(interface.descriptor.operations[0].name, "read");
|
||||
assert!(interface.descriptor.operations[0].parameters.is_empty());
|
||||
assert!(interface.scope_ref.is_none());
|
||||
for expected in data().nodes {
|
||||
let call = read(&expected.path);
|
||||
let response = world.call_operation(call.clone()).unwrap();
|
||||
response
|
||||
.validate_for(&interface.descriptor, &call.operation)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
DocumentContent::decode(response.result).unwrap(),
|
||||
DocumentContent {
|
||||
path: expected.path,
|
||||
title: expected.title,
|
||||
href: expected.href,
|
||||
html: expected.html,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_boundaries_empty_children_and_order_are_protocol_observations() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
let observe = |path: &str, depth| {
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert!(observe("/", 0).children.is_none());
|
||||
let root = observe("/", 1);
|
||||
let children = root.children.unwrap();
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].object.name, "book");
|
||||
assert!(children[0].children.is_none());
|
||||
let root = observe("/", 3);
|
||||
let book = &root.children.as_ref().unwrap()[0];
|
||||
let chapters = book.children.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
chapters
|
||||
.iter()
|
||||
.map(|node| node.object.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["1-chapter.md", "10-last.md", "2-second.md"]
|
||||
);
|
||||
assert!(chapters[0].children.as_ref().unwrap()[0].children.is_none());
|
||||
assert_eq!(chapters[1].children, Some(vec![]));
|
||||
assert_eq!(
|
||||
observe("/book/1-chapter.md/1.1-section.md", 1).children,
|
||||
Some(vec![])
|
||||
);
|
||||
assert!(observe("/", MAX_DEPTH).children.is_some());
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: MAX_DEPTH + 1
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::ResourceLimitExceeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_host_failures_are_not_replaced_by_direct_body_lookup() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
for path in ["relative", "/book/", "/book//x", "/book/../x", "/./book"] {
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 0
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/missing",
|
||||
"/1-chapter.md",
|
||||
"/draft/private.md",
|
||||
"/book/draft/private.md",
|
||||
] {
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 1
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
}
|
||||
for reference in [
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "missing".into(),
|
||||
},
|
||||
InterfaceReference {
|
||||
scope: "/book".into(),
|
||||
name: "document".into(),
|
||||
},
|
||||
InterfaceReference {
|
||||
scope: "/draft".into(),
|
||||
name: "document".into(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: reference.clone()
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.reference = reference;
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: "document".into()
|
||||
},
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.scope_ref = Some("not-the-published-root".into());
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceMismatch
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.operation = "missing".into();
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::OperationNotFound
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.arguments
|
||||
.insert("extra".into(), Value::String("not accepted".into()));
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidArguments
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.target.validator = Some(vec![1]);
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::ValidatorMismatch
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.validator = Some(vec![1]);
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceValidatorMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_invalid_disconnected_and_private_input() {
|
||||
let reject = |mutate: fn(&mut WorldspaceData)| {
|
||||
let mut input = data();
|
||||
mutate(&mut input);
|
||||
assert!(Worldspace::new(input).is_err());
|
||||
};
|
||||
reject(|data| data.nodes.push(data.nodes[0].clone()));
|
||||
reject(|data| data.nodes[2].display_path = "/book".into());
|
||||
reject(|data| data.nodes[2].href = "/book/".into());
|
||||
reject(|data| data.nodes[2].name = "a/b".into());
|
||||
reject(|data| data.nodes[2].name = "..".into());
|
||||
reject(|data| data.nodes[2].name.clear());
|
||||
reject(|data| data.nodes[2].path = "/book//x".into());
|
||||
reject(|data| data.nodes[2].path = "/elsewhere".into());
|
||||
reject(|data| data.nodes[2].parent = None);
|
||||
reject(|data| data.nodes[3].parent = Some("/missing".into()));
|
||||
reject(|data| data.nodes[0].name = "root".into());
|
||||
reject(|data| data.nodes[0].parent = Some("/".into()));
|
||||
reject(|data| data.nodes[0].display_path = "/home".into());
|
||||
reject(|data| {
|
||||
data.nodes.remove(0);
|
||||
});
|
||||
reject(|data| data.nodes[2].display_path = "/draft/secret.md".into());
|
||||
reject(|data| {
|
||||
data.nodes
|
||||
.push(node("/draft", Some("/"), "/private", "/private/"))
|
||||
});
|
||||
reject(|data| data.nodes[0].href = "//external.example/".into());
|
||||
reject(|data| data.nodes[0].title.clear());
|
||||
assert!(Worldspace::new(WorldspaceData { nodes: vec![] }).is_err());
|
||||
assert!(
|
||||
Worldspace::new(WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone(); MAX_NODES + 1]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let mut wide = WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone()],
|
||||
};
|
||||
for index in 1..MAX_NODES {
|
||||
let path = format!("/node-{index}");
|
||||
wide.nodes
|
||||
.push(node(&path, Some("/"), &path, &format!("{path}/")));
|
||||
}
|
||||
let world = Worldspace::new(wide).unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1
|
||||
})
|
||||
.unwrap()
|
||||
.children
|
||||
.unwrap()
|
||||
.len(),
|
||||
MAX_NODES - 1
|
||||
);
|
||||
let mut deep = WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone()],
|
||||
};
|
||||
let mut parent = "/".to_string();
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let path = format!("{}/a", parent.trim_end_matches('/'));
|
||||
deep.nodes
|
||||
.push(node(&path, Some(&parent), &path, &format!("{path}/")));
|
||||
parent = path;
|
||||
if deep.nodes.len() == MAX_DEPTH as usize + 1 {
|
||||
assert!(Worldspace::new(deep.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(Worldspace::new(deep).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_contract_roundtrips_and_rejects_malformed_or_unknown_fields() {
|
||||
let json = serde_json::to_string(&data()).unwrap();
|
||||
assert!(json.contains("displayPath"));
|
||||
let world = Worldspace::from_json(&json).unwrap();
|
||||
assert!(world.call_operation(read("/")).is_ok());
|
||||
assert!(Worldspace::from_json("not json").is_err());
|
||||
assert!(Worldspace::from_json(r#"{"nodes":[],"draft":true}"#).is_err());
|
||||
let too_large = " ".repeat(wip_site_worldspace::MAX_JSON_BYTES + 1);
|
||||
assert!(Worldspace::from_json(&too_large).is_err());
|
||||
// Input order need not be topological; child order still follows the input.
|
||||
let mut shuffled = data();
|
||||
shuffled.nodes.reverse();
|
||||
assert!(
|
||||
Worldspace::new(shuffled)
|
||||
.unwrap()
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 4
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// This is deliberately explicit, not silently skipped when generated input is absent.
|
||||
/// Run after `npm run build`: cargo test -p wip-site-worldspace --test worldspace -- --include-ignored
|
||||
#[test]
|
||||
#[ignore = "requires the site's generated dist/worldspace.json (npm run build)"]
|
||||
fn generated_real_public_documents_support_all_interactions() {
|
||||
let json = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../dist/worldspace.json"
|
||||
))
|
||||
.unwrap();
|
||||
let data: WorldspaceData = serde_json::from_str(&json).unwrap();
|
||||
assert!(data.nodes.len() > 10);
|
||||
let world = Worldspace::from_json(&json).unwrap();
|
||||
let full = world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: MAX_DEPTH,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(full.children.unwrap()[0].object.name, "book");
|
||||
let descriptor = world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: Worldspace::document_interface(),
|
||||
})
|
||||
.unwrap()
|
||||
.descriptor;
|
||||
for node in &data.nodes {
|
||||
assert!(!node.display_path.starts_with("/draft/"));
|
||||
assert!(!node.html.is_empty());
|
||||
let request = read(&node.path);
|
||||
let response = world.call_operation(request.clone()).unwrap();
|
||||
response
|
||||
.validate_for(&descriptor, &request.operation)
|
||||
.unwrap();
|
||||
let content = DocumentContent::decode(response.result).unwrap();
|
||||
assert_eq!(content.path, node.path);
|
||||
assert_eq!(content.href, node.href);
|
||||
assert_eq!(content.html, node.html);
|
||||
if let Some(parent) = &node.parent {
|
||||
assert_eq!(
|
||||
node.path,
|
||||
format!("{}/{}", parent.trim_end_matches('/'), node.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(data.nodes.iter().any(|node| node.path
|
||||
== "/book/1-concept.md/1.1-interaction-abstraction.md"
|
||||
&& node.display_path == "/1.1-interaction-abstraction.md"
|
||||
&& node.href.ends_with("/book/1.1-interaction-abstraction/")));
|
||||
for path in ["/draft", "/book/draft", "/book/draft/6-security.md"] {
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user