Compare commits

..
Author SHA1 Message Date
Hare eb92878e3c feat: support typed in-process WIP clients without HTTP 2026-10-16 05:26:11 +09:00
Hare 53fa44da26 fix: correct protocol name in Rust documentation 2026-10-15 17:53:48 +09:00
Hare 3c3edf4c6f chore: prepare 0.2.0 release 2026-10-09 00:35:06 +09:00
Hare 1cbe03b49e feat: expose typed Text View tokens and plain-text conversion
T-28: generate owned semantic tokens directly from Object/Interface structure, including all escaped data and layout. Preserve render APIs through the same token generation and delimiter-free concatenation; preserve input limits, validation order and errors. Document splitting, semantic roles and destination escaping boundaries.

Validation: cargo fmt --all -- --check; cargo check --workspace --all-targets; cargo test --workspace (312 passing tests/doctests, including 16 Text View consumer tests and 4 Text View/README doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps; RUSTDOCFLAGS="-D warnings" cargo test -p wip-text-view -p wip-protocol --doc; git diff --check. All passed. Existing golden files unchanged; every prior Text View case now verifies token/plain/direct equality or exact error equality. No existing crates depend on wip-text-view; full workspace validation covers adjacent consumers.
2026-10-08 18:27:04 +09:00
Hare 26cba3398f feat: add standalone WIP Text View signatures
Render one Object or paired Interface entity without path/context, lookups or wire-schema changes. Preserve declarations, summaries, optional/unit and empty distinctions; validate protocol semantics and bounded input before rendering. Add five spec/nested goldens and 13 external API tests plus a standalone doctest.

Validation: cargo fmt --all -- --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets; cargo test --workspace --doc; nix develop -c bash (cargo clippy --workspace --all-targets -- -D warnings, RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps, cargo run --package wip-public-api-example); git diff --check. All passed. Default shell lacks clippy; declared Nix dev shell supplied it.
2026-10-08 17:50:16 +09:00
33 changed files with 3908 additions and 197 deletions
+13
View File
@@ -4,6 +4,19 @@ All notable changes to the published WIP Rust crates are documented here.
## Unreleased
## 0.2.0
- Adds public Text View `Token`/`TokenKind`, `tokenize_object`,
`tokenize_interface`, and delimiter-free `to_plain_text`. Direct rendering uses
the same token generation path; HTML and terminal-color renderers are not included.
- Makes Client Interface caches scope-lifetime-aware, invalidates observations on
confirmed binding loss, and retains immutable contexts for in-flight calls.
- Adds `wip-text-view`, an independent single-entity Object/Interface signature
library with Protocol validation, typed errors, full ordered declarations,
documentation summaries and safe Text View quoting/escapes. No path, lookup,
cache, UI, network or Protocol wire-schema changes are required.
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
exact `(scope, name)` records in Objects, fetches and calls, with optional
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
Generated
+16 -9
View File
@@ -1235,7 +1235,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "wip-client"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"wip-http",
"wip-protocol",
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]]
name = "wip-fs-server"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"http",
"rustix 1.1.5",
@@ -1256,7 +1256,7 @@ dependencies = [
[[package]]
name = "wip-host"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"trybuild",
"wip-interface",
@@ -1265,7 +1265,7 @@ dependencies = [
[[package]]
name = "wip-http"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"http",
@@ -1277,7 +1277,7 @@ dependencies = [
[[package]]
name = "wip-interface"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"trybuild",
"wip-interface-macros",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "wip-interface-macros"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
@@ -1295,11 +1295,11 @@ dependencies = [
[[package]]
name = "wip-protocol"
version = "0.1.0"
version = "0.2.0"
[[package]]
name = "wip-public-api-example"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"wip-client",
"wip-host",
@@ -1308,9 +1308,16 @@ dependencies = [
"wip-protocol",
]
[[package]]
name = "wip-text-view"
version = "0.2.0"
dependencies = [
"wip-protocol",
]
[[package]]
name = "wip-tui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"crossterm",
+9 -8
View File
@@ -3,17 +3,18 @@ members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
repository = "https://gitea.hareworks.net/Hare/wip-rs"
[workspace.dependencies]
wip-client = { version = "0.1.0", path = "crates/wip-client" }
wip-fs-server = { version = "0.1.0", path = "crates/wip-fs-server" }
wip-host = { version = "0.1.0", path = "crates/wip-host" }
wip-http = { version = "0.1.0", path = "crates/wip-http" }
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
wip-client = { version = "0.2.0", path = "crates/wip-client" }
wip-fs-server = { version = "0.2.0", path = "crates/wip-fs-server" }
wip-host = { version = "0.2.0", path = "crates/wip-host" }
wip-http = { version = "0.2.0", path = "crates/wip-http" }
wip-interface = { version = "0.2.0", path = "crates/wip-interface" }
wip-interface-macros = { version = "0.2.0", path = "crates/wip-interface-macros" }
wip-protocol = { version = "0.2.0", path = "crates/wip-protocol" }
wip-text-view = { version = "0.2.0", path = "crates/wip-text-view" }
+42 -1
View File
@@ -1,6 +1,47 @@
# Structured Interface reference and scope publication migration
This unreleased API/wire change follows `wip-reference` canonical Core Model,
## Transport-independent Client API
The default `wip-client` feature set still exposes the HTTP-compatible
`wip_client::Client` and `PreparedRequest`. Its constructor takes state and wire
limits, sessions canonicalize HTTP endpoints, and `complete` consumes an HTTP
response. Existing TUI and native HTTP consumers need no source migration; the
wire contract is unchanged.
For an in-process Host (including Client and Host in one WASM module), disable
default features and use `wip_client::direct::{Client, Request, Response}`:
```toml
wip-client = { version = "0.2.0", default-features = false }
```
- `direct::Client::new(ClientLimits)` takes no wire limits.
- `open_session(worldspace_identity, SecurityContext)` accepts an opaque identity,
not a URL. Issue a separate identity per Worldspace view/Host lifetime and a
separate security identity whenever authorization context changes. Direct and
canonical HTTP session identities cannot alias each other's cache.
- Preparation methods retain their names and arguments. Their result is
`direct::PreparedRequest { id, request: Request }`, with `Observe`,
`FetchInterface`, and `CallOperation` variants containing Protocol request DTOs.
- Route that exact DTO to the session's Host/security context; mark calls
dispatched before invoking the Host. Complete with the original `id` and the
matching `Response` variant containing `Result<Protocol DTO, ProtocolError>`.
No HTTP request, response, JSON codec, or artificial endpoint is needed.
- Direct responses undergo the same semantic validation and state transitions
as HTTP responses. Wrong interactions and invalid results are rejected;
invalid call responses retain an unknown execution outcome. Retrievals may
be explicitly refreshed, but operations are never automatically retried.
- `ClientLimits` still bounds cache, sessions, in-flight requests, and history.
HTTP status/envelope checks and byte/JSON nesting limits belong to the optional
HTTP adapter. An embedding application owns DTO allocation/execution budgets.
The `direct` module API documentation contains a runnable typed example. Run
`cargo test -p wip-client --no-default-features` to exercise the core without an
HTTP dependency; ordinary `cargo test -p wip-client` also runs unchanged HTTP
regressions and direct/HTTP state parity tests.
The 0.2.0 API/wire change follows `wip-reference` canonical Core Model,
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
It replaces the earlier opaque Interface reference contract; HTTP routes and
error codes have **not** changed. Peers must upgrade together: the codec does
+17 -5
View File
@@ -1,13 +1,14 @@
# wip-rs
Rust building blocks for the Web Interface Protocol (WIP).
Rust building blocks for the Worldspace Interface Protocol (WIP).
The repository is a resolver-3 virtual Cargo workspace whose packages all live
under `crates/*`. It provides a transport-independent protocol model, the
standard framework-independent WIP over HTTP v1 binding, a stateful
presentation-independent client runtime, a Rust interface-authoring facade, a
request-scoped host runtime, a bounded Linux filesystem projection server, an
interactive terminal client, and a private public-API conformance consumer. The
interactive terminal client, a standalone Text View signature library, and a
private public-API conformance consumer. The
repository root is not itself a package.
## Crate graph and responsibilities
@@ -15,6 +16,9 @@ repository root is not itself a package.
```text
Published libraries
wip-text-view
└── wip-protocol
wip-client
├── wip-http
│ └── wip-protocol
@@ -47,19 +51,20 @@ wip-public-api-example
| Crate | Responsibility | Does not own |
| --- | --- | --- |
| `wip-text-view` | Standalone, validated single-Object and single-Interface Text View signatures with full declarations, summaries and safe escapes. | Retrieval, cache, placement context, wire schema, UI layout, parsing or call-target reconstruction. |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-host` | Scoped interface registration and publication lifetimes, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
## Published crates
The `0.1.0` release consists of these six crates:
The `0.2.0` release consists of these seven crates:
- `wip-protocol`
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
@@ -67,6 +72,13 @@ The `0.1.0` release consists of these six crates:
- `wip-host`
- `wip-http`
- `wip-client`
- `wip-text-view`
The [`wip-text-view`](crates/wip-text-view/README.md) library's
`render_object(&Object)` API needs no path;
`render_interface(&Interface)` takes one borrowed entity pairing the complete
structured reference with its descriptor, without changing Protocol wire fields.
Both return one complete plain-text signature or a typed error.
The filesystem server, terminal client, and public-API example are workspace-only packages and set `publish = false`. All workspace packages require Rust 1.88 or newer and are licensed under either Apache-2.0 or MIT at your option.
@@ -81,7 +93,7 @@ or application traits.
## Structured Interface references
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
The 0.2.0 Protocol/HTTP API uses explicit `InterfaceReference { scope,
name }` records and optional `scope_ref` observation/precondition metadata.
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
for construction examples, validation boundaries and downstream migration status.
+8 -5
View File
@@ -1,12 +1,12 @@
# Releasing
The six crates published from this workspace use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
The seven public crates in the current workspace (including `wip-text-view`) use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
## Preconditions
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
2. Confirm the intended version in `[workspace.package]` and every internal requirement in `[workspace.dependencies]`.
3. Confirm ownership of all six names on crates.io.
3. Confirm ownership of all seven names on crates.io.
4. Run the complete repository validation suite:
```console
@@ -44,6 +44,9 @@ cargo publish --package wip-interface-macros
Wait until both versions are available from the crates.io index, then continue:
```console
cargo publish --dry-run --package wip-text-view
cargo publish --package wip-text-view
cargo publish --dry-run --package wip-interface
cargo publish --package wip-interface
@@ -65,11 +68,11 @@ Registry propagation can take time. A dependent dry-run reporting that a WIP cra
## Tag
After all six uploads resolve successfully, tag the exact published commit and push the tag:
After all seven uploads resolve successfully, tag the exact published commit and push the tag:
```console
git tag -a v0.1.0 -m "release: 0.1.0"
git push origin v0.1.0
git tag -a v0.2.0 -m "chore: release 0.2.0"
git push origin v0.2.0
```
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
+13 -1
View File
@@ -9,6 +9,18 @@ repository.workspace = true
readme = "README.md"
documentation = "https://docs.rs/wip-client"
[features]
default = ["http"]
http = ["dep:wip-http"]
[dependencies]
wip-http.workspace = true
wip-http = { workspace = true, optional = true }
wip-protocol.workspace = true
[[test]]
name = "adapter"
required-features = ["http"]
[[test]]
name = "runtime"
required-features = ["http"]
+20 -4
View File
@@ -1,15 +1,31 @@
# wip-client
Presentation-independent stateful runtime for Web Interface Protocol (WIP) clients.
Presentation-independent stateful runtime for Worldspace Interface Protocol (WIP) clients.
`wip-client` manages endpoint- and security-context-isolated Known Space, object and Interface Descriptor observations, bounded request ownership, supersession, out-of-order response suppression, validator recovery, and operation call contexts. It produces and consumes framework-neutral `wip-http` messages but performs no network I/O and contains no UI types.
`wip-client` manages Worldspace-view- and security-context-isolated Known Space, object and Interface Descriptor observations, bounded request ownership, supersession, out-of-order response suppression, validator recovery, and operation call contexts. Its typed logical core requires neither HTTP nor JSON. The default HTTP facade produces and consumes framework-neutral `wip-http` messages using the same logical runtime. Neither path performs network I/O or contains UI types.
```toml
[dependencies]
wip-client = "0.1.0"
wip-client = "0.2.0"
```
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
The default `http` feature preserves `wip_client::Client::new(state_limits, wire_limits)`, canonical HTTP endpoint sessions, `PreparedRequest`, and HTTP `complete`. Applications send each prepared request and return its corresponding response.
## Direct typed Client (no HTTP dependency)
```toml
[dependencies]
wip-client = { version = "0.2.0", default-features = false }
wip-protocol = "0.2.0"
```
Use `wip_client::direct::{Client, Request, Response}`. Construct the client with `Client::new(state_limits)` and open a session using an opaque, application-owned Worldspace view identity plus `SecurityContext`; no URL is parsed or required. Allocate different identities for different Hosts/views and when replacing a Host. Direct identities and HTTP endpoint identities occupy distinct namespaces, even when their strings match.
The existing observation, interface acquisition, and call preparation methods return `direct::PreparedRequest { id, request }`. Dispatch the `Request::Observe`, `Request::FetchInterface`, or `Request::CallOperation` DTO to the Host, then call `complete(id, Response::<matching interaction>(result))`, where `result` is `Result<Protocol response DTO, ProtocolError>`. The prepared request must be dispatched unchanged; routing it to the session's Host/security context remains the application's responsibility. Mark calls dispatched immediately before invoking the Host.
Typed results are **not trusted**: the runtime checks interaction correspondence, observation depth/names/paths, scoped Interface correspondence, descriptor structure and format, descriptor-bound arguments/results, and per-interaction Protocol error codes. Invalid call responses leave the outcome unknown, just as with HTTP. HTTP status/envelope classification, JSON encoding, and wire size/depth limits remain exclusive to the optional adapter; `ClientLimits` bounds retained logical state on both paths. Direct DTO allocation and execution budgets belong to the embedding application.
See [migration guidance](../../MIGRATION.md#transport-independent-client-api) and the `direct` module's executable API example.
## Interface identity and scope lifetime
+346
View File
@@ -0,0 +1,346 @@
//! Optional HTTP facade. Encoding and wire classification live only here.
use std::ops::{Deref, DerefMut};
use wip_http::http::{Request, Response};
use wip_http::{
ClientResponseError, DecodedResponse, Endpoint, Limits, StatusMismatch,
decode_call_operation_response, decode_fetch_interface_response, decode_observe_response,
encode_call_operation_request, encode_fetch_interface_request, encode_observe_request,
};
use super::*;
/// Encoded HTTP request paired with its runtime identity.
#[derive(Debug)]
pub struct PreparedRequest {
/// Identity required when completing or failing this request.
pub id: RequestId,
/// Standard WIP over HTTP request.
pub request: Request<Vec<u8>>,
}
/// HTTP-compatible facade over the typed logical client.
///
/// State inspection and transport failure reporting delegate to the same core.
/// Request encoding is performed before changing cache or in-flight state.
#[derive(Debug)]
pub struct Client {
core: DirectClient,
wire_limits: Limits,
}
impl Deref for Client {
type Target = DirectClient;
fn deref(&self) -> &Self::Target {
&self.core
}
}
impl DerefMut for Client {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.core
}
}
impl Client {
/// Creates an empty runtime with explicit state and wire bounds.
#[must_use]
pub fn new(limits: ClientLimits, wire_limits: Limits) -> Self {
Self {
core: DirectClient::new(limits),
wire_limits,
}
}
/// Returns one cached object observation.
#[must_use]
pub fn object(&self, session: &SessionId, path: &str) -> Option<ObjectObservation> {
self.core.object(session, path)
}
/// Returns bounded Known Space in canonical path order.
#[must_use]
pub fn known_space(&self, session: &SessionId) -> Option<Vec<ObjectObservation>> {
self.core.known_space(session)
}
/// Returns one materialized lazy-tree node.
#[must_use]
pub fn tree(&self, session: &SessionId, path: &str) -> Option<&TreeObservation> {
self.core.tree(session, path)
}
/// Returns the descriptor observation for a complete scoped reference.
#[must_use]
pub fn interface(
&self,
session: &SessionId,
reference: &InterfaceReference,
) -> Option<&InterfaceObservation> {
self.core.interface(session, reference)
}
/// Returns bounded operation history from oldest to newest.
#[must_use]
pub fn call_history(&self, session: &SessionId) -> Option<&VecDeque<CallRecord>> {
self.core.call_history(session)
}
/// Returns bounded diagnostic history from oldest to newest.
#[must_use]
pub fn diagnostics(&self, session: &SessionId) -> Option<&VecDeque<Diagnostic>> {
self.core.diagnostics(session)
}
/// Returns the number of requests currently in flight.
#[must_use]
pub fn in_flight_len(&self) -> usize {
self.core.in_flight_len()
}
/// Returns the immutable context of an in-flight operation.
#[must_use]
pub fn pending_call_context(&self, id: RequestId) -> Option<&CallContext> {
self.core.pending_call_context(id)
}
/// Marks an operation as handed to the transport.
pub fn mark_dispatched(&mut self, id: RequestId) -> Result<(), ClientError> {
self.core.mark_dispatched(id)
}
/// Records a failure with no response, preserving the dispatch boundary.
pub fn fail_transport(
&mut self,
id: RequestId,
detail: impl Into<String>,
) -> Result<Completion, ClientError> {
self.core.fail_transport(id, detail)
}
/// Records timeout or disconnect after dispatch without retrying.
pub fn fail_dispatched_call(
&mut self,
id: RequestId,
failure: DispatchedTransportFailure,
detail: impl Into<String>,
) -> Result<Completion, ClientError> {
self.core.fail_dispatched_call(id, failure, detail)
}
/// Opens a session isolated by canonical endpoint and security context.
pub fn open_session(
&mut self,
endpoint: &str,
security_context: SecurityContext,
) -> Result<SessionId, ClientError> {
let endpoint = Endpoint::parse(endpoint).map_err(invalid_request)?;
self.core.open_session_id(SessionId {
endpoint: endpoint.to_string(),
security_context,
binding: SessionBinding::Http,
})
}
/// Ensures requested coverage without duplicating fresh or loading work.
pub fn ensure_observed(
&mut self,
session: &SessionId,
path: impl Into<String>,
depth: u32,
) -> Result<Option<PreparedRequest>, ClientError> {
let path = path.into();
wip_protocol::validate_path(&path).map_err(invalid_request)?;
let coverage = observation_coverage(self.core.session(session)?, &path, depth);
if coverage != ObservationCoverage::Missing
|| self.core.pending.len() >= self.core.limits.in_flight
{
return Ok(None);
}
self.refresh_observed(session, path, depth).map(Some)
}
/// Ensures direct children without duplicating fresh or loading work.
pub fn ensure_children_observed(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<Option<PreparedRequest>, ClientError> {
self.ensure_observed(session, path, 1)
}
/// Refreshes one object without replacing previously observed children.
pub fn refresh_object(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.refresh_observed(session, path, 0)
}
/// Refreshes one object and its direct children.
pub fn refresh_children(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.refresh_observed(session, path, 1)
}
/// Explicitly refreshes observation coverage, superseding older requests.
pub fn refresh_observed(
&mut self,
session: &SessionId,
path: impl Into<String>,
depth: u32,
) -> Result<PreparedRequest, ClientError> {
let endpoint = endpoint(session)?;
let limits = self.wire_limits;
let (id, request) = self
.core
.refresh_observed_with(session, path, depth, |request| {
encode_observe_request(&endpoint, request, limits).map_err(invalid_request)
})?;
Ok(PreparedRequest { id, request })
}
/// Ensures an interface without duplicating work or evicting another descriptor.
pub fn ensure_interface(
&mut self,
session: &SessionId,
reference: InterfaceReference,
) -> Result<Option<PreparedRequest>, ClientError> {
let state = self.core.session(session)?;
if state.interfaces.contains_key(&reference)
|| state.interfaces.len() >= self.core.limits.interfaces_per_session
|| self.core.pending.len() >= self.core.limits.in_flight
{
return Ok(None);
}
self.prepare_interface(session, reference).map(Some)
}
/// Explicitly starts or refreshes a descriptor observation.
pub fn prepare_interface(
&mut self,
session: &SessionId,
reference: InterfaceReference,
) -> Result<PreparedRequest, ClientError> {
let endpoint = endpoint(session)?;
let limits = self.wire_limits;
let (id, request) = self
.core
.prepare_interface_with(session, reference, |request| {
encode_fetch_interface_request(&endpoint, request, limits).map_err(invalid_request)
})?;
Ok(PreparedRequest { id, request })
}
/// Constructs an operation using fresh observations and an immutable CallContext.
pub fn prepare_call(
&mut self,
session: &SessionId,
path: &str,
interface: &InterfaceReference,
operation: impl Into<String>,
arguments: BTreeMap<String, Value>,
) -> Result<PreparedRequest, ClientError> {
let endpoint = endpoint(session)?;
let limits = self.wire_limits;
let (id, request) = self.core.prepare_call_with(
session,
path,
interface,
operation,
arguments,
|request, descriptor| {
encode_call_operation_request(&endpoint, request, descriptor, limits)
.map_err(invalid_request)
},
)?;
Ok(PreparedRequest { id, request })
}
/// Decodes HTTP metadata and body, then validates and applies the logical result.
pub fn complete(
&mut self,
id: RequestId,
response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> {
let pending = self.core.pending.get(&id).ok_or_else(unknown_request)?;
let session = pending.session().clone();
let mut mismatch = None;
let decoded = match pending {
Pending::Observe { request, .. } => {
decode_observe_response(request, &response, self.wire_limits)
.map(|value| LogicalResponse::Observe(unpack(value, &mut mismatch)))
}
Pending::Interface { request, .. } => {
decode_fetch_interface_response(request, &response, self.wire_limits)
.map(|value| LogicalResponse::FetchInterface(unpack(value, &mut mismatch)))
}
Pending::Call { context, .. } => decode_call_operation_response(
&context.request,
&context.descriptor,
&response,
self.wire_limits,
)
.map(|value| LogicalResponse::CallOperation(unpack(value, &mut mismatch))),
}
.map_err(classify_response_error);
let completion = self.core.complete_result(id, decoded)?;
if !matches!(completion, Completion::StaleResponseRejected(_))
&& let Some(mismatch) = mismatch
{
let maximum = self.core.limits.history;
self.core.session_mut(&session)?.push_diagnostic(
Diagnostic {
request_id: id,
kind: DiagnosticKind::HttpStatusMismatch,
detail: format!(
"received HTTP {}; canonical status is {}",
mismatch.actual, mismatch.expected
),
},
maximum,
);
}
Ok(completion)
}
}
fn endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
if session.binding != SessionBinding::Http {
return Err(invalid_request(
"direct Worldspace session cannot be used as an HTTP endpoint",
));
}
Endpoint::parse(&session.endpoint).map_err(invalid_request)
}
fn unpack<T>(
value: DecodedResponse<T>,
mismatch: &mut Option<StatusMismatch>,
) -> Result<T, ProtocolError> {
match value {
DecodedResponse::Success(value) => Ok(value),
DecodedResponse::ProtocolFailure {
error,
status_mismatch,
} => {
*mismatch = status_mismatch;
Err(error)
}
}
}
fn classify_response_error(error: ClientResponseError) -> ClientError {
let kind = match error {
ClientResponseError::InvalidResponse { .. } => ClientErrorKind::InvalidResponse,
ClientResponseError::UnsupportedDescriptorFormat { .. } => {
ClientErrorKind::UnsupportedDescriptorFormat
}
ClientResponseError::TransportBinding(_) => ClientErrorKind::Transport,
};
ClientError::new(kind, error.to_string())
}
+196 -151
View File
@@ -1,28 +1,65 @@
//! Presentation-independent stateful runtime for WIP clients.
//! Stateful WIP client with a transport-independent logical core.
//!
//! [`Client`] separates observations by canonical WIP over HTTP endpoint and caller-
//! supplied security context. It reconciles requested observation coverage without
//! duplicating fresh or loading work, creates requests with the standard
//! [`wip_http`] adapter, correlates responses by [`RequestId`], and retains
//! bounded object, tree, interface, diagnostic, and operation state. The crate
//! contains no terminal, widget, or UI event types.
//! Use [`direct::Client`] for typed in-process interactions without HTTP or JSON.
//! The default `http` feature retains the original `Client` HTTP facade.
//! Both paths share bounded state, validation, correlation, and Scope lifetime handling.
#![deny(missing_docs)]
mod validation;
use validation::{invalid_request, validate_call, validate_interface, validate_observe};
use std::collections::{BTreeMap, VecDeque};
use std::error::Error;
use std::fmt::{self, Display, Formatter};
use wip_http::http::{Request, Response};
use wip_http::{
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
encode_fetch_interface_request, encode_observe_request,
};
#[cfg(feature = "http")]
mod http_adapter;
#[cfg(feature = "http")]
pub use http_adapter::{Client, PreparedRequest};
/// Typed, transport-independent client API. Available without default features.
///
/// ```
/// use wip_client::direct::{Client, Request, Response};
/// use wip_client::{ClientLimits, Completion, SecurityContext};
/// use wip_protocol::{Object, ObjectObservation};
///
/// let mut client = Client::new(ClientLimits::new(2, 32, 16, 8, 16)?);
/// let session = client.open_session("embedded-world-v1", SecurityContext::new("reader"))?;
/// let prepared = client.refresh_object(&session, "/")?;
/// let Request::Observe(request) = prepared.request else { unreachable!() };
/// assert_eq!(request.path, "/");
/// // An in-process Host returns this DTO directly, without HTTP or JSON.
/// let result = Ok(ObjectObservation {
/// object: Object {
/// name: String::new(), description: None, interfaces: vec![],
/// r#ref: None, validator: None,
/// },
/// children: None,
/// });
/// let completion = client.complete(prepared.id, Response::Observe(result))?;
/// assert!(matches!(completion, Completion::Object(_)));
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// `FetchInterface` and `CallOperation` use the corresponding request/response
/// variants. Dispatch each prepared request unchanged to its session's Host and
/// security context. Call [`DirectClient::mark_dispatched`] immediately before invoking
/// an Operation. Typed responses are untrusted and validated against the pending
/// request and immutable CallContext; the runtime never automatically retries.
pub mod direct {
pub use super::{
DirectClient as Client, DirectPreparedRequest as PreparedRequest,
LogicalRequest as Request, LogicalResponse as Response,
};
}
use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object,
ObjectObservation as ProtocolObjectObservation, ObserveRequest, OperationDeclaration,
ProtocolError, ProtocolErrorCode, ProtocolInteraction, Target, Value,
};
/// Monotonic identity assigned to one logical request.
@@ -58,15 +95,23 @@ impl SecurityContext {
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum SessionBinding {
Direct,
#[cfg(feature = "http")]
Http,
}
/// Identity of an isolated client session.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SessionId {
endpoint: String,
security_context: SecurityContext,
binding: SessionBinding,
}
impl SessionId {
/// Returns the canonical endpoint URL, including its trailing slash.
/// Returns the canonical HTTP endpoint or application-defined direct Worldspace identity.
#[must_use]
pub fn endpoint(&self) -> &str {
&self.endpoint
@@ -368,13 +413,35 @@ pub struct Diagnostic {
pub detail: String,
}
/// Encoded HTTP request paired with its runtime identity.
/// Typed request paired with its runtime identity.
#[derive(Debug)]
pub struct PreparedRequest {
pub struct DirectPreparedRequest {
/// Identity required when completing or failing this request.
pub id: RequestId,
/// Standard WIP over HTTP request.
pub request: Request<Vec<u8>>,
/// Logical interaction to dispatch to the selected Worldspace.
pub request: LogicalRequest,
}
/// A typed request with no transport encoding.
#[derive(Debug, Clone, PartialEq)]
pub enum LogicalRequest {
/// Observe an Object and requested descendants.
Observe(ObserveRequest),
/// Fetch a scoped Interface descriptor.
FetchInterface(FetchInterfaceRequest),
/// Invoke an Operation using immutable prepared inputs.
CallOperation(CallOperationRequest),
}
/// An untrusted typed result. Every completion is validated against its request.
#[derive(Debug, Clone, PartialEq)]
pub enum LogicalResponse {
/// Observe success or Protocol failure.
Observe(Result<ProtocolObjectObservation, ProtocolError>),
/// Interface success or Protocol failure.
FetchInterface(Result<FetchInterfaceResponse, ProtocolError>),
/// Operation success or Protocol failure.
CallOperation(Result<CallOperationResponse, ProtocolError>),
}
/// Result of applying a correlated response to the runtime.
@@ -553,40 +620,43 @@ impl Pending {
/// Stateful, presentation-independent WIP client runtime.
#[derive(Debug)]
pub struct Client {
pub struct DirectClient {
limits: ClientLimits,
wire_limits: Limits,
sessions: BTreeMap<SessionId, Session>,
pending: BTreeMap<RequestId, Pending>,
next_request_id: u64,
}
impl Client {
/// Creates an empty runtime with explicit state and wire bounds.
impl DirectClient {
/// Creates an empty logical runtime with explicit state bounds.
#[must_use]
pub fn new(limits: ClientLimits, wire_limits: Limits) -> Self {
pub fn new(limits: ClientLimits) -> Self {
Self {
limits,
wire_limits,
sessions: BTreeMap::new(),
pending: BTreeMap::new(),
next_request_id: 1,
}
}
/// Opens or returns a session isolated by canonical endpoint and security context.
/// Opens or returns a session isolated by Worldspace view and security context.
///
/// The identity is opaque, not a URL. Use different identities for different
/// Worldspace views and replace it when an in-process Host is replaced.
/// No endpoint parsing, HTTP binding, or JSON codec is involved.
pub fn open_session(
&mut self,
endpoint: &str,
security_context: SecurityContext,
) -> Result<SessionId, ClientError> {
let endpoint = Endpoint::parse(endpoint).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
let id = SessionId {
endpoint: endpoint.to_string(),
self.open_session_id(SessionId {
endpoint: endpoint.to_owned(),
security_context,
};
binding: SessionBinding::Direct,
})
}
fn open_session_id(&mut self, id: SessionId) -> Result<SessionId, ClientError> {
if !self.sessions.contains_key(&id) {
if self.sessions.len() >= self.limits.sessions {
return Err(ClientError::new(
@@ -679,7 +749,7 @@ impl Client {
session: &SessionId,
path: impl Into<String>,
depth: u32,
) -> Result<Option<PreparedRequest>, ClientError> {
) -> Result<Option<DirectPreparedRequest>, ClientError> {
let path = path.into();
wip_protocol::validate_path(&path).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
@@ -697,7 +767,7 @@ impl Client {
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<Option<PreparedRequest>, ClientError> {
) -> Result<Option<DirectPreparedRequest>, ClientError> {
self.ensure_observed(session, path, 1)
}
@@ -707,7 +777,7 @@ impl Client {
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
) -> Result<DirectPreparedRequest, ClientError> {
self.refresh_observed(session, path, 0)
}
@@ -716,30 +786,40 @@ impl Client {
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
) -> Result<DirectPreparedRequest, ClientError> {
self.refresh_observed(session, path, 1)
}
/// Explicitly starts or refreshes an object observation through `depth`.
///
/// Unlike [`Client::ensure_observed`], this always creates a new request and
/// Unlike [`DirectClient::ensure_observed`], this always creates a new request and
/// supersedes older requests for the same root path.
pub fn refresh_observed(
&mut self,
session: &SessionId,
path: impl Into<String>,
depth: u32,
) -> Result<PreparedRequest, ClientError> {
) -> Result<DirectPreparedRequest, ClientError> {
let (id, request) = self.refresh_observed_with(session, path, depth, |request| {
Ok(LogicalRequest::Observe(request.clone()))
})?;
Ok(DirectPreparedRequest { id, request })
}
fn refresh_observed_with<T>(
&mut self,
session: &SessionId,
path: impl Into<String>,
depth: u32,
encode: impl FnOnce(&ObserveRequest) -> Result<T, ClientError>,
) -> Result<(RequestId, T), ClientError> {
self.ensure_capacity()?;
let request = ObserveRequest {
path: path.into(),
depth,
};
let endpoint = session_endpoint(session)?;
let encoded =
encode_observe_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
request.validate().map_err(invalid_request)?;
let encoded = encode(&request)?;
let id = self.allocate_request_id();
let maximum = self.limits.objects_per_session;
let state = self.session_mut(session)?;
@@ -782,10 +862,7 @@ impl Client {
request,
},
);
Ok(PreparedRequest {
id,
request: encoded,
})
Ok((id, encoded))
}
/// Ensures that an interface descriptor has been observed without issuing
@@ -800,7 +877,7 @@ impl Client {
&mut self,
session: &SessionId,
reference: InterfaceReference,
) -> Result<Option<PreparedRequest>, ClientError> {
) -> Result<Option<DirectPreparedRequest>, ClientError> {
let state = self.session(session)?;
if state.interfaces.contains_key(&reference)
|| state.interfaces.len() >= self.limits.interfaces_per_session
@@ -813,22 +890,31 @@ impl Client {
/// Explicitly starts or refreshes an interface descriptor observation.
///
/// Unlike [`Client::ensure_interface`], this always creates a new request and
/// Unlike [`DirectClient::ensure_interface`], this always creates a new request and
/// may evict an older non-loading descriptor when the interface cache is full.
pub fn prepare_interface(
&mut self,
session: &SessionId,
reference: InterfaceReference,
) -> Result<PreparedRequest, ClientError> {
) -> Result<DirectPreparedRequest, ClientError> {
let (id, request) = self.prepare_interface_with(session, reference, |request| {
Ok(LogicalRequest::FetchInterface(request.clone()))
})?;
Ok(DirectPreparedRequest { id, request })
}
fn prepare_interface_with<T>(
&mut self,
session: &SessionId,
reference: InterfaceReference,
encode: impl FnOnce(&FetchInterfaceRequest) -> Result<T, ClientError>,
) -> Result<(RequestId, T), ClientError> {
self.ensure_capacity()?;
let request = FetchInterfaceRequest {
interface: reference,
};
let endpoint = session_endpoint(session)?;
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
.map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
request.validate().map_err(invalid_request)?;
let encoded = encode(&request)?;
let id = self.allocate_request_id();
let maximum = self.limits.interfaces_per_session;
let state = self.session_mut(session)?;
@@ -863,10 +949,7 @@ impl Client {
request,
},
);
Ok(PreparedRequest {
id,
request: encoded,
})
Ok((id, encoded))
}
/// Constructs an operation from fresh object and interface observations.
@@ -883,7 +966,27 @@ impl Client {
interface: &InterfaceReference,
operation: impl Into<String>,
arguments: BTreeMap<String, Value>,
) -> Result<PreparedRequest, ClientError> {
) -> Result<DirectPreparedRequest, ClientError> {
let (id, request) = self.prepare_call_with(
session,
path,
interface,
operation,
arguments,
|request, _| Ok(LogicalRequest::CallOperation(request.clone())),
)?;
Ok(DirectPreparedRequest { id, request })
}
fn prepare_call_with<T>(
&mut self,
session: &SessionId,
path: &str,
interface: &InterfaceReference,
operation: impl Into<String>,
arguments: BTreeMap<String, Value>,
encode: impl FnOnce(&CallOperationRequest, &InterfaceDescriptor) -> Result<T, ClientError>,
) -> Result<(RequestId, T), ClientError> {
self.ensure_capacity()?;
let operation = operation.into();
let context = {
@@ -969,14 +1072,11 @@ impl Client {
request,
}
};
let endpoint = session_endpoint(session)?;
let encoded = encode_call_operation_request(
&endpoint,
&context.request,
&context.descriptor,
self.wire_limits,
)
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))?;
context
.request
.validate_with(&context.descriptor)
.map_err(invalid_request)?;
let encoded = encode(&context.request, &context.descriptor)?;
let id = self.allocate_request_id();
self.session_mut(&context.session)?
.pin_call_object(context.object_key);
@@ -987,10 +1087,7 @@ impl Client {
dispatched: false,
},
);
Ok(PreparedRequest {
id,
request: encoded,
})
Ok((id, encoded))
}
/// Marks an operation request as handed to the transport.
@@ -1008,11 +1105,20 @@ impl Client {
}
}
/// Applies one HTTP response to the exact request identity that produced it.
/// Validates and applies a typed response to the exact request that produced it.
/// A mismatched interaction is invalid; invalid call responses have unknown outcomes.
pub fn complete(
&mut self,
id: RequestId,
response: Response<Vec<u8>>,
response: LogicalResponse,
) -> Result<Completion, ClientError> {
self.complete_result(id, Ok(response))
}
fn complete_result(
&mut self,
id: RequestId,
response: Result<LogicalResponse, ClientError>,
) -> Result<Completion, ClientError> {
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
match pending {
@@ -1141,13 +1247,13 @@ impl Client {
id: RequestId,
session: &SessionId,
request: ObserveRequest,
response: Response<Vec<u8>>,
response: Result<LogicalResponse, ClientError>,
) -> Result<Completion, ClientError> {
if !self.entry_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id);
}
match decode_observe_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(value)) => {
match validate_observe(&request, response) {
Ok(Ok(value)) => {
let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone();
if let Err(error) =
@@ -1168,11 +1274,7 @@ impl Client {
self.sessions.insert(session.clone(), candidate);
Ok(Completion::Object(observation))
}
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
}) => {
self.record_status_mismatch(session, id, status_mismatch)?;
Ok(Err(error)) => {
let observation_state = if error.code == ProtocolErrorCode::NotFound {
ObservationState::Stale
} else {
@@ -1205,13 +1307,13 @@ impl Client {
id: RequestId,
session: &SessionId,
request: FetchInterfaceRequest,
response: Response<Vec<u8>>,
response: Result<LogicalResponse, ClientError>,
) -> Result<Completion, ClientError> {
if !self.interface_request_is_current(session, &request.interface, id) {
return self.reject_stale(session, id);
}
match decode_fetch_interface_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(value)) => {
match validate_interface(&request, response) {
Ok(Ok(value)) => {
let state = self.session_mut(session)?;
// Newer scope evidence (Observe or another Interface name) can
// supersede even the first fetch for this pair.
@@ -1290,11 +1392,7 @@ impl Client {
record.touched = touched;
Ok(Completion::Interface(observation))
}
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
}) => {
self.record_status_mismatch(session, id, status_mismatch)?;
Ok(Err(error)) => {
self.session_mut(session)?
.interfaces
.get_mut(&request.interface)
@@ -1319,15 +1417,10 @@ impl Client {
&mut self,
id: RequestId,
context: CallContext,
response: Response<Vec<u8>>,
response: Result<LogicalResponse, ClientError>,
) -> Result<Completion, ClientError> {
match decode_call_operation_response(
&context.request,
&context.descriptor,
&response,
self.wire_limits,
) {
Ok(DecodedResponse::Success(value)) => {
match validate_call(&context, response) {
Ok(Ok(value)) => {
let session_id = context.session.clone();
let maximum = self.limits.history;
let state = self.session_mut(&session_id)?;
@@ -1347,12 +1440,8 @@ impl Client {
state.push_call(record.clone(), maximum);
Ok(Completion::Call(Box::new(record)))
}
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
}) => {
Ok(Err(error)) => {
let session_id = context.session.clone();
self.record_status_mismatch(&session_id, id, status_mismatch)?;
if error.code == ProtocolErrorCode::OperationOutcomeUnknown {
let maximum = self.limits.history;
let state = self.session_mut(&session_id)?;
@@ -1410,7 +1499,7 @@ impl Client {
Ok(Completion::Call(Box::new(record)))
}
Err(error) => {
let failure = classify_response_error(error);
let failure = error;
self.finish_unknown_call(id, context, OutcomeUnknownReason::ResponseDecode, failure)
}
}
@@ -1452,9 +1541,8 @@ impl Client {
id: RequestId,
session: &SessionId,
subject: RetrievalSubject,
error: ClientResponseError,
error: ClientError,
) -> Result<Completion, ClientError> {
let error = classify_response_error(error);
match subject {
RetrievalSubject::Observe(request) => {
set_observe_subject_state(
@@ -1505,29 +1593,6 @@ impl Client {
Ok(())
}
fn record_status_mismatch(
&mut self,
session: &SessionId,
id: RequestId,
mismatch: Option<wip_http::StatusMismatch>,
) -> Result<(), ClientError> {
if let Some(mismatch) = mismatch {
let maximum = self.limits.history;
self.session_mut(session)?.push_diagnostic(
Diagnostic {
request_id: id,
kind: DiagnosticKind::HttpStatusMismatch,
detail: format!(
"received HTTP {}; canonical status is {}",
mismatch.actual, mismatch.expected
),
},
maximum,
);
}
Ok(())
}
fn push_failure_diagnostic(
&mut self,
session: &SessionId,
@@ -1682,30 +1747,10 @@ fn observation_coverage(state: &Session, path: &str, depth: u32) -> ObservationC
}
}
fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
Endpoint::parse(&session.endpoint)
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))
}
fn unknown_request() -> ClientError {
ClientError::new(ClientErrorKind::InvalidRequest, "unknown request identity")
}
fn classify_response_error(error: ClientResponseError) -> ClientError {
match error {
ClientResponseError::InvalidResponse { .. } => {
ClientError::new(ClientErrorKind::InvalidResponse, error.to_string())
}
ClientResponseError::UnsupportedDescriptorFormat { .. } => ClientError::new(
ClientErrorKind::UnsupportedDescriptorFormat,
error.to_string(),
),
ClientResponseError::TransportBinding(_) => {
ClientError::new(ClientErrorKind::Transport, error.to_string())
}
}
}
fn set_observe_subject_state(
state: &mut Session,
request: &ObserveRequest,
+80
View File
@@ -0,0 +1,80 @@
use super::*;
fn invalid_response(error: impl Display) -> ClientError {
ClientError::new(ClientErrorKind::InvalidResponse, error.to_string())
}
pub(super) fn invalid_request(error: impl Display) -> ClientError {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
}
fn validate_failure<T>(
result: Result<T, ProtocolError>,
interaction: ProtocolInteraction,
validate: impl FnOnce(&T) -> Result<(), ClientError>,
) -> Result<Result<T, ProtocolError>, ClientError> {
match &result {
Ok(value) => validate(value)?,
Err(error) if !error.code.is_allowed_for(interaction) => {
return Err(invalid_response(
"Protocol error is forbidden for this interaction",
));
}
Err(_) => {}
}
Ok(result)
}
pub(super) fn validate_observe(
request: &ObserveRequest,
response: Result<LogicalResponse, ClientError>,
) -> Result<Result<ProtocolObjectObservation, ProtocolError>, ClientError> {
let LogicalResponse::Observe(result) = response? else {
return Err(invalid_response(
"response interaction does not match Observe request",
));
};
validate_failure(result, ProtocolInteraction::Observe, |value| {
request.validate_response(value).map_err(invalid_response)
})
}
pub(super) fn validate_interface(
request: &FetchInterfaceRequest,
response: Result<LogicalResponse, ClientError>,
) -> Result<Result<FetchInterfaceResponse, ProtocolError>, ClientError> {
let LogicalResponse::FetchInterface(result) = response? else {
return Err(invalid_response(
"response interaction does not match FetchInterface request",
));
};
validate_failure(result, ProtocolInteraction::FetchInterface, |value| {
value.validate_for(request).map_err(invalid_response)?;
if value.descriptor.format != INTERFACE_FORMAT_V1 {
return Err(ClientError::new(
ClientErrorKind::UnsupportedDescriptorFormat,
format!(
"unsupported interface descriptor format `{}`",
value.descriptor.format
),
));
}
Ok(())
})
}
pub(super) fn validate_call(
context: &CallContext,
response: Result<LogicalResponse, ClientError>,
) -> Result<Result<CallOperationResponse, ProtocolError>, ClientError> {
let LogicalResponse::CallOperation(result) = response? else {
return Err(invalid_response(
"response interaction does not match CallOperation request",
));
};
validate_failure(result, ProtocolInteraction::CallOperation, |value| {
value
.validate_for(&context.descriptor, &context.request.operation)
.map_err(invalid_response)
})
}
+614
View File
@@ -0,0 +1,614 @@
//! Public API tests: no HTTP types, codec, endpoint parser, or JSON dependency.
use std::collections::BTreeMap;
use wip_client::direct::{Client, PreparedRequest, Request, Response};
use wip_client::{
CallOutcome, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure,
ObservationState, OutcomeUnknownReason, SecurityContext, SessionId,
};
use wip_protocol::{
CallOperationResponse, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceReference, Object, ObjectObservation, OperationDeclaration, ParameterDeclaration,
ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
};
fn setup() -> (Client, SessionId) {
let mut client = Client::new(ClientLimits::new(4, 16, 8, 8, 4).unwrap());
let session = client
.open_session("in-process world", SecurityContext::new("alice"))
.unwrap();
(client, session)
}
fn reference() -> InterfaceReference {
InterfaceReference {
scope: "/scope".into(),
name: "math".into(),
}
}
fn object(name: &str, identity: Option<&str>) -> ObjectObservation {
ObjectObservation {
object: Object {
name: name.into(),
description: None,
interfaces: vec![reference()],
r#ref: identity.map(str::to_owned),
validator: None,
},
children: None,
}
}
fn descriptor() -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "double".into(),
documentation: None,
parameters: vec![ParameterDeclaration {
name: "n".into(),
documentation: None,
required: true,
r#type: TypeExpr::Integer,
}],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Integer,
},
}],
}
}
fn interface(scope_ref: Option<&str>) -> FetchInterfaceResponse {
FetchInterfaceResponse {
interface: reference(),
scope_ref: scope_ref.map(str::to_owned),
validator: None,
descriptor: descriptor(),
}
}
fn failure(code: ProtocolErrorCode) -> ProtocolError {
ProtocolError {
code,
message: "test failure".into(),
}
}
fn observe(client: &mut Client, session: &SessionId, path: &str, value: ObjectObservation) {
let request = client.refresh_object(session, path).unwrap();
assert!(
matches!(&request.request, Request::Observe(value) if value.path == path && value.depth == 0)
);
client
.complete(request.id, Response::Observe(Ok(value)))
.unwrap();
}
fn ready(client: &mut Client, session: &SessionId) {
observe(client, session, "/scope/item", object("item", None));
let request = client.prepare_interface(session, reference()).unwrap();
assert!(
matches!(request.request, Request::FetchInterface(value) if value.interface == reference())
);
client
.complete(request.id, Response::FetchInterface(Ok(interface(None))))
.unwrap();
}
fn call(client: &mut Client, session: &SessionId) -> PreparedRequest {
client
.prepare_call(
session,
"/scope/item",
&reference(),
"double",
BTreeMap::from([("n".into(), Value::Integer(3))]),
)
.unwrap()
}
#[test]
fn typed_interactions_succeed_with_optional_identity_and_validators_absent() {
let (mut client, session) = setup();
ready(&mut client, &session);
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
assert!(
client
.ensure_interface(&session, reference())
.unwrap()
.is_none()
);
let request = call(&mut client, &session);
let Request::CallOperation(logical) = request.request else {
panic!("wrong request")
};
assert_eq!(logical.target.path, "/scope/item");
assert_eq!(logical.target.validator, None);
assert_eq!(logical.interface.scope_ref, None);
assert_eq!(logical.interface.validator, None);
assert_eq!(logical.arguments["n"], Value::Integer(3));
client.mark_dispatched(request.id).unwrap();
let result = CallOperationResponse {
result: Value::Integer(6),
validator: Some(vec![2]),
};
let completion = client
.complete(request.id, Response::CallOperation(Ok(result.clone())))
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if record.outcome == CallOutcome::Success(result))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().validator,
Some(vec![2])
);
assert_eq!(client.in_flight_len(), 0);
assert_eq!(
client
.complete(
request.id,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::Integer(6),
validator: None,
}))
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
}
#[test]
fn worldspace_and_security_identities_partition_observations_without_urls() {
let (mut client, session) = setup();
ready(&mut client, &session);
let other_world = client
.open_session("another world", SecurityContext::new("alice"))
.unwrap();
let other_security = client
.open_session("in-process world", SecurityContext::new("bob"))
.unwrap();
for other in [&other_world, &other_security] {
assert!(client.object(other, "/scope/item").is_none());
assert!(client.interface(other, &reference()).is_none());
}
assert_eq!(
client
.open_session("in-process world", SecurityContext::new("alice"))
.unwrap(),
session
);
}
#[test]
fn typed_observations_validate_name_depth_duplicate_children_and_interface_scope() {
let (mut client, session) = setup();
let mut wrong_scope = object("item", None);
wrong_scope.object.interfaces[0].scope = "/elsewhere".into();
let mut unexpected_children = object("item", None);
unexpected_children.children = Some(vec![]);
let mut duplicate = object("item", None);
duplicate.children = Some(vec![object("child", None), object("child", None)]);
for (depth, response) in [
(0, object("wrong-name", None)),
(1, object("item", None)),
(0, unexpected_children),
(0, wrong_scope),
(1, duplicate),
] {
let request = client
.refresh_observed(&session, "/scope/item", depth)
.unwrap();
let error = client
.complete(request.id, Response::Observe(Ok(response)))
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
assert!(matches!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Error(_)
));
}
}
#[test]
fn typed_descriptor_correspondence_structure_and_format_are_checked() {
let (mut client, session) = setup();
let mut mismatched = interface(None);
mismatched.interface.name = "other".into();
let mut malformed = interface(None);
malformed
.descriptor
.operations
.push(malformed.descriptor.operations[0].clone());
let mut unsupported = interface(None);
unsupported.descriptor.format = "future".into();
for (value, kind) in [
(mismatched, ClientErrorKind::InvalidResponse),
(malformed, ClientErrorKind::InvalidResponse),
(unsupported, ClientErrorKind::UnsupportedDescriptorFormat),
] {
let request = client.prepare_interface(&session, reference()).unwrap();
assert_eq!(
client
.complete(request.id, Response::FetchInterface(Ok(value)))
.unwrap_err()
.kind,
kind
);
}
}
#[test]
fn typed_arguments_and_results_use_the_fixed_descriptor() {
let (mut client, session) = setup();
ready(&mut client, &session);
let invalid = client
.prepare_call(
&session,
"/scope/item",
&reference(),
"double",
BTreeMap::from([("n".into(), Value::String("not integer".into()))]),
)
.unwrap_err();
assert_eq!(invalid.kind, ClientErrorKind::InvalidRequest);
assert_eq!(client.in_flight_len(), 0);
let request = call(&mut client, &session);
client.mark_dispatched(request.id).unwrap();
let completion = client
.complete(
request.id,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::String("not integer".into()),
validator: None,
})),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome,
CallOutcome::Unknown { reason: OutcomeUnknownReason::ResponseDecode, .. }))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
assert_eq!(client.in_flight_len(), 0);
}
#[test]
fn typed_protocol_failures_validate_interaction_and_apply_recovery() {
let (mut client, session) = setup();
let request = client.refresh_object(&session, "/scope/item").unwrap();
assert!(matches!(
client
.complete(
request.id,
Response::Observe(Err(failure(ProtocolErrorCode::NotFound)))
)
.unwrap(),
Completion::ProtocolFailure(_)
));
let request = client.prepare_interface(&session, reference()).unwrap();
assert!(matches!(
client
.complete(
request.id,
Response::FetchInterface(Err(failure(ProtocolErrorCode::InterfaceNotFound)))
)
.unwrap(),
Completion::ProtocolFailure(_)
));
let request = client.refresh_object(&session, "/scope/item").unwrap();
assert_eq!(
client
.complete(
request.id,
Response::Observe(Err(failure(ProtocolErrorCode::OperationOutcomeUnknown)))
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
let request = client.prepare_interface(&session, reference()).unwrap();
assert_eq!(
client
.complete(
request.id,
Response::FetchInterface(Err(failure(ProtocolErrorCode::ValidatorMismatch)))
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
ready(&mut client, &session);
let request = call(&mut client, &session);
let completion = client
.complete(
request.id,
Response::CallOperation(Err(failure(ProtocolErrorCode::InterfaceMismatch))),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::ProtocolFailure(_)))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client.interface(&session, &reference()).unwrap().state,
ObservationState::Stale
);
}
#[test]
fn mismatched_interactions_are_not_trusted_and_calls_remain_unknown() {
let (mut client, session) = setup();
let request = client.refresh_object(&session, "/scope/item").unwrap();
assert_eq!(
client
.complete(request.id, Response::FetchInterface(Ok(interface(None))))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
let request = client.prepare_interface(&session, reference()).unwrap();
assert_eq!(
client
.complete(request.id, Response::Observe(Ok(object("item", None))))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
ready(&mut client, &session);
let request = call(&mut client, &session);
let completion = client
.complete(
request.id,
Response::Observe(Err(failure(ProtocolErrorCode::NotFound))),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { .. }))
);
}
#[test]
fn typed_stale_responses_cannot_replace_new_observations() {
let (mut client, session) = setup();
let old = client.refresh_object(&session, "/scope/item").unwrap();
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
let new = client.refresh_object(&session, "/scope/item").unwrap();
client
.complete(new.id, Response::Observe(Ok(object("item", Some("new")))))
.unwrap();
assert_eq!(
client
.complete(old.id, Response::Observe(Ok(object("item", Some("old")))))
.unwrap(),
Completion::StaleResponseRejected(old.id)
);
assert_eq!(
client
.object(&session, "/scope/item")
.unwrap()
.object
.unwrap()
.r#ref
.as_deref(),
Some("new")
);
}
#[test]
fn scope_replacement_retires_descriptors_and_pending_fetch_but_not_call_snapshot() {
let (mut client, session) = setup();
observe(
&mut client,
&session,
"/scope",
object("scope", Some("old")),
);
ready(&mut client, &session);
let call = call(&mut client, &session);
let fetch = client.prepare_interface(&session, reference()).unwrap();
observe(
&mut client,
&session,
"/scope",
object("scope", Some("new")),
);
assert!(
client
.interface(&session, &reference())
.unwrap()
.descriptor
.is_none()
);
assert_eq!(
client
.complete(
fetch.id,
Response::FetchInterface(Ok(interface(Some("old"))))
)
.unwrap(),
Completion::StaleResponseRejected(fetch.id)
);
assert_eq!(
client.pending_call_context(call.id).unwrap().descriptor(),
&descriptor()
);
assert_eq!(
client
.pending_call_context(call.id)
.unwrap()
.request()
.interface
.scope_ref,
None
);
// A newer descriptor has a different return type; the prepared call still uses Integer.
let fetch = client.prepare_interface(&session, reference()).unwrap();
let mut next = interface(Some("new"));
next.descriptor.operations[0].returns.r#type = TypeExpr::String;
client
.complete(fetch.id, Response::FetchInterface(Ok(next)))
.unwrap();
let completion = client
.complete(
call.id,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::Integer(6),
validator: None,
})),
)
.unwrap();
assert!(
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Success(_)))
);
}
#[test]
fn scope_not_found_retires_interfaces_and_late_fetch_cannot_restore_them() {
let (mut client, session) = setup();
ready(&mut client, &session);
let old = client.prepare_interface(&session, reference()).unwrap();
let missing = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
missing.id,
Response::Observe(Err(failure(ProtocolErrorCode::NotFound))),
)
.unwrap();
assert!(client.interface(&session, &reference()).is_none());
assert_eq!(
client
.complete(old.id, Response::FetchInterface(Ok(interface(None))))
.unwrap(),
Completion::StaleResponseRejected(old.id)
);
}
#[test]
fn failures_before_and_after_dispatch_never_retry_operations() {
let (mut client, session) = setup();
ready(&mut client, &session);
let before = call(&mut client, &session);
assert!(
matches!(client.fail_transport(before.id, "not handed to Host").unwrap(),
Completion::Call(record) if matches!(record.outcome, CallOutcome::NotDispatched(_)))
);
assert_eq!(
client.object(&session, "/scope/item").unwrap().state,
ObservationState::Fresh
);
let after = call(&mut client, &session);
client.mark_dispatched(after.id).unwrap();
assert!(
matches!(client.fail_dispatched_call(after.id, DispatchedTransportFailure::Timeout, "deadline").unwrap(),
Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { reason: OutcomeUnknownReason::Timeout, .. }))
);
assert_eq!(client.in_flight_len(), 0);
assert!(
client
.ensure_observed(&session, "/scope/item", 0)
.unwrap()
.is_none()
);
ready(&mut client, &session);
let unknown = call(&mut client, &session);
assert!(
matches!(client.complete(unknown.id, Response::CallOperation(Err(failure(ProtocolErrorCode::OperationOutcomeUnknown)))).unwrap(),
Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { reason: OutcomeUnknownReason::Protocol, .. }))
);
assert_eq!(client.in_flight_len(), 0);
assert_eq!(client.call_history(&session).unwrap().len(), 3);
}
#[test]
fn direct_limits_bound_sessions_inflight_cache_and_history() {
let mut client = Client::new(ClientLimits::new(1, 1, 1, 1, 1).unwrap());
let session = client
.open_session("world", SecurityContext::new("alice"))
.unwrap();
assert_eq!(
client
.open_session("other", SecurityContext::new("alice"))
.unwrap_err()
.kind,
ClientErrorKind::Capacity
);
let first = client.refresh_object(&session, "/scope/item").unwrap();
assert!(
client
.ensure_observed(&session, "/scope/other", 0)
.unwrap()
.is_none()
);
assert_eq!(
client
.refresh_object(&session, "/scope/other")
.unwrap_err()
.kind,
ClientErrorKind::Capacity
);
client
.complete(first.id, Response::Observe(Ok(object("item", None))))
.unwrap();
observe(&mut client, &session, "/scope/other", object("other", None));
assert_eq!(client.known_space(&session).unwrap().len(), 1);
for _ in 0..3 {
let request = client.refresh_object(&session, "/scope/other").unwrap();
client
.complete(request.id, Response::Observe(Ok(object("wrong", None))))
.unwrap_err();
}
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
}
#[test]
fn invalid_direct_requests_do_not_acquire_pending_state() {
let (mut client, session) = setup();
assert_eq!(
client
.refresh_object(&session, "not/absolute")
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(
client
.prepare_interface(
&session,
InterfaceReference {
scope: "/".into(),
name: "".into()
}
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(client.in_flight_len(), 0);
assert!(client.known_space(&session).unwrap().is_empty());
}
+289
View File
@@ -0,0 +1,289 @@
#![cfg(feature = "http")]
//! The same stateful scenario runs through direct DTOs and the HTTP facade.
use std::collections::BTreeMap;
use wip_client::direct::{Client as DirectClient, Request, Response};
use wip_client::{
CallOutcome, Client, ClientLimits, Completion, InterfaceObservation, ObjectObservation,
RequestId, SecurityContext, SessionId,
};
use wip_http::{
Limits, decode_call_operation_request, decode_fetch_interface_request, decode_observe_request,
encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
};
use wip_protocol::{
CallOperationResponse, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceReference, Object, ObjectObservation as ProtocolObservation, OperationDeclaration,
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
};
fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
}
fn reference() -> InterfaceReference {
InterfaceReference {
scope: "/scope".into(),
name: "math".into(),
}
}
fn descriptor() -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "run".into(),
documentation: None,
parameters: vec![],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Integer,
},
}],
}
}
fn observation(name: &str, identity: &str) -> ProtocolObservation {
ProtocolObservation {
object: Object {
name: name.into(),
description: None,
interfaces: vec![reference()],
r#ref: Some(identity.into()),
validator: None,
},
children: None,
}
}
fn interface(identity: &str) -> FetchInterfaceResponse {
FetchInterfaceResponse {
interface: reference(),
scope_ref: Some(identity.into()),
validator: None,
descriptor: descriptor(),
}
}
enum Channel {
Direct(DirectClient),
Http(Client),
}
impl Channel {
fn core(&self) -> &DirectClient {
match self {
Self::Direct(client) => client,
Self::Http(client) => client,
}
}
fn observe(&mut self, session: &SessionId, path: &str) -> (RequestId, Request) {
match self {
Self::Direct(client) => {
let p = client.refresh_object(session, path).unwrap();
(p.id, p.request)
}
Self::Http(client) => {
let p = client.refresh_object(session, path).unwrap();
(
p.id,
Request::Observe(decode_observe_request(p.request.body(), limits()).unwrap()),
)
}
}
}
fn fetch(&mut self, session: &SessionId) -> (RequestId, Request) {
match self {
Self::Direct(client) => {
let p = client.prepare_interface(session, reference()).unwrap();
(p.id, p.request)
}
Self::Http(client) => {
let p = client.prepare_interface(session, reference()).unwrap();
(
p.id,
Request::FetchInterface(
decode_fetch_interface_request(p.request.body(), limits()).unwrap(),
),
)
}
}
}
fn call(&mut self, session: &SessionId) -> (RequestId, Request) {
match self {
Self::Direct(client) => {
let p = client
.prepare_call(session, "/scope/item", &reference(), "run", BTreeMap::new())
.unwrap();
client.mark_dispatched(p.id).unwrap();
(p.id, p.request)
}
Self::Http(client) => {
let p = client
.prepare_call(session, "/scope/item", &reference(), "run", BTreeMap::new())
.unwrap();
client.mark_dispatched(p.id).unwrap();
(
p.id,
Request::CallOperation(
decode_call_operation_request(p.request.body(), &descriptor(), limits())
.unwrap(),
),
)
}
}
}
fn complete(&mut self, pending: (RequestId, Request), result: Response) -> Completion {
let (id, request) = pending;
match self {
Self::Direct(client) => client.complete(id, result).unwrap(),
Self::Http(client) => {
let wire = match (request, result) {
(Request::Observe(request), Response::Observe(Ok(value))) => {
encode_observe_response(&request, &value, limits())
}
(Request::FetchInterface(request), Response::FetchInterface(Ok(value))) => {
encode_fetch_interface_response(&request, &value, limits())
}
(Request::CallOperation(request), Response::CallOperation(Ok(value))) => {
encode_call_operation_response(&request, &descriptor(), &value, limits())
}
(_, Response::Observe(Err(error))) => encode_protocol_error_response(
ProtocolInteraction::Observe,
&error,
limits(),
),
(_, Response::FetchInterface(Err(error))) => encode_protocol_error_response(
ProtocolInteraction::FetchInterface,
&error,
limits(),
),
(_, Response::CallOperation(Err(error))) => encode_protocol_error_response(
ProtocolInteraction::CallOperation,
&error,
limits(),
),
_ => panic!("test interaction mismatch"),
}
.unwrap();
client.complete(id, wire).unwrap()
}
}
}
}
#[derive(Debug, PartialEq)]
struct Snapshot {
objects: Vec<ObjectObservation>,
interface: Option<InterfaceObservation>,
outcomes: Vec<CallOutcome>,
requests: Vec<Request>,
}
fn scenario(http: bool) -> Snapshot {
let state_limits = ClientLimits::new(4, 16, 8, 8, 8).unwrap();
let (mut channel, session) = if http {
let mut client = Client::new(state_limits, limits());
let session = client
.open_session("https://example.test/wip", SecurityContext::new("alice"))
.unwrap();
(Channel::Http(client), session)
} else {
let mut client = DirectClient::new(state_limits);
let session = client
.open_session("local world", SecurityContext::new("alice"))
.unwrap();
(Channel::Direct(client), session)
};
let mut requests = vec![];
for (path, name, identity) in [
("/scope", "scope", "scope-v1"),
("/scope/item", "item", "item-v1"),
] {
let p = channel.observe(&session, path);
requests.push(p.1.clone());
channel.complete(p, Response::Observe(Ok(observation(name, identity))));
}
let p = channel.fetch(&session);
requests.push(p.1.clone());
channel.complete(p, Response::FetchInterface(Ok(interface("scope-v1"))));
let p = channel.call(&session);
requests.push(p.1.clone());
channel.complete(
p,
Response::CallOperation(Ok(CallOperationResponse {
result: Value::Integer(42),
validator: Some(vec![2]),
})),
);
let p = channel.call(&session);
requests.push(p.1.clone());
channel.complete(
p,
Response::CallOperation(Err(ProtocolError {
code: ProtocolErrorCode::InterfaceMismatch,
message: "changed".into(),
})),
);
let p = channel.observe(&session, "/scope/item");
channel.complete(p, Response::Observe(Ok(observation("item", "item-v1"))));
let p = channel.fetch(&session);
channel.complete(p, Response::FetchInterface(Ok(interface("scope-v1"))));
let old = channel.fetch(&session);
let p = channel.observe(&session, "/scope");
channel.complete(p, Response::Observe(Ok(observation("scope", "scope-v2"))));
let old_id = old.0;
assert_eq!(
channel.complete(old, Response::FetchInterface(Ok(interface("scope-v1")))),
Completion::StaleResponseRejected(old_id)
);
let p = channel.fetch(&session);
channel.complete(p, Response::FetchInterface(Ok(interface("scope-v2"))));
let p = channel.call(&session);
requests.push(p.1.clone());
channel.complete(
p,
Response::CallOperation(Err(ProtocolError {
code: ProtocolErrorCode::OperationOutcomeUnknown,
message: "unknown".into(),
})),
);
assert_eq!(channel.core().in_flight_len(), 0);
Snapshot {
objects: channel.core().known_space(&session).unwrap(),
interface: channel.core().interface(&session, &reference()).cloned(),
outcomes: channel
.core()
.call_history(&session)
.unwrap()
.iter()
.map(|record| record.outcome.clone())
.collect(),
requests,
}
}
#[test]
fn direct_and_http_agree_on_requests_state_calls_scope_invalidation_and_uncertainty() {
assert_eq!(scenario(false), scenario(true));
}
#[test]
fn http_endpoint_and_identical_direct_identity_do_not_share_cache() {
let mut client = Client::new(ClientLimits::new(4, 16, 8, 8, 8).unwrap(), limits());
let http = client
.open_session("https://example.test/wip", SecurityContext::new("alice"))
.unwrap();
let direct = DirectClient::open_session(
&mut client,
"https://example.test/wip/",
SecurityContext::new("alice"),
)
.unwrap();
assert_ne!(http, direct);
let request = DirectClient::refresh_object(&mut client, &direct, "/scope").unwrap();
DirectClient::complete(
&mut client,
request.id,
Response::Observe(Ok(observation("scope", "local"))),
)
.unwrap();
assert!(client.object(&http, "/scope").is_none());
assert!(client.refresh_object(&direct, "/scope").is_err());
}
+128
View File
@@ -0,0 +1,128 @@
#![cfg(feature = "http")]
//! Wire budgets are adapter-only and failed encoding must not acquire state.
use std::collections::BTreeMap;
use wip_client::{Client, ClientErrorKind, ClientLimits, ObservationState, SecurityContext};
use wip_http::{Limits, encode_fetch_interface_response, encode_observe_response};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceReference, Object, ObjectObservation, ObserveRequest, OperationDeclaration,
ReturnDeclaration, TypeExpr,
};
fn state_limits() -> ClientLimits {
ClientLimits::new(2, 8, 8, 8, 8).unwrap()
}
#[test]
fn request_encoding_failure_leaves_retrieval_cache_and_inflight_unchanged() {
let mut client = Client::new(state_limits(), Limits::new(1, 4096, 32).unwrap());
let session = client
.open_session("https://example.test/wip", SecurityContext::new("alice"))
.unwrap();
assert_eq!(
client.refresh_object(&session, "/item").unwrap_err().kind,
ClientErrorKind::InvalidRequest
);
let reference = InterfaceReference {
scope: "/".into(),
name: "test".into(),
};
assert_eq!(
client
.prepare_interface(&session, reference.clone())
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert!(client.object(&session, "/item").is_none());
assert!(client.interface(&session, &reference).is_none());
assert_eq!(client.in_flight_len(), 0);
}
#[test]
fn call_encoding_failure_keeps_fresh_observations_and_no_pending_call() {
let limits = Limits::new(80, 4096, 32).unwrap();
let mut client = Client::new(state_limits(), limits);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("alice"))
.unwrap();
let reference = InterfaceReference {
scope: "/".into(),
name: "test".into(),
};
let observe = client.refresh_object(&session, "/item").unwrap();
client
.complete(
observe.id,
encode_observe_response(
&ObserveRequest {
path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: Object {
name: "item".into(),
description: None,
interfaces: vec![reference.clone()],
r#ref: None,
validator: None,
},
children: None,
},
limits,
)
.unwrap(),
)
.unwrap();
let fetch = client
.prepare_interface(&session, reference.clone())
.unwrap();
client
.complete(
fetch.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: reference.clone(),
},
&FetchInterfaceResponse {
interface: reference.clone(),
scope_ref: None,
validator: None,
descriptor: InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "run".into(),
documentation: None,
parameters: vec![],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Integer,
},
}],
},
},
limits,
)
.unwrap(),
)
.unwrap();
assert_eq!(
client
.prepare_call(&session, "/item", &reference, "run", BTreeMap::new())
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Fresh
);
assert_eq!(
client.interface(&session, &reference).unwrap().state,
ObservationState::Fresh
);
assert!(client.call_history(&session).unwrap().is_empty());
assert_eq!(client.in_flight_len(), 0);
}
+4 -4
View File
@@ -1,16 +1,16 @@
# wip-host
Framework-independent Host publication and operation dispatch for the Web Interface Protocol (WIP).
Framework-independent Host publication and operation dispatch for the Worldspace Interface Protocol (WIP).
`wip-host` registers generated interfaces, explicitly publishes them on request-scoped objects, validates object and interface preconditions, and performs interface-qualified operation dispatch against one host-owned instance. Backend transactions, persistence, and network serving remain application responsibilities.
```toml
[dependencies]
wip-host = "0.1.0"
wip-interface = "0.1.0"
wip-host = "0.2.0"
wip-interface = "0.2.0"
```
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. The legacy `InterfaceRef` registration token remains Host-private: `protocol_reference()` maps its opaque string verbatim to the name at root scope `/`, without parsing it. Registry lookup and fetch accept structured references; non-root references do not resolve legacy registrations. Legacy responses omit `scope_ref`.
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. Registration takes an explicit `Scope` and local name; `InterfaceRef::protocol_reference()` preserves those coordinates without parsing display text. Providers attach a current `ScopeObservation` inside the coherent backend boundary, and fetch returns its optional Object ref as `scope_ref`. Calls check a supplied `scope_ref` against that current binding; omission adds no identity precondition. Retire a Scope on Object deletion, replacement or movement; retained registry artifacts alone do not establish publication.
Malformed interface-reference shapes fail calls as `InvalidRequest` before object resolution. A well-shaped reference outside the target path's ancestor-or-self scope fails at the membership check as `InterfaceMismatch`.
+1 -1
View File
@@ -1,4 +1,4 @@
//! Host-side publication and operation dispatch for Web Interface Protocol objects.
//! Host-side publication and operation dispatch for Worldspace Interface Protocol objects.
//!
//! # Responsibility boundaries
//!
+1 -1
View File
@@ -6,7 +6,7 @@ The crate maps the three WIP Core interactions to fixed HTTP routes and strict U
```toml
[dependencies]
wip-http = "0.1.0"
wip-http = "0.2.0"
```
```rust
+2 -2
View File
@@ -1,12 +1,12 @@
# wip-interface
Public Rust authoring API for Web Interface Protocol (WIP) interfaces and application DTO codecs.
Public Rust authoring API for Worldspace Interface Protocol (WIP) interfaces and application DTO codecs.
The crate re-exports `#[interface]` and `#[derive(WipType)]`, generates Interface Descriptors and typed dispatch companions, and converts application-owned Rust values to and from the transport-independent value model.
```toml
[dependencies]
wip-interface = "0.1.0"
wip-interface = "0.2.0"
```
```rust
+1 -1
View File
@@ -1,4 +1,4 @@
//! Public authoring API for Web Interface Protocol (WIP) interfaces.
//! Public authoring API for Worldspace Interface Protocol (WIP) interfaces.
//!
//! Add `wip-interface` to an application and import it under the short name
//! used by the attribute examples:
+3 -3
View File
@@ -1,12 +1,12 @@
# wip-protocol
Transport-independent logical types and validation for the Web Interface Protocol (WIP).
Transport-independent logical types and validation for the Worldspace Interface Protocol (WIP).
This crate defines the canonical Object and Interface Descriptor models, the three Core interaction request and response types, schema-neutral operation `Value`s, validators, protocol errors, and structural validation. It does not define an HTTP mapping, application DTOs, persistence, or network I/O.
```toml
[dependencies]
wip-protocol = "0.1.0"
wip-protocol = "0.2.0"
```
Interface identity is an exact `InterfaceReference { scope, name }` pair.
@@ -16,7 +16,7 @@ and Object/interaction validation at response boundaries. Fetching needs no
target Object path. Fetch responses and interface call targets carry optional
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
for the breaking Rust and HTTP wire changes and deferred consumer semantics.
for the breaking Rust and HTTP wire changes and consumer migration guidance.
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
+1 -1
View File
@@ -1,4 +1,4 @@
//! Canonical transport-independent logical types for the Web Interface Protocol.
//! Canonical transport-independent logical types for the Worldspace Interface Protocol.
//!
//! This crate follows the Core Model and `3-protocol.md` without assigning HTTP
//! routes, methods, headers, URL encodings, or JSON envelopes. The correspondence
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "wip-text-view"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "Standalone Object and Interface Text View signatures for WIP"
license.workspace = true
repository.workspace = true
readme = "README.md"
documentation = "https://docs.rs/wip-text-view"
[dependencies]
wip-protocol.workspace = true
+193
View File
@@ -0,0 +1,193 @@
# wip-text-view
Standalone Object and Interface signature rendering for WIP Text View, following
`4.2-text-view.md` at reference revision `6086f3c` and the Protocol semantics in
`3-protocol.md`. Only `wip-protocol` and the Rust standard library are dependencies.
No Client, Host, HTTP I/O, cache, UI framework or retrieval service is required.
```toml
[dependencies]
wip-text-view = "0.2.0"
wip-protocol = "0.2.0"
```
## Single-entity API
```rust
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
use wip_text_view::{Interface, render_object, render_interface};
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
let object = Object {
name: "item".into(), description: None, interfaces: vec![reference.clone()],
r#ref: None, validator: None,
};
let object_signature = render_object(&object)?;
assert_eq!(object_signature,
"object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
let descriptor = InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(), documentation: None,
types: vec![], operations: vec![],
};
let interface = Interface { reference: &reference, descriptor: &descriptor };
let interface_signature = render_interface(&interface)?;
assert_eq!(interface_signature, "interface \"/::example\" {\n}\n");
# Ok::<(), wip_text_view::RenderError>(())
```
`Interface<'a>` is one borrowed SDK input entity containing a complete structured
reference and its corresponding descriptor, not a wire schema extension. The
caller pairs them from a coherent observation or publication. The descriptor has
no identity, so a renderer cannot prove the pairing's provenance or freshness.
For example, a caller with a validated `FetchInterfaceResponse` can borrow its
`interface` and `descriptor`; it does not pass `scope_ref` or validators.
An Object has no placement path. This API always emits `object { ... }`, never
requests a path and never infers one from name or ref. It requires no descriptor.
An Interface needs no Object, separate name/scope/context arguments or lookup.
There is no document aggregator, configuration or details selector.
## Typed display tokens
```rust
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
use wip_text_view::{
Interface, TokenKind, tokenize_object, tokenize_interface, to_plain_text,
render_object, render_interface,
};
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
let object = Object {
name: "item".into(), description: None, interfaces: vec![reference.clone()],
r#ref: None, validator: None,
};
let object_tokens = tokenize_object(&object)?;
assert_eq!(object_tokens[0].kind(), TokenKind::Keyword);
assert_eq!(object_tokens[0].text(), "object");
assert_eq!(to_plain_text(&object_tokens), render_object(&object)?);
let descriptor = InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(), documentation: None,
types: vec![], operations: vec![],
};
let input = Interface { reference: &reference, descriptor: &descriptor };
let tokens = tokenize_interface(&input)?;
for token in &tokens {
// Custom renderers can select presentation by role without reparsing text.
if token.kind() == TokenKind::Reference {
assert_eq!(token.text(), "\"/::example\"");
}
}
let joined: String = tokens.iter().map(|token| token.text()).collect();
assert_eq!(to_plain_text(&tokens), joined);
assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
# Ok::<(), wip_text_view::RenderError>(())
```
`tokenize_object` / `tokenize_interface` return `Result<Vec<Token>, RenderError>`.
They generate **directly from the input structure**, not by lexing a completed
Text View string. Each token owns its text (and can outlive the input) and exposes
`kind()` / `text()`. Existing render APIs use the same token generation followed
by `to_plain_text(&[Token])`; there is no second grammar/escape/layout engine.
### Kind and splitting contract
| Kind | One token contains |
| --- | --- |
| `Keyword` | One grammar keyword, Object property label, or primitive type keyword |
| `TypeName` | One type declaration name or Named type reference |
| `OperationName` | One Operation declaration name |
| `ParameterName` / `FieldName` | One parameter / record field name |
| `CaseName` | One enum or union case name |
| `String` | One complete quoted Object name or description |
| `Reference` | One complete quoted absolute Interface reference, with both quoting layers |
| `Documentation` | Entire `/// "summary"` content, including marker and internal space, excluding indentation/LF |
| `Symbol` | One punctuation mark, except the indivisible two-character `->` arrow |
| `Whitespace` | One inter-token ASCII space |
| `Newline` | One LF, including the final LF |
| `Indentation` | All leading spaces of a non-root line, two spaces per depth level |
Quoted identifiers retain their semantic role even when named like keywords.
Quotes, backticks and all WIP escapes are part of the data token, never separate
symbols. Host spaces inside quoted data remain in that token. Adjacent symbols
are separate; no empty tokens or zero-depth indentation tokens are emitted.
`TokenKind` is non-exhaustive so consumers should handle future roles with a
fallback. The API does not distinguish type declaration names from Named uses,
or enum cases from union cases; both share the documented role.
All layout is included. Concatenate token text **without separators**, not with
`join(" ")`. `to_plain_text` is only that concatenation: no interpretation,
validation, quoting, escaping or layout completion. An empty slice yields `""`;
a filtered/reordered slice is allowed but need not be a complete signature.
Token text is escaped for **WIP Text View**, not for HTML or other destinations.
Custom renderers must escape for their destination and treat Host text as data,
not markup or instructions. This crate supplies no HTML, ANSI, color or theme
renderer/adapter and no external text lexer/parser or AST.
## Output and validation
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
Markdown fences, syntax highlighting or provider envelopes. Nonempty composites
and parameter lists use multiple lines; layout is deterministic here, but not a
byte-canonical protocol format.
- All Object references remain in Host order. All types, then all operations,
fields, parameters and cases remain in declaration order, without elision.
- Missing vs empty description/documentation, optional vs unit, no payload vs
unit payload, and empty record/enum/union/declaration sets remain distinct.
- Full absolute references use component quoting **and** outer JSON-string quoting.
Identifiers use keywords/backtick rules. All Host strings use standard JSON
escapes plus DEL/C1, Unicode line/paragraph separator and bidi-control escapes.
No normalization, case conversion, URL encoding or input mutation occurs.
- Documentation summaries are displayed in their prescribed positions, including
return documentation between `)` and `->`. Details are not displayed. Host text
is data, not renderer instructions or markup; quoting alone is not a prompt
injection defense. AI adapters must preserve the trust boundary.
- Object ref/validator, children, domain data and Interface scope_ref/validator
are not signature content. No annotations are inferred from operation names.
The render functions return `Result<String, RenderError>`; token generation
returns `Result<Vec<Token>, RenderError>`. Neither returns partial signatures. Resource checks happen first, followed by existing Protocol validators:
Object shape and reference uniqueness, or Interface reference shape plus descriptor
structure, duplicate names, unknown Named references and cycles. Descriptor format
is checked after semantics: valid but unsupported formats return
`UnsupportedDescriptorFormat`, distinct from `InvalidDescriptor`. Supported format
is exactly `wip-interface/1`. Other validation errors preserve the Protocol's typed
`ValidationError` (also exposed as the error source).
Bounded input limits are **1 MiB** of total string/opaque byte data, **16,384**
declarations/fields/parameters/cases/type expressions/references, **256** named type
declarations, and **64** TypeExpr nesting levels. Details and Object runtime
metadata count toward input bytes even though they are not rendered. A limit
returns `RenderError::ResourceLimit`, never truncates or supplies `...`. Counts
bound allocation and recursive validation before those validators run; these are
library safety limits, not Protocol restrictions.
Without path/context, this library cannot attest Object scope ancestry, placement,
observation consistency, identity, membership, authorization or currentness.
Callers validate those at their observation boundary. Callers also own retrieval,
resolution, cache, selection, multiple-signature arrangement and AI/UI adaptation.
Text is display-only, not serialization, a digest, identity/cache key or call
target. No parser or round-trip API is supplied.
## Validation
`tests/signatures.rs` is an external consumer of the public APIs. Goldens cover
the spec's Object, issue.read, CloseResult, search/return documentation and
identifier examples (with permitted multiline layout), plus nested documentation.
The suite checks all TypeExpr variants/positions, ordering and empty distinctions,
keyword and Unicode names, both reference quoting layers, all required control
escapes, input immutability, metadata exclusion and typed rejection cases. Every
existing case compares the render result (including exact errors) with token
text concatenation and `to_plain_text`. Additional tests assert complete Object
token splitting, all semantic roles, keyword collisions, atomic escaped data,
all documentation positions, layout tokens and ownership beyond input lifetime.
Input budgets bound token counts per node, indentation depth and escape expansion;
token generation checks them before any rendering/validation traversal.
```console
cargo test -p wip-text-view
cargo clippy -p wip-text-view --all-targets -- -D warnings
RUSTDOCFLAGS='-D warnings' cargo doc -p wip-text-view --no-deps
```
+221
View File
@@ -0,0 +1,221 @@
//! Standalone, single-entity WIP Text View signatures.
//!
//! [`render_object`] accepts just an [`Object`], and [`render_interface`] accepts
//! just an [`Interface`] (a reference paired with its corresponding descriptor).
//! No Client, Host, HTTP, UI, retrieval, cache, path or display context is needed.
//! The caller supplies a coherent observation; descriptor identity cannot be
//! inferred from descriptor contents. No protocol wire fields are added.
//!
//! Output follows WIP `4.2-text-view.md` (reference revision `6086f3c`): UTF-8
//! plain text, LF, two-space indentation, all declarations in original order,
//! and documentation summaries only. There is no truncation or details setting.
//! Object paths are neither requested nor guessed: output uses `object { ... }`.
//! Runtime metadata is never rendered. Host documentation is untrusted data,
//! not markup or instructions; quoting does not by itself prevent prompt injection.
//!
//! Each call checks resource limits, then Protocol shape/semantic validation.
//! Interface rendering additionally requires [`wip_protocol::INTERFACE_FORMAT_V1`].
//! Limits reject the entire input, never produce partial text: at most 1 MiB of
//! input string/byte data, 16,384 declarations/fields/cases/type expressions or
//! references, 256 named type declarations (bounding validator graph recursion),
//! and 64 nested TypeExpr levels. These are implementation limits, not new
//! Protocol semantics. An Object without placement context can only be checked
//! for name/reference shape and duplicate references; the caller must validate
//! scope ancestry and observation consistency at its observation boundary.
//!
//! Signatures are display-only: no parser, round-trip, identity comparison,
//! cache key or call-target reconstruction is provided. The caller owns fetching,
//! freshness, authorization, selection and arrangement of multiple signatures.
//!
//! ```
//! use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
//! use wip_text_view::{Interface, render_object, render_interface};
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
//! let object = Object {
//! name: "item".into(), description: None, interfaces: vec![reference.clone()],
//! r#ref: Some("not a path".into()), validator: Some(vec![1]),
//! };
//! assert_eq!(render_object(&object)?,
//! "object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
//! let descriptor = InterfaceDescriptor {
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
//! types: vec![], operations: vec![],
//! };
//! let interface = Interface { reference: &reference, descriptor: &descriptor };
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
//! # Ok::<(), wip_text_view::RenderError>(())
//! ```
//!
//! # Typed display tokens
//!
//! [`tokenize_object`] and [`tokenize_interface`] traverse the input structure
//! directly and return owned [`Token`] sequences, including all layout. Existing
//! render APIs use this same generation path followed by [`to_plain_text`]. The
//! input limits above also bound token allocation: tokens per node, indentation
//! depth and escape expansion are bounded; no partial signature is returned.
//! Token text is WIP-escaped, **not HTML-escaped** or escaped for other destinations.
//! See [`Token`] for the semantic splitting contract; no renderer/theme is supplied.
//!
//! ```
//! use wip_protocol::{InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
//! use wip_text_view::{Interface, TokenKind, tokenize_interface, to_plain_text, render_interface};
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
//! let descriptor = InterfaceDescriptor {
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
//! types: vec![], operations: vec![],
//! };
//! let input = Interface { reference: &reference, descriptor: &descriptor };
//! let tokens = tokenize_interface(&input)?;
//! assert_eq!(tokens[0].kind(), TokenKind::Keyword);
//! assert_eq!(tokens[0].text(), "interface");
//! let concatenated: String = tokens.iter().map(|token| token.text()).collect();
//! assert_eq!(to_plain_text(&tokens), concatenated);
//! assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
//! # Ok::<(), wip_text_view::RenderError>(())
//! ```
#![deny(missing_docs)]
mod limits;
mod render;
mod token;
pub use token::{Token, TokenKind, to_plain_text};
use std::{error::Error, fmt};
use wip_protocol::{
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, Object, ValidationError,
};
/// One Interface input entity, borrowing its identity and corresponding data.
///
/// The caller pairs values from the same observation/publication. The descriptor
/// itself has no identity, so rendering cannot verify provenance or freshness.
/// This is an SDK display input, not a replacement wire schema or a document.
#[derive(Debug, Clone, Copy)]
pub struct Interface<'a> {
/// Complete, absolute, Client-facing Interface reference.
pub reference: &'a InterfaceReference,
/// Descriptor corresponding to `reference`.
pub descriptor: &'a InterfaceDescriptor,
}
/// Failure to render a complete, validated signature. No partial text is returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderError {
/// Object-independent Protocol validation failed.
InvalidObject(ValidationError),
/// The Interface's structured reference is malformed.
InvalidReference(ValidationError),
/// Descriptor structure or semantics failed Protocol validation.
InvalidDescriptor(ValidationError),
/// A semantically valid descriptor uses a format this renderer cannot interpret.
UnsupportedDescriptorFormat {
/// Exact unsupported format identifier.
format: String,
},
/// Input exceeds an implementation safety limit, without truncation.
ResourceLimit {
/// Limit that was exceeded.
limit: ResourceLimit,
},
}
/// Bounded input dimensions checked before recursive Protocol validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceLimit {
/// Total input string and opaque byte length exceeds 1 MiB.
InputBytes,
/// Total declarations, fields, parameters, cases, expressions or references exceeds 16,384.
Nodes,
/// More than 256 named type declarations.
TypeDeclarations,
/// More than 64 nested TypeExpr levels (outer expression is level one).
TypeDepth,
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidObject(e) => write!(f, "invalid Object: {e}"),
Self::InvalidReference(e) => write!(f, "invalid Interface reference: {e}"),
Self::InvalidDescriptor(e) => write!(f, "invalid Interface descriptor: {e}"),
Self::UnsupportedDescriptorFormat { format } => {
write!(f, "unsupported descriptor format: {format:?}")
}
Self::ResourceLimit { limit } => write!(f, "Text View input exceeds {limit:?} limit"),
}
}
}
impl Error for RenderError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidObject(e) | Self::InvalidReference(e) | Self::InvalidDescriptor(e) => {
Some(e)
}
_ => None,
}
}
}
/// Renders one complete Object signature, with no placement path or inferred data.
///
/// Validates shape and ordered-set uniqueness, but cannot validate ancestry or
/// root/non-root placement without context. Missing and empty descriptions remain
/// distinct; ref and validator bytes are ignored for display. Input is unmodified.
/// See [crate documentation](crate) for limits and caller responsibilities.
pub fn render_object(object: &Object) -> Result<String, RenderError> {
tokenize_object(object).map(|tokens| to_plain_text(&tokens))
}
/// Generates the complete owned display token sequence for one Object signature.
///
/// Uses the same validation, limits, metadata exclusion and caller responsibilities
/// as [`render_object`]. No partial sequence is returned on error. Input is not
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
pub fn tokenize_object(object: &Object) -> Result<Vec<Token>, RenderError> {
limits::object(object)?;
object.validate().map_err(RenderError::InvalidObject)?;
Ok(render::object(object))
}
/// Renders one complete Interface signature from one paired input entity.
///
/// Validates reference shape and all descriptor declarations/named references,
/// including cycles, before checking format support. No lookup or Object is
/// needed. All types precede all operations, with original member order; only
/// documentation summaries are shown, without interpreting Host text.
/// Input is unmodified. See [crate documentation](crate) for safety limits.
pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError> {
tokenize_interface(interface).map(|tokens| to_plain_text(&tokens))
}
/// Generates the complete owned display token sequence for one Interface signature.
///
/// Uses the same validation, limits, format support and summary-only documentation
/// as [`render_interface`]. No partial sequence is returned on error. Input is not
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
/// This traverses structured declarations directly, not a rendered-text lexer.
pub fn tokenize_interface(interface: &Interface<'_>) -> Result<Vec<Token>, RenderError> {
limits::interface(interface)?;
interface
.reference
.validate()
.map_err(RenderError::InvalidReference)?;
interface
.descriptor
.validate()
.map_err(RenderError::InvalidDescriptor)?;
if interface.descriptor.format != INTERFACE_FORMAT_V1 {
return Err(RenderError::UnsupportedDescriptorFormat {
format: interface.descriptor.format.clone(),
});
}
Ok(render::interface(interface))
}
// Keep both README examples executable as external-consumer doctests.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
mod readme {}
+125
View File
@@ -0,0 +1,125 @@
use wip_protocol::{Documentation, InterfaceReference, Object, TypeExpr};
use crate::{Interface, RenderError, ResourceLimit};
#[derive(Default)]
struct Budget {
bytes: usize,
nodes: usize,
}
fn check(value: usize, max: usize, limit: ResourceLimit) -> Result<(), RenderError> {
if value > max {
Err(RenderError::ResourceLimit { limit })
} else {
Ok(())
}
}
impl Budget {
fn bytes(&mut self, len: usize) -> Result<(), RenderError> {
self.bytes = self.bytes.saturating_add(len);
check(self.bytes, 1024 * 1024, ResourceLimit::InputBytes)
}
fn nodes(&mut self, len: usize) -> Result<(), RenderError> {
self.nodes = self.nodes.saturating_add(len);
check(self.nodes, 16_384, ResourceLimit::Nodes)
}
fn reference(&mut self, reference: &InterfaceReference) -> Result<(), RenderError> {
self.nodes(1)?;
self.bytes(reference.scope.len())?;
self.bytes(reference.name.len())
}
fn doc(&mut self, doc: &Option<Documentation>) -> Result<(), RenderError> {
if let Some(doc) = doc {
self.bytes(doc.summary.len())?;
self.bytes(doc.details.as_ref().map_or(0, String::len))?;
}
Ok(())
}
fn declaration(&mut self, name: &str, doc: &Option<Documentation>) -> Result<(), RenderError> {
self.nodes(1)?;
self.bytes(name.len())?;
self.doc(doc)
}
fn expr(&mut self, expr: &TypeExpr, depth: usize) -> Result<(), RenderError> {
check(depth, 64, ResourceLimit::TypeDepth)?;
self.nodes(1)?;
match expr {
TypeExpr::Named { name } => self.bytes(name.len())?,
TypeExpr::List { items } => self.expr(items, depth + 1)?,
TypeExpr::Record { fields } => {
self.nodes(fields.len())?;
for field in fields {
self.bytes(field.name.len())?;
self.doc(&field.documentation)?;
self.expr(&field.r#type, depth + 1)?;
}
}
TypeExpr::Enum { cases } => {
self.nodes(cases.len())?;
for case in cases {
self.bytes(case.name.len())?;
self.doc(&case.documentation)?;
}
}
TypeExpr::Union { cases } => {
self.nodes(cases.len())?;
for case in cases {
self.bytes(case.name.len())?;
self.doc(&case.documentation)?;
if let Some(payload) = &case.payload {
self.expr(payload, depth + 1)?;
}
}
}
_ => {}
}
Ok(())
}
}
pub(crate) fn object(object: &Object) -> Result<(), RenderError> {
let mut budget = Budget::default();
budget.bytes(object.name.len())?;
budget.bytes(object.description.as_ref().map_or(0, String::len))?;
budget.bytes(object.r#ref.as_ref().map_or(0, String::len))?;
budget.bytes(object.validator.as_ref().map_or(0, Vec::len))?;
check(object.interfaces.len(), 16_384, ResourceLimit::Nodes)?;
for reference in &object.interfaces {
budget.reference(reference)?;
}
Ok(())
}
pub(crate) fn interface(interface: &Interface<'_>) -> Result<(), RenderError> {
let mut budget = Budget::default();
let descriptor = interface.descriptor;
budget.reference(interface.reference)?;
budget.bytes(descriptor.format.len())?;
budget.doc(&descriptor.documentation)?;
check(descriptor.types.len(), 256, ResourceLimit::TypeDeclarations)?;
// Check collection counts before traversing even empty names/expressions.
check(descriptor.operations.len(), 16_384, ResourceLimit::Nodes)?;
for ty in &descriptor.types {
budget.declaration(&ty.name, &ty.documentation)?;
budget.expr(&ty.definition, 1)?;
}
for operation in &descriptor.operations {
budget.declaration(&operation.name, &operation.documentation)?;
check(operation.parameters.len(), 16_384, ResourceLimit::Nodes)?;
for parameter in &operation.parameters {
budget.declaration(&parameter.name, &parameter.documentation)?;
budget.expr(&parameter.r#type, 1)?;
}
budget.nodes(1)?; // Return declaration.
budget.doc(&operation.returns.documentation)?;
budget.expr(&operation.returns.r#type, 1)?;
}
Ok(())
}
+334
View File
@@ -0,0 +1,334 @@
use std::fmt::Write as _;
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
use crate::{Interface, Token, TokenKind};
use TokenKind::{
CaseName, FieldName, Indentation, Keyword, Newline, OperationName, ParameterName, Reference,
Symbol, TypeName, Whitespace,
};
// Quoting a component and quoting the complete reference are separate layers.
fn reference(reference: &InterfaceReference) -> String {
format!(
"{}::{}",
component(&reference.scope, true),
component(&reference.name, false)
)
}
fn component(value: &str, scope: bool) -> String {
if !value.is_empty()
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/'))
{
value.into()
} else {
quote(value, '"')
}
}
fn identifier(value: &str) -> String {
const KEYWORDS: &[&str] = &[
"object",
"at",
"name",
"description",
"interfaces",
"interface",
"type",
"operation",
"enum",
"union",
"unit",
"boolean",
"integer",
"number",
"string",
"bytes",
"json",
"entry",
];
if value
.bytes()
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !KEYWORDS.contains(&value)
{
value.into()
} else {
quote(value, '`')
}
}
fn quote(value: &str, delimiter: char) -> String {
let mut output = String::new();
output.push(delimiter);
for ch in value.chars() {
match ch {
'`' if delimiter == '`' => output.push_str("\\`"),
'\\' => output.push_str("\\\\"),
'"' => output.push_str("\\\""),
'\u{08}' => output.push_str("\\b"),
'\u{0c}' => output.push_str("\\f"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
'\u{00}'..='\u{1f}'
| '\u{7f}'..='\u{9f}'
| '\u{2028}'
| '\u{2029}'
| '\u{061c}'
| '\u{200e}'..='\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}' => {
write!(output, "\\u{:04x}", ch as u32).expect("String write");
}
_ => output.push(ch),
}
}
output.push(delimiter);
output
}
pub(crate) fn object(object: &Object) -> Vec<Token> {
let mut writer = Writer(Vec::new());
writer.word(Keyword, "object");
writer.space();
writer.symbol("{");
writer.newline();
writer.indent(1);
writer.word(Keyword, "name");
writer.symbol(":");
writer.space();
writer.word(TokenKind::String, quote(&object.name, '"'));
writer.symbol(";");
writer.newline();
if let Some(description) = &object.description {
writer.indent(1);
writer.word(Keyword, "description");
writer.symbol(":");
writer.space();
writer.word(TokenKind::String, quote(description, '"'));
writer.symbol(";");
writer.newline();
}
writer.indent(1);
writer.word(Keyword, "interfaces");
writer.symbol(":");
writer.space();
writer.symbol("[");
for (index, interface) in object.interfaces.iter().enumerate() {
if index > 0 {
writer.symbol(",");
writer.space();
}
writer.word(Reference, quote(&reference(interface), '"'));
}
writer.symbol("]");
writer.symbol(";");
writer.newline();
writer.symbol("}");
writer.newline();
writer.0
}
pub(crate) fn interface(interface: &Interface<'_>) -> Vec<Token> {
let mut writer = Writer(Vec::new());
writer.doc(&interface.descriptor.documentation, 0);
writer.word(Keyword, "interface");
writer.space();
writer.word(Reference, quote(&reference(interface.reference), '"'));
writer.space();
writer.symbol("{");
writer.newline();
for declaration in &interface.descriptor.types {
writer.doc(&declaration.documentation, 1);
writer.indent(1);
writer.word(Keyword, "type");
writer.space();
writer.word(TypeName, identifier(&declaration.name));
writer.space();
writer.symbol("=");
writer.space();
writer.expr(&declaration.definition, 1);
writer.symbol(";");
writer.newline();
}
for operation in &interface.descriptor.operations {
writer.operation(operation);
}
writer.symbol("}");
writer.newline();
writer.0
}
struct Writer(Vec<Token>);
impl Writer {
fn word(&mut self, kind: TokenKind, text: impl Into<String>) {
self.0.push(Token {
kind,
text: text.into(),
});
}
fn symbol(&mut self, text: &str) {
self.word(Symbol, text);
}
fn space(&mut self) {
self.word(Whitespace, " ");
}
fn newline(&mut self) {
self.word(Newline, "\n");
}
fn indent(&mut self, depth: usize) {
if depth > 0 {
self.word(Indentation, " ".repeat(depth));
}
}
fn doc(&mut self, doc: &Option<Documentation>, depth: usize) {
if let Some(doc) = doc {
self.indent(depth);
self.word(
TokenKind::Documentation,
format!("/// {}", quote(&doc.summary, '"')),
);
self.newline();
}
}
fn member(&mut self, kind: TokenKind, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
self.indent(depth);
self.word(kind, identifier(name));
if !required {
self.symbol("?");
}
self.symbol(":");
self.space();
self.expr(ty, depth);
self.symbol(",");
self.newline();
}
fn operation(&mut self, operation: &OperationDeclaration) {
self.doc(&operation.documentation, 1);
self.indent(1);
self.word(Keyword, "operation");
self.space();
self.word(OperationName, identifier(&operation.name));
self.symbol("(");
if !operation.parameters.is_empty() {
self.newline();
for parameter in &operation.parameters {
self.doc(&parameter.documentation, 2);
self.member(
ParameterName,
&parameter.name,
parameter.required,
&parameter.r#type,
2,
);
}
self.indent(1);
}
self.symbol(")");
if operation.returns.documentation.is_some() {
self.newline();
self.doc(&operation.returns.documentation, 1);
self.indent(1);
} else {
self.space();
}
self.symbol("->");
self.space();
self.expr(&operation.returns.r#type, 1);
self.symbol(";");
self.newline();
}
// Starts at the current cursor. Composite closing braces align with the
// declaration/field/parameter containing this expression, not its width.
fn expr(&mut self, expr: &TypeExpr, depth: usize) {
match expr {
TypeExpr::Unit => self.word(Keyword, "unit"),
TypeExpr::Boolean => self.word(Keyword, "boolean"),
TypeExpr::Integer => self.word(Keyword, "integer"),
TypeExpr::Number => self.word(Keyword, "number"),
TypeExpr::String => self.word(Keyword, "string"),
TypeExpr::Bytes => self.word(Keyword, "bytes"),
TypeExpr::Json => self.word(Keyword, "json"),
TypeExpr::Entry => self.word(Keyword, "entry"),
TypeExpr::Named { name } => self.word(TypeName, identifier(name)),
TypeExpr::List { items } => {
self.symbol("[");
self.expr(items, depth);
self.symbol("]");
}
TypeExpr::Record { fields } => {
self.symbol("{");
if !fields.is_empty() {
self.newline();
for field in fields {
self.doc(&field.documentation, depth + 1);
self.member(
FieldName,
&field.name,
field.required,
&field.r#type,
depth + 1,
);
}
self.indent(depth);
}
self.symbol("}");
}
TypeExpr::Enum { cases } => {
self.word(Keyword, "enum");
self.space();
self.symbol("{");
if !cases.is_empty() {
self.newline();
for case in cases {
self.doc(&case.documentation, depth + 1);
self.indent(depth + 1);
self.word(CaseName, identifier(&case.name));
self.symbol(",");
self.newline();
}
self.indent(depth);
}
self.symbol("}");
}
TypeExpr::Union { cases } => {
self.word(Keyword, "union");
self.space();
self.symbol("{");
if !cases.is_empty() {
self.newline();
for case in cases {
self.doc(&case.documentation, depth + 1);
self.indent(depth + 1);
self.word(CaseName, identifier(&case.name));
if let Some(payload) = &case.payload {
self.symbol("(");
self.expr(payload, depth + 1);
self.symbol(")");
}
self.symbol(",");
self.newline();
}
self.indent(depth);
}
self.symbol("}");
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Owned display data, independent of the Protocol wire model.
/// Semantic display role assigned from the input structure, not from text parsing.
///
/// Identifiers retain their role even when quoted or identical to a keyword.
/// Type declarations and Named expressions both use [`Self::TypeName`]. Enum and
/// union cases both use [`Self::CaseName`]. See [`Token`] for splitting rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TokenKind {
/// Grammar keyword, including Object property labels and primitive types.
Keyword,
/// Named type declaration or reference within the same Interface.
TypeName,
/// Operation declaration name (not an invocation target).
OperationName,
/// Operation parameter name.
ParameterName,
/// Record field name.
FieldName,
/// Enum or union case name.
CaseName,
/// Quoted Object name or description.
String,
/// Entire quoted absolute Interface reference, including both quoting layers.
Reference,
/// Entire `/// "summary"` line content, excluding indentation and LF.
Documentation,
/// One punctuation mark, or the complete two-character return arrow `->`.
Symbol,
/// One inter-token ASCII space, never indentation or text inside quotes.
Whitespace,
/// One LF (`\n`), including the final signature LF.
Newline,
/// All leading spaces on a non-root line: two ASCII spaces per depth level.
Indentation,
}
/// One owned, already escaped fragment of a Text View signature.
///
/// Each keyword, identifier, quoted string and complete quoted reference is one
/// token, including its delimiters and escapes. Documentation is one token for
/// the `///` marker, space and quoted summary together. Symbols are individual
/// punctuation marks except `->`, which is indivisible. Spaces between tokens,
/// each LF, and each nonzero line indentation have separate tokens. No empty
/// tokens are emitted; an empty value still has its quotes. Adjacent symbols are
/// not merged. Host whitespace inside quoted data stays inside that data token.
///
/// The generation APIs emit all layout: concatenating [`Self::text`] in order,
/// without separators, produces exactly the existing plain-text signature.
/// Tokens own their text and can outlive the input; they are display data, not
/// an AST, lexer output, Protocol serialization or identity/call-target data.
///
/// Text is escaped for **WIP Text View**, not for HTML or any other destination.
/// Custom renderers must apply destination-specific escaping and must not treat
/// Host documentation as markup or instructions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub(crate) kind: TokenKind,
pub(crate) text: String,
}
impl Token {
/// Returns the semantic display role, without reparsing the output text.
pub fn kind(&self) -> TokenKind {
self.kind
}
/// Returns the exact output fragment, with WIP quoting and escapes applied.
pub fn text(&self) -> &str {
&self.text
}
}
/// Concatenates token text in order, with no separators or further processing.
///
/// This performs no grammar interpretation, validation, quoting, escaping or
/// layout completion. An empty slice yields an empty string. Filtering or
/// reordering tokens is allowed, but need not produce a complete signature.
/// Object/Interface generation limits are checked by [`crate::tokenize_object`]
/// and [`crate::tokenize_interface`], not by this concatenation function.
pub fn to_plain_text(tokens: &[Token]) -> String {
tokens.iter().map(Token::text).collect()
}
@@ -0,0 +1,8 @@
interface "/::example" {
type `string` = {
`表示名`: string,
};
operation `find-item`(
`search term`: string,
) -> `string`;
}
@@ -0,0 +1,22 @@
/// "Issueの内容と関連項目を読む。"
interface "/::issue.read" {
type Relation = enum {
parent,
sibling,
related,
};
type RelatedItems = {
/// "同じWorldspace内の関連項目。"
items: [entry],
next_cursor?: string,
};
/// "本文を取得する。"
operation read_body() -> {
body: string,
};
/// "関係を指定して関連項目を取得する。"
operation get_related_items(
relation: Relation,
limit?: integer,
) -> RelatedItems;
}
@@ -0,0 +1,25 @@
interface "/::example" {
/// "type summary"
type Z = {
/// "field summary"
z?: unit,
a: [union {
/// "case summary"
z,
/// ""
a(unit),
nested(enum {
/// "enum summary"
z,
a,
}),
}],
};
operation z(
z: unit,
a?: unit,
) -> unit;
operation a()
/// "no params result"
-> unit;
}
@@ -0,0 +1,5 @@
object {
name: "123";
description: "Issue 123の公開操作。本文はOperationで取得する。";
interfaces: ["/::issue.read", "/::issue.lifecycle"];
}
@@ -0,0 +1,18 @@
interface "/::example" {
type CloseResult = union {
closed,
blocked({
dependencies: [entry],
}),
};
operation refresh() -> unit;
/// "対象を検索する。"
operation search(
/// "検索語。"
query: string,
)
/// "条件に一致する項目。"
-> {
items: [entry],
};
}
File diff suppressed because it is too large Load Diff