feat(client): add stateful WIP runtime

This commit is contained in:
2026-09-30 08:46:12 +09:00
parent 96db3dd6a8
commit 2da2a28a67
6 changed files with 2481 additions and 4 deletions
Generated
+8
View File
@@ -456,6 +456,14 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "wip-client"
version = "0.1.0"
dependencies = [
"wip-https",
"wip-protocol",
]
[[package]]
name = "wip-host"
version = "0.1.0"
+8 -4
View File
@@ -4,10 +4,10 @@ Rust building blocks for the Web Interface Protocol (WIP).
The repository is a resolver-3 virtual Cargo workspace whose packages all live
under `crates/*`. It provides a transport-independent protocol model, the
standard framework-independent WIP over HTTPS v1 binding, a Rust
interface-authoring facade, a request-scoped host runtime, and a private
public-API conformance consumer. The repository root is not itself a package.
standard framework-independent WIP over HTTPS v1 binding, a stateful
presentation-independent client runtime, a Rust interface-authoring facade, a
request-scoped host runtime, and a private public-API conformance consumer. The
repository root is not itself a package.
## Crate graph and responsibilities
```text
@@ -15,6 +15,9 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching
│ └── wip-protocol
├── wip-client stateful Known Space, lazy tree, observation, and call runtime
│ ├── wip-https
│ └── wip-protocol
├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol
│ └── wip-interface-macros proc-macro implementation detail
@@ -27,6 +30,7 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
| --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, lazy entry trees, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
| `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, and interface-qualified dispatch against one host-owned instance. | Backend atomicity, global object lookup, or network serving. |
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "wip-client"
version = "0.1.0"
edition = "2024"
description = "Presentation-independent stateful WIP client runtime"
license = "MIT OR Apache-2.0"
[dependencies]
wip-https = { path = "../wip-https" }
wip-protocol = { path = "../wip-protocol" }
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
use std::collections::BTreeMap;
use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
ObservationState, OutcomeUnknownReason, SecurityContext,
};
use wip_https::http::{Response, StatusCode};
use wip_https::{
Limits, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response,
};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError,
ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
};
fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap()
}
fn setup() -> (Client, wip_client::SessionId) {
let mut client = Client::new(ClientLimits::new(2, 16, 16, 16, 16).unwrap(), limits());
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
(client, session)
}
fn response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
Response::builder()
.status(status)
.header("content-type", "application/json; charset=utf-8")
.body(body.to_vec())
.unwrap()
}
fn object() -> Object {
Object {
name: "item".into(),
description: None,
interfaces: vec!["math".into()],
r#ref: None,
validator: Some(b"object-v1".to_vec()),
}
}
fn descriptor(format: &str) -> InterfaceDescriptor {
InterfaceDescriptor {
format: format.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "calculate".into(),
documentation: None,
parameters: vec![ParameterDeclaration {
name: "value".into(),
required: true,
documentation: None,
r#type: TypeExpr::Integer,
}],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Integer,
},
}],
}
}
#[test]
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
let cases: &[&[u8]] = &[
b"{not json",
br#"{"name":"item","interfaces":[],"unknown":true}"#,
br#"{"name":"item","interfaces":["math","math"]}"#,
br#"{"name":"wrong","interfaces":[]}"#,
];
for body in cases {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let error = client
.complete(request.id, response(StatusCode::OK, body))
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
assert!(matches!(
client.object(&session, "/item").unwrap().state,
ObservationState::Error(_)
));
}
}
#[test]
fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
let cases = [
(
StatusCode::NOT_FOUND,
br#"{"error":{"code":"interface_not_found","message":"wrong"}}"#.as_slice(),
),
(
StatusCode::OK,
br#"{"error":{"code":"not_found","message":"wrong status"}}"#.as_slice(),
),
(
StatusCode::CREATED,
br#"{"name":"item","interfaces":[]}"#.as_slice(),
),
];
for (status, body) in cases {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
assert_eq!(
client
.complete(request.id, response(status, body))
.unwrap_err()
.kind,
ClientErrorKind::InvalidResponse
);
}
}
#[test]
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let error = ProtocolError {
code: ProtocolErrorCode::NotFound,
message: "missing".into(),
};
let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!(
client.complete(request.id, encoded).unwrap(),
Completion::ProtocolFailure(error)
);
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client.diagnostics(&session).unwrap().back().unwrap().kind,
DiagnosticKind::HttpStatusMismatch
);
}
#[test]
fn non_success_without_valid_envelope_remains_transport_failure() {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let error = client
.complete(
request.id,
response(StatusCode::BAD_GATEWAY, b"gateway html"),
)
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Transport);
}
#[test]
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
let (mut client, session) = setup();
let request = client.prepare_interface(&session, "math").unwrap();
let logical = FetchInterfaceRequest {
interface: "math".into(),
};
let value = FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor("future-format/2"),
validator: Some(b"v2".to_vec()),
};
let encoded = encode_fetch_interface_response(&logical, &value, limits()).unwrap();
let error = client.complete(request.id, encoded).unwrap_err();
assert_eq!(error.kind, ClientErrorKind::UnsupportedDescriptorFormat);
}
#[test]
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
let (mut client, session) = setup();
let fetch = client.prepare_fetch(&session, "/item").unwrap();
client
.complete(
fetch.id,
encode_fetch_response(
&FetchRequest {
path: "/item".into(),
},
&object(),
limits(),
)
.unwrap(),
)
.unwrap();
let interface = client.prepare_interface(&session, "math").unwrap();
client
.complete(
interface.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: "math".into(),
},
&FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(INTERFACE_FORMAT_V1),
validator: None,
},
limits(),
)
.unwrap(),
)
.unwrap();
let call = client
.prepare_call(
&session,
"/item",
"math",
"calculate",
BTreeMap::from([("value".into(), Value::Integer(2))]),
)
.unwrap();
client.mark_dispatched(call.id).unwrap();
let completion = client
.complete(
call.id,
response(
StatusCode::OK,
br#"{"result":"not an integer","unknown":true}"#,
),
)
.unwrap();
assert!(matches!(
completion,
Completion::Call(record)
if matches!(
&record.outcome,
CallOutcome::Unknown {
reason: OutcomeUnknownReason::ResponseDecode,
failure: Some(failure),
} if failure.kind == ClientErrorKind::InvalidResponse
)
));
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(client.in_flight_len(), 0);
}
+517
View File
@@ -0,0 +1,517 @@
use std::collections::BTreeMap;
use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure,
ObservationState, OutcomeUnknownReason, SecurityContext,
};
use wip_https::http::Response;
use wip_https::{
Limits, encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response,
encode_fetch_tree_response, encode_protocol_error_response,
};
use wip_protocol::{
CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest,
FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
};
fn wire_limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap()
}
fn client(objects: usize, interfaces: usize, in_flight: usize, history: usize) -> Client {
Client::new(
ClientLimits::new(8, objects, interfaces, in_flight, history).unwrap(),
wire_limits(),
)
}
fn object(name: &str, reference: Option<&str>, validator: &[u8]) -> Object {
Object {
name: name.into(),
description: Some(format!("object {name}")),
interfaces: vec!["math".into()],
r#ref: reference.map(str::to_owned),
validator: Some(validator.to_vec()),
}
}
fn descriptor(return_type: TypeExpr) -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "calculate".into(),
documentation: None,
parameters: vec![ParameterDeclaration {
name: "value".into(),
required: true,
documentation: None,
r#type: TypeExpr::Integer,
}],
returns: ReturnDeclaration {
documentation: None,
r#type: return_type,
},
}],
}
}
fn fetch_response(path: &str, value: &Object) -> Response<Vec<u8>> {
encode_fetch_response(&FetchRequest { path: path.into() }, value, wire_limits()).unwrap()
}
fn observe_object(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
let request = client.prepare_fetch(session, path).unwrap();
client
.complete(request.id, fetch_response(path, &value))
.unwrap();
}
fn observe_interface(
client: &mut Client,
session: &wip_client::SessionId,
value: FetchInterfaceResponse,
) {
let request = client
.prepare_interface(session, value.interface.clone())
.unwrap();
let response = encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: value.interface.clone(),
},
&value,
wire_limits(),
)
.unwrap();
client.complete(request.id, response).unwrap();
}
fn args(value: i64) -> BTreeMap<String, Value> {
BTreeMap::from([("value".into(), Value::Integer(value))])
}
#[test]
fn sessions_partition_known_space_by_canonical_endpoint_and_security_context() {
let mut client = client(8, 8, 8, 8);
let alice = client
.open_session(
"https://example.test/wip",
SecurityContext::new("alice-token"),
)
.unwrap();
let alice_canonical = client
.open_session(
"https://example.test/wip/",
SecurityContext::new("alice-token"),
)
.unwrap();
let bob = client
.open_session(
"https://example.test/wip",
SecurityContext::new("bob-token"),
)
.unwrap();
let other = client
.open_session(
"https://other.test/wip",
SecurityContext::new("alice-token"),
)
.unwrap();
assert_eq!(alice, alice_canonical);
observe_object(&mut client, &alice, "/item", object("item", None, b"a"));
assert!(client.object(&alice, "/item").is_some());
assert!(client.object(&bob, "/item").is_none());
assert!(client.object(&other, "/item").is_none());
}
#[test]
fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let old = client.prepare_fetch(&session, "/item").unwrap();
let new = client.prepare_fetch(&session, "/item").unwrap();
assert_eq!(
client
.complete(
old.id,
fetch_response("/item", &object("item", None, b"old"))
)
.unwrap(),
Completion::StaleResponseRejected(old.id)
);
assert!(matches!(
client.object(&session, "/item").unwrap().state,
ObservationState::Loading(id) if id == new.id
));
client
.complete(
new.id,
fetch_response("/item", &object("item", None, b"new")),
)
.unwrap();
assert_eq!(
client.object(&session, "/item").unwrap().validator,
Some(b"new".to_vec())
);
}
#[test]
fn normalized_object_cache_rejects_conflicts_and_evicts_to_its_bound() {
let mut client = client(2, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(
&mut client,
&session,
"/a/shared",
object("shared", Some("shared-ref"), b"v1"),
);
observe_object(
&mut client,
&session,
"/b/shared",
object("shared", Some("shared-ref"), b"v1"),
);
let conflict = client.prepare_fetch(&session, "/b/shared").unwrap();
let mut changed = object("shared", Some("shared-ref"), b"v1");
changed.description = Some("contradiction".into());
let error = client
.complete(conflict.id, fetch_response("/b/shared", &changed))
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
observe_object(
&mut client,
&session,
"/second",
object("second", None, b"2"),
);
observe_object(&mut client, &session, "/third", object("third", None, b"3"));
assert!(client.object(&session, "/a/shared").is_none());
assert!(client.object(&session, "/second").is_some());
assert!(client.object(&session, "/third").is_some());
}
#[test]
fn tree_expansion_is_lazy_truncated_and_refreshes_edges() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
assert!(client.tree(&session, "/").is_none());
let request = client.prepare_tree(&session, "/", 1, 4).unwrap();
let logical_request = FetchTreeRequest {
path: "/".into(),
depth: 1,
limit: 4,
};
let tree = FetchTreeResponse {
root: ObjectTreeNode {
object: object("", None, b"root"),
children: vec![ObjectTreeNode {
object: object("items", None, b"items"),
children: vec![],
}],
},
truncated: true,
};
client
.complete(
request.id,
encode_fetch_tree_response(&logical_request, &tree, wire_limits()).unwrap(),
)
.unwrap();
assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]);
assert!(client.tree(&session, "/").unwrap().truncated);
assert!(client.object(&session, "/items").is_some());
let refresh = client.prepare_tree(&session, "/", 1, 4).unwrap();
let refreshed = FetchTreeResponse {
root: ObjectTreeNode {
object: object("", None, b"root-2"),
children: vec![],
},
truncated: false,
};
client
.complete(
refresh.id,
encode_fetch_tree_response(&logical_request, &refreshed, wire_limits()).unwrap(),
)
.unwrap();
assert!(client.tree(&session, "/").unwrap().children.is_empty());
assert!(!client.tree(&session, "/").unwrap().truncated);
}
#[test]
fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(
&mut client,
&session,
"/item",
object("item", Some("item-ref"), b"object-v1"),
);
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: Some(b"interface-v1".to_vec()),
},
);
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(7))
.unwrap();
assert_eq!(
client
.pending_call_context(call.id)
.unwrap()
.operation()
.name,
"calculate"
);
client.mark_dispatched(call.id).unwrap();
// A newer interface observation cannot change this in-flight call's schema.
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::String),
validator: Some(b"interface-v2".to_vec()),
},
);
let history_context = match client
.complete(
call.id,
encode_call_operation_response(
&wip_protocol::CallOperationRequest {
target: wip_protocol::Target {
path: "/item".into(),
validator: Some(b"object-v1".to_vec()),
},
interface: wip_protocol::InterfaceTarget {
reference: "math".into(),
validator: Some(b"interface-v1".to_vec()),
},
operation: "calculate".into(),
arguments: args(7),
},
&descriptor(TypeExpr::Integer),
&CallOperationResponse {
result: Value::Integer(14),
validator: Some(b"object-v2".to_vec()),
},
wire_limits(),
)
.unwrap(),
)
.unwrap()
{
Completion::Call(record) => record,
other => panic!("unexpected completion: {other:?}"),
};
assert_eq!(
history_context.context.descriptor(),
&descriptor(TypeExpr::Integer)
);
assert_eq!(
history_context.context.object().validator,
Some(b"object-v1".to_vec())
);
assert_eq!(
history_context.context.interface().validator,
Some(b"interface-v1".to_vec())
);
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
assert_eq!(
client.interface(&session, "math").unwrap().descriptor,
Some(descriptor(TypeExpr::String))
);
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Stale
);
}
#[test]
fn validator_mismatch_stales_only_its_observation_and_never_retries() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(&mut client, &session, "/item", object("item", None, b"v1"));
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: Some(b"i1".to_vec()),
},
);
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(1))
.unwrap();
client.mark_dispatched(call.id).unwrap();
let error = ProtocolError {
code: ProtocolErrorCode::InterfaceValidatorMismatch,
message: "stale descriptor".into(),
};
let response =
encode_protocol_error_response(ProtocolInteraction::CallOperation, &error, wire_limits())
.unwrap();
client.complete(call.id, response).unwrap();
assert_eq!(
client.interface(&session, "math").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Fresh
);
assert_eq!(client.in_flight_len(), 0);
assert_eq!(client.call_history(&session).unwrap().len(), 1);
// Reobservation changes cache state but does not manufacture a replacement call.
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: Some(b"i2".to_vec()),
},
);
assert_eq!(client.in_flight_len(), 0);
assert_eq!(client.call_history(&session).unwrap().len(), 1);
}
#[test]
fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(&mut client, &session, "/item", object("item", None, b"v1"));
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: None,
},
);
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(1))
.unwrap();
client.mark_dispatched(call.id).unwrap();
let unknown = ProtocolError {
code: ProtocolErrorCode::OperationOutcomeUnknown,
message: "commit uncertain".into(),
};
let completion = client
.complete(
call.id,
encode_protocol_error_response(
ProtocolInteraction::CallOperation,
&unknown,
wire_limits(),
)
.unwrap(),
)
.unwrap();
assert!(matches!(
completion,
Completion::Call(record)
if matches!(
record.outcome,
CallOutcome::Unknown {
reason: OutcomeUnknownReason::Protocol,
..
}
)
));
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Stale
);
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
let second = client
.prepare_call(&session, "/item", "math", "calculate", args(2))
.unwrap();
client.mark_dispatched(second.id).unwrap();
let completion = client
.fail_dispatched_call(
second.id,
DispatchedTransportFailure::Timeout,
"deadline elapsed",
)
.unwrap();
assert!(matches!(
completion,
Completion::Call(record)
if matches!(
record.outcome,
CallOutcome::Unknown {
reason: OutcomeUnknownReason::Timeout,
..
}
)
));
assert_eq!(client.in_flight_len(), 0);
assert_eq!(client.call_history(&session).unwrap().len(), 2);
}
#[test]
fn in_flight_and_history_are_bounded() {
let mut client = client(8, 8, 1, 1);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let first = client.prepare_fetch(&session, "/one").unwrap();
let error = client.prepare_fetch(&session, "/two").unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Capacity);
client
.complete(first.id, fetch_response("/one", &object("one", None, b"1")))
.unwrap();
// Diagnostic history evicts oldest entries at the configured bound.
let stale_old = client.prepare_fetch(&session, "/one").unwrap();
let stale_new = client.prepare_fetch(&session, "/one").unwrap_err();
assert_eq!(stale_new.kind, ClientErrorKind::Capacity);
client
.complete(
stale_old.id,
Response::builder()
.status(200)
.header("content-type", "application/json")
.body(b"not json".to_vec())
.unwrap(),
)
.unwrap_err();
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
}