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
26 changed files with 2411 additions and 266 deletions
+8
View File
@@ -4,6 +4,14 @@ 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,
Generated
+10 -10
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",
@@ -1310,14 +1310,14 @@ dependencies = [
[[package]]
name = "wip-text-view"
version = "0.1.0"
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 -9
View File
@@ -3,18 +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-text-view = { version = "0.1.0", path = "crates/wip-text-view" }
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
+7 -6
View File
@@ -1,6 +1,6 @@
# 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
@@ -58,13 +58,13 @@ wip-public-api-example
| `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`
@@ -72,9 +72,10 @@ The `0.1.0` release consists of these six crates:
- `wip-host`
- `wip-http`
- `wip-client`
- `wip-text-view`
The unreleased workspace also adds [`wip-text-view`](crates/wip-text-view/README.md)
as a seventh public library. Its `render_object(&Object)` API needs no path;
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.
@@ -92,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.
+3 -3
View File
@@ -1,6 +1,6 @@
# Releasing
The seven public crates in the current workspace (including the unreleased `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.
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
@@ -71,8 +71,8 @@ Registry propagation can take time. A dependent dry-run reporting that a WIP cra
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
+93 -3
View File
@@ -5,6 +5,12 @@ Standalone Object and Interface signature rendering for WIP Text View, following
`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
@@ -42,6 +48,84 @@ 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,
@@ -63,8 +147,8 @@ There is no document aggregator, configuration or details selector.
- Object ref/validator, children, domain data and Interface scope_ref/validator
are not signature content. No annotations are inferred from operation names.
Both functions return `Result<String, RenderError>` and never return partial
signatures. Resource checks happen first, followed by existing Protocol validators:
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
@@ -94,7 +178,13 @@ 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.
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
+55
View File
@@ -45,11 +45,42 @@
//! 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::{
@@ -135,6 +166,15 @@ impl Error for RenderError {
/// 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))
@@ -148,6 +188,16 @@ pub fn render_object(object: &Object) -> Result<String, RenderError> {
/// 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
@@ -164,3 +214,8 @@ pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError
}
Ok(render::interface(interface))
}
// Keep both README examples executable as external-consumer doctests.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
mod readme {}
+149 -64
View File
@@ -1,7 +1,11 @@
use std::fmt::Write as _;
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
use crate::Interface;
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 {
@@ -90,159 +94,240 @@ fn quote(value: &str, delimiter: char) -> String {
output
}
pub(crate) fn object(object: &Object) -> String {
let mut output = format!("object {{\n name: {};\n", quote(&object.name, '"'));
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 {
writeln!(output, " description: {};", quote(description, '"')).expect("String write");
writer.indent(1);
writer.word(Keyword, "description");
writer.symbol(":");
writer.space();
writer.word(TokenKind::String, quote(description, '"'));
writer.symbol(";");
writer.newline();
}
output.push_str(" interfaces: [");
writer.indent(1);
writer.word(Keyword, "interfaces");
writer.symbol(":");
writer.space();
writer.symbol("[");
for (index, interface) in object.interfaces.iter().enumerate() {
if index > 0 {
output.push_str(", ");
writer.symbol(",");
writer.space();
}
output.push_str(&quote(&reference(interface), '"'));
writer.word(Reference, quote(&reference(interface), '"'));
}
output.push_str("];\n}\n");
output
writer.symbol("]");
writer.symbol(";");
writer.newline();
writer.symbol("}");
writer.newline();
writer.0
}
pub(crate) fn interface(interface: &Interface<'_>) -> String {
let mut writer = Writer(String::new());
pub(crate) fn interface(interface: &Interface<'_>) -> Vec<Token> {
let mut writer = Writer(Vec::new());
writer.doc(&interface.descriptor.documentation, 0);
writeln!(
writer.0,
"interface {} {{",
quote(&reference(interface.reference), '"')
)
.expect("String write");
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);
write!(writer.0, "type {} = ", identifier(&declaration.name)).expect("String write");
writer.word(Keyword, "type");
writer.space();
writer.word(TypeName, identifier(&declaration.name));
writer.space();
writer.symbol("=");
writer.space();
writer.expr(&declaration.definition, 1);
writer.0.push_str(";\n");
writer.symbol(";");
writer.newline();
}
for operation in &interface.descriptor.operations {
writer.operation(operation);
}
writer.0.push_str("}\n");
writer.symbol("}");
writer.newline();
writer.0
}
struct Writer(String);
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) {
for _ in 0..depth {
self.0.push_str(" ");
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);
writeln!(self.0, "/// {}", quote(&doc.summary, '"')).expect("String write");
self.word(
TokenKind::Documentation,
format!("/// {}", quote(&doc.summary, '"')),
);
self.newline();
}
}
fn member(&mut self, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
fn member(&mut self, kind: TokenKind, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
self.indent(depth);
self.0.push_str(&identifier(name));
self.word(kind, identifier(name));
if !required {
self.0.push('?');
self.symbol("?");
}
self.0.push_str(": ");
self.symbol(":");
self.space();
self.expr(ty, depth);
self.0.push_str(",\n");
self.symbol(",");
self.newline();
}
fn operation(&mut self, operation: &OperationDeclaration) {
self.doc(&operation.documentation, 1);
self.indent(1);
write!(self.0, "operation {}(", identifier(&operation.name)).expect("String write");
self.word(Keyword, "operation");
self.space();
self.word(OperationName, identifier(&operation.name));
self.symbol("(");
if !operation.parameters.is_empty() {
self.0.push('\n');
self.newline();
for parameter in &operation.parameters {
self.doc(&parameter.documentation, 2);
self.member(&parameter.name, parameter.required, &parameter.r#type, 2);
self.member(
ParameterName,
&parameter.name,
parameter.required,
&parameter.r#type,
2,
);
}
self.indent(1);
}
self.0.push(')');
self.symbol(")");
if operation.returns.documentation.is_some() {
self.0.push('\n');
self.newline();
self.doc(&operation.returns.documentation, 1);
self.indent(1);
self.0.push_str("-> ");
} else {
self.0.push_str(" -> ");
self.space();
}
self.symbol("->");
self.space();
self.expr(&operation.returns.r#type, 1);
self.0.push_str(";\n");
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.0.push_str("unit"),
TypeExpr::Boolean => self.0.push_str("boolean"),
TypeExpr::Integer => self.0.push_str("integer"),
TypeExpr::Number => self.0.push_str("number"),
TypeExpr::String => self.0.push_str("string"),
TypeExpr::Bytes => self.0.push_str("bytes"),
TypeExpr::Json => self.0.push_str("json"),
TypeExpr::Entry => self.0.push_str("entry"),
TypeExpr::Named { name } => self.0.push_str(&identifier(name)),
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.0.push('[');
self.symbol("[");
self.expr(items, depth);
self.0.push(']');
self.symbol("]");
}
TypeExpr::Record { fields } => {
self.0.push('{');
self.symbol("{");
if !fields.is_empty() {
self.0.push('\n');
self.newline();
for field in fields {
self.doc(&field.documentation, depth + 1);
self.member(&field.name, field.required, &field.r#type, depth + 1);
self.member(
FieldName,
&field.name,
field.required,
&field.r#type,
depth + 1,
);
}
self.indent(depth);
}
self.0.push('}');
self.symbol("}");
}
TypeExpr::Enum { cases } => {
self.0.push_str("enum {");
self.word(Keyword, "enum");
self.space();
self.symbol("{");
if !cases.is_empty() {
self.0.push('\n');
self.newline();
for case in cases {
self.doc(&case.documentation, depth + 1);
self.indent(depth + 1);
self.0.push_str(&identifier(&case.name));
self.0.push_str(",\n");
self.word(CaseName, identifier(&case.name));
self.symbol(",");
self.newline();
}
self.indent(depth);
}
self.0.push('}');
self.symbol("}");
}
TypeExpr::Union { cases } => {
self.0.push_str("union {");
self.word(Keyword, "union");
self.space();
self.symbol("{");
if !cases.is_empty() {
self.0.push('\n');
self.newline();
for case in cases {
self.doc(&case.documentation, depth + 1);
self.indent(depth + 1);
self.0.push_str(&identifier(&case.name));
self.word(CaseName, identifier(&case.name));
if let Some(payload) = &case.payload {
self.0.push('(');
self.symbol("(");
self.expr(payload, depth + 1);
self.0.push(')');
self.symbol(")");
}
self.0.push_str(",\n");
self.symbol(",");
self.newline();
}
self.indent(depth);
}
self.0.push('}');
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()
}
+252 -1
View File
@@ -1,6 +1,32 @@
use std::error::Error;
use wip_protocol::*;
use wip_text_view::{Interface, RenderError, ResourceLimit, render_interface, render_object};
use wip_text_view::{
Interface, RenderError, ResourceLimit, Token, TokenKind, to_plain_text, tokenize_interface,
tokenize_object,
};
// Run every existing golden/escape/error/limit case through both public paths.
fn equivalent(tokens: Result<Vec<Token>, RenderError>, direct: &Result<String, RenderError>) {
let plain = tokens.map(|tokens| {
assert!(tokens.iter().all(|token| !token.text().is_empty()));
let joined: String = tokens.iter().map(Token::text).collect();
assert_eq!(to_plain_text(&tokens), joined);
joined
});
assert_eq!(&plain, direct);
}
fn render_object(object: &Object) -> Result<String, RenderError> {
let direct = wip_text_view::render_object(object);
equivalent(tokenize_object(object), &direct);
direct
}
fn render_interface(input: &Interface<'_>) -> Result<String, RenderError> {
let direct = wip_text_view::render_interface(input);
equivalent(tokenize_interface(input), &direct);
direct
}
fn reference(scope: &str, name: &str) -> InterfaceReference {
InterfaceReference {
@@ -803,3 +829,228 @@ fn input_limits_reject_without_truncation_and_allow_boundary() {
})
);
}
fn parts(tokens: &[Token]) -> Vec<(TokenKind, &str)> {
tokens
.iter()
.map(|token| (token.kind(), token.text()))
.collect()
}
#[test]
fn object_token_splitting_is_explicit_and_owned() {
use TokenKind::*;
let tokens = {
let object = Object {
name: "name".into(),
description: Some("".into()),
interfaces: vec![reference("/開発::x", "issue#read")],
r#ref: Some("SECRET".into()),
validator: Some(b"SECRET".to_vec()),
};
tokenize_object(&object).unwrap()
}; // Tokens are usable after the input has been dropped.
assert_eq!(
parts(&tokens),
vec![
(Keyword, "object"),
(Whitespace, " "),
(Symbol, "{"),
(Newline, "\n"),
(Indentation, " "),
(Keyword, "name"),
(Symbol, ":"),
(Whitespace, " "),
(String, "\"name\""),
(Symbol, ";"),
(Newline, "\n"),
(Indentation, " "),
(Keyword, "description"),
(Symbol, ":"),
(Whitespace, " "),
(String, "\"\""),
(Symbol, ";"),
(Newline, "\n"),
(Indentation, " "),
(Keyword, "interfaces"),
(Symbol, ":"),
(Whitespace, " "),
(Symbol, "["),
(Reference, "\"\\\"/開発::x\\\"::\\\"issue#read\\\"\""),
(Symbol, "]"),
(Symbol, ";"),
(Newline, "\n"),
(Symbol, "}"),
(Newline, "\n"),
]
);
assert_eq!(to_plain_text(&[]), "");
assert_eq!(to_plain_text(&tokens[0..1]), "object"); // No layout completion.
assert!(!to_plain_text(&tokens).contains("SECRET"));
}
#[test]
fn semantic_roles_do_not_depend_on_lexical_spelling() {
use TokenKind::*;
let mut d = descriptor();
d.documentation = doc("interface\n<untrusted> 日本語");
d.types = vec![
TypeDeclaration {
documentation: doc("type"),
..ty(
"string",
TypeExpr::Record {
fields: vec![FieldDeclaration {
documentation: doc("field"),
..field(
"string",
false,
TypeExpr::Enum {
cases: vec![EnumCase {
name: "string".into(),
documentation: doc("enum case"),
}],
},
)
}],
},
)
},
ty(
"U",
TypeExpr::Union {
cases: vec![UnionCase {
name: "string".into(),
documentation: doc("union case"),
payload: Some(TypeExpr::Unit),
}],
},
),
];
d.operations = vec![OperationDeclaration {
documentation: doc("operation"),
returns: ReturnDeclaration {
documentation: doc("return"),
r#type: named("string"),
},
..operation(
"string",
vec![ParameterDeclaration {
documentation: doc("parameter"),
..parameter("string", false, TypeExpr::String)
}],
TypeExpr::Unit,
)
}];
let r = reference("/", "example");
let input = Interface {
reference: &r,
descriptor: &d,
};
let before = d.clone();
let tokens = tokenize_interface(&input).unwrap();
let p = parts(&tokens);
for kind in [TypeName, OperationName, ParameterName, FieldName, CaseName] {
assert!(p.contains(&(kind, "`string`")), "{kind:?}");
}
assert!(p.contains(&(Keyword, "string")));
assert_eq!(
p.iter()
.filter(|(k, t)| *k == TypeName && *t == "`string`")
.count(),
2
);
assert_eq!(p.iter().filter(|(k, _)| *k == CaseName).count(), 2);
let docs: Vec<_> = p
.iter()
.filter(|(k, _)| *k == Documentation)
.map(|(_, t)| *t)
.collect();
assert_eq!(
docs,
vec![
"/// \"interface\\n<untrusted> 日本語\"",
"/// \"type\"",
"/// \"field\"",
"/// \"enum case\"",
"/// \"union case\"",
"/// \"operation\"",
"/// \"parameter\"",
"/// \"return\"",
]
);
assert!(
p.windows(2)
.any(|w| w == [(Indentation, " "), (Symbol, "->")])
);
assert!(
p.windows(3)
.any(|w| w == [(FieldName, "`string`"), (Symbol, "?"), (Symbol, ":")])
);
assert!(p.contains(&(Indentation, " ")));
for token in &tokens {
match token.kind() {
Whitespace => assert_eq!(token.text(), " "),
Newline => assert_eq!(token.text(), "\n"),
Indentation => {
assert!(token.text().bytes().all(|b| b == b' '));
assert_eq!(token.text().len() % 2, 0);
}
Symbol => assert!(
["{", "}", "[", "]", "(", ")", ":", ";", ",", "?", "=", "->"]
.contains(&token.text())
),
_ => assert!(!token.text().contains('\n')),
}
}
assert_eq!(to_plain_text(&tokens), render_interface(&input).unwrap());
assert_eq!(d, before);
}
#[test]
fn escaped_identifiers_strings_docs_and_references_stay_atomic() {
use TokenKind::*;
let name = "日本語::#`\\\"\n\t\u{001b}\u{202e}";
let expected = "`日本語::#\\`\\\\\\\"\\n\\t\\u001b\\u202e`";
let mut d = descriptor();
d.documentation = doc(name);
d.types = vec![ty(name, TypeExpr::Unit)];
d.operations = vec![operation(
name,
vec![parameter(name, true, named(name))],
named(name),
)];
let r = reference("/a\"\\`::#", "日本語\"\\`::#\n");
let tokens = tokenize_interface(&Interface {
reference: &r,
descriptor: &d,
})
.unwrap();
let p = parts(&tokens);
for kind in [TypeName, OperationName, ParameterName] {
assert!(p.contains(&(kind, expected)));
}
assert!(p.contains(&(
Documentation,
"/// \"日本語::#`\\\\\\\"\\n\\t\\u001b\\u202e\""
)));
let reference_text = r#""\"/a\\\"\\\\`::#\"::\"日本語\\\"\\\\`::#\\n\"""#;
assert!(p.contains(&(Reference, reference_text)), "{p:?}");
let object = Object {
name: name.into(),
description: Some(name.into()),
interfaces: vec![r],
r#ref: None,
validator: None,
};
let tokens = tokenize_object(&object).unwrap();
assert_eq!(
tokens
.iter()
.filter(|t| t.kind() == String)
.map(Token::text)
.collect::<Vec<_>>(),
vec!["\"日本語::#`\\\\\\\"\\n\\t\\u001b\\u202e\""; 2]
);
assert!(parts(&tokens).contains(&(Reference, reference_text)));
}