Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
755e6aeb88 | ||
|
|
8ecc307fc9 |
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
All notable changes to the published WIP Rust crates are documented here.
|
All notable changes to the published WIP Rust crates are documented here.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||||
|
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||||
|
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||||
|
without changing routes, validators or the wire error-code set.
|
||||||
|
- Breaking Host registration migration to explicit Scope publication lifetimes and
|
||||||
|
local names, with coherent provider-attested scope Object binding for fetch and
|
||||||
|
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
|
||||||
|
retires retained tokens, while ordinary Object state updates preserve lifetime.
|
||||||
|
- Migrates FS root publication and public-example self-scopes to the Host API,
|
||||||
|
including coherent backend observations and explicit built-in names. Non-root
|
||||||
|
FS semantics and Client cache recovery remain follow-up work. See
|
||||||
|
[MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
Initial public release of:
|
Initial public release of:
|
||||||
|
|||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
# Structured Interface reference and scope publication migration
|
||||||
|
|
||||||
|
This unreleased 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
|
||||||
|
not accept legacy strings or display shorthand as Protocol references.
|
||||||
|
|
||||||
|
## Rust API
|
||||||
|
|
||||||
|
`Object.interfaces` is now `Vec<InterfaceReference>`.
|
||||||
|
`FetchInterfaceRequest.interface`, `FetchInterfaceResponse.interface`, and
|
||||||
|
`InterfaceTarget.reference` all use the same record:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::{FetchInterfaceRequest, InterfaceReference, InterfaceTarget};
|
||||||
|
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: "/github".into(),
|
||||||
|
name: "issue.read".into(),
|
||||||
|
};
|
||||||
|
reference.validate()?; // reference shape, no target Object path needed
|
||||||
|
let fetch = FetchInterfaceRequest { interface: reference.clone() };
|
||||||
|
let target = InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref: Some("opaque-scope-object-ref".into()),
|
||||||
|
validator: None,
|
||||||
|
};
|
||||||
|
# Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Construct scope and name explicitly from your Worldspace publication context.
|
||||||
|
There is no implicit conversion from a string, delimiter parser, normalization,
|
||||||
|
case folding, or URL decoding. A name may contain `/`, `.`, `::`, `#`, or Unicode;
|
||||||
|
only an empty name is invalid. Scope must be a canonical absolute Worldspace
|
||||||
|
path: `/`, or nonempty slash-separated segments other than `.` and `..`, with
|
||||||
|
no empty segments or trailing slash. `%2F` is ordinary field text, not an escape
|
||||||
|
for a Worldspace path separator.
|
||||||
|
|
||||||
|
Equality, hashing and ordering operate on both fields, independent of member
|
||||||
|
order/JSON escape spelling. Use the record itself as a membership/cache key;
|
||||||
|
identical names in different scopes are different references. The descriptor
|
||||||
|
still contains only format, documentation, types and operations, not identity.
|
||||||
|
|
||||||
|
`FetchInterfaceResponse` and `InterfaceTarget` additionally require the Rust
|
||||||
|
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||||
|
not an invented identity. This is an opaque scope Object ref observation/call
|
||||||
|
precondition, **not** a third Interface identity component or a validator.
|
||||||
|
Host now enforces scope publication lifetimes and optional scope-ref identity
|
||||||
|
inside the provider boundary (T-21). Client cache-recovery semantics remain
|
||||||
|
follow-up work (T-22); do not mistake registry artifact retention for publication.
|
||||||
|
|
||||||
|
## Validation boundaries
|
||||||
|
|
||||||
|
- `InterfaceReference::validate()` and `FetchInterfaceRequest::validate()`
|
||||||
|
check reference shape independently of a target Object path.
|
||||||
|
- `Object::validate()` checks shape and duplicate `(scope, name)` pairs;
|
||||||
|
`Object::validate_at_path()` additionally checks placement, including scope.
|
||||||
|
`ObserveRequest::validate_response()` checks every derived node path,
|
||||||
|
including the depth boundary. Invalid success responses are Client-local
|
||||||
|
`InvalidResponse`, not Host-returned Protocol errors.
|
||||||
|
- `InterfaceReference::validate_for_path(path)` checks ancestor-or-self scope
|
||||||
|
compatibility by path segments. Root and self scope are valid; `/a` is not
|
||||||
|
an ancestor of `/ab`. This has no indexability dependency.
|
||||||
|
- `CallOperationRequest::validate()` checks descriptor-independent shape.
|
||||||
|
`validate_with()` adds operation/argument checks but does not replace Host
|
||||||
|
publication lookup. Host interface selection checks scope compatibility and
|
||||||
|
exact membership **after** target lookup: invalid reference shape is
|
||||||
|
`InvalidRequest`, but a valid reference outside the target's scope is
|
||||||
|
`InterfaceMismatch`, with `NotFound` retaining precedence.
|
||||||
|
|
||||||
|
## HTTP wire
|
||||||
|
|
||||||
|
All four reference positions use an exact object with required string fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"scope":"/github","name":"issue.read"}
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"interface": {"scope":"/github","name":"issue.read"},
|
||||||
|
"scope_ref": "opaque-scope-object-ref",
|
||||||
|
"descriptor": {"format":"wip-interface/1","types":[],"operations":[]}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A call uses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target": {"path":"/github/issues/123"},
|
||||||
|
"interface": {
|
||||||
|
"reference": {"scope":"/github","name":"issue.read"},
|
||||||
|
"scope_ref": "opaque-scope-object-ref"
|
||||||
|
},
|
||||||
|
"operation":"refresh",
|
||||||
|
"arguments":{}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown/duplicate members, missing fields, null fields, relative scopes, empty
|
||||||
|
names, legacy `"interface-v1"` and shorthand `"/github::issue.read"` strings
|
||||||
|
are rejected. Field order and JSON escaping do not affect decoded identity.
|
||||||
|
`scope_ref` is a normal opaque string, **not Base64**; omission is allowed but
|
||||||
|
explicit null and nonstrings are invalid. An empty string is a valid opaque
|
||||||
|
ref. Validators remain canonical padded standard Base64.
|
||||||
|
|
||||||
|
Descriptor-independent HTTP call metadata decoding enforces reference shape
|
||||||
|
and the same strict JSON rules without turning scope compatibility into early
|
||||||
|
`InvalidRequest`. Normal descriptor-bound encode/decode uses the same reference
|
||||||
|
codec. Request violations retain the existing `400 invalid_request` boundary;
|
||||||
|
malformed success responses retain Client-local `InvalidResponse`. Standard
|
||||||
|
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||||
|
mapping and Transport failure classification are unchanged.
|
||||||
|
|
||||||
|
## Host registration and provider migration
|
||||||
|
|
||||||
|
Registration now requires an explicit canonical Scope and local name:
|
||||||
|
|
||||||
|
```rust,ignore
|
||||||
|
let mut registry = wip_host::InterfaceRegistry::new();
|
||||||
|
let scope = wip_host::Scope::new("/service")?;
|
||||||
|
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;
|
||||||
|
|
||||||
|
// INSIDE the backend transaction/lock/stable-handle boundary:
|
||||||
|
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||||
|
let publication = wip_host::PublishedObject::new(current_target_object)
|
||||||
|
.expose(&api)?
|
||||||
|
.bind_scope(current);
|
||||||
|
```
|
||||||
|
|
||||||
|
Collision keys are exact `(scope, name)` coordinates, not Rust Definition TypeId.
|
||||||
|
The same Definition can be registered in different scopes or under different
|
||||||
|
names. Root, self and ancestor scopes are valid, but ancestor compatibility and
|
||||||
|
explicit target membership remain independent checks. Descriptors still have
|
||||||
|
no publication identity fields.
|
||||||
|
|
||||||
|
A Scope carries a private publication lifetime, not a generated Protocol ref.
|
||||||
|
Clone it only while the same scope Object remains published: ordinary Object
|
||||||
|
state/validator updates do not retire it. Deletion, replacement or movement
|
||||||
|
requires retiring the old lifetime with `registry.remove_scope(&scope)` and
|
||||||
|
creating a fresh Scope before republication, even at the same path or ref.
|
||||||
|
Retained registration tokens cannot fetch or start calls after retirement.
|
||||||
|
Ref-less replacement cannot be tracked by a Client identity precondition; refs
|
||||||
|
and scope_ref remain optional, not forcibly generated.
|
||||||
|
|
||||||
|
`ScopeObservation::new(&scope, &object)` is a provider attestation that this
|
||||||
|
lifetime currently belongs to the Object at the scope path. Construct and attach
|
||||||
|
it with `PublishedObject::bind_scope` inside the same coherent boundary as target
|
||||||
|
resolution, membership, Descriptor/validator selection, implementation and
|
||||||
|
backend handles, through dispatch and commit/rollback. A matching ref or an
|
||||||
|
independent scope prelookup is not sufficient. The scope Object need not expose
|
||||||
|
any interfaces. Providers own atomicity and external-writer limitations; the
|
||||||
|
runtime checks the attached lifetime and optional scope_ref, and validates both
|
||||||
|
arguments and results against the same frozen Descriptor. A supplied scope_ref
|
||||||
|
mismatch, including a missing current ref, is `InterfaceMismatch` at membership
|
||||||
|
precedence, after target existence and a supplied Object validator mismatch.
|
||||||
|
Omission applies no scope identity precondition.
|
||||||
|
|
||||||
|
Artifact-only `registry.fetch_interface(reference)` is removed. Prefer
|
||||||
|
`ObjectProvider::fetch_interface(&provider, ®istry, &reference)`, which resolves
|
||||||
|
the visible scope Object with `with_object` and uses its attached current binding.
|
||||||
|
Low-level `registry.fetch_interface(&reference, ¤t_observation)` requires
|
||||||
|
the same coherent current attestation, not a cached snapshot. Fetch returns
|
||||||
|
Descriptor, Interface validator and optional scope Object ref together; Clients
|
||||||
|
need not additionally fetch an ancestor Object. `registry.scopes()` may repeat
|
||||||
|
a handle for several names; do not treat it as a unique-scope iterator.
|
||||||
|
|
||||||
|
`PublishedObject::protocol_object()` is a path-free composition projection, not
|
||||||
|
publication authority. For current observations use `ObjectProvider::observe_object`
|
||||||
|
or `publication.protocol_object_at(path)` inside the coherent provider boundary;
|
||||||
|
the latter also validates compatible live scope associations and response shape.
|
||||||
|
|
||||||
|
## Downstream status
|
||||||
|
|
||||||
|
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
|
||||||
|
at its deployment-pinned root Scope. It binds that lifetime from the refreshed
|
||||||
|
retained root lineage inside the backend execution boundary for calls, generic
|
||||||
|
Object observations and fetch. A textual Linux root rename/replacement does not
|
||||||
|
substitute the pinned root Object. The root has no Object ref, so fetch omits
|
||||||
|
scope_ref; the private lifetime check still applies. Non-root FS registrations
|
||||||
|
fail closed pending broader FS/TUI semantics integration (T-23).
|
||||||
|
|
||||||
|
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
|
||||||
|
`/article-service`, fetches through its provider, and carries the observed scope
|
||||||
|
ref in calls. Client keys use structured values; scope deletion/replacement
|
||||||
|
cache recovery remains T-22. This Host change does not modify TUI formatting;
|
||||||
|
canonical Text View work is tracked separately in T-24. Interface authoring/macros remain
|
||||||
|
descriptor-only and need no identity fields or declaration changes.
|
||||||
@@ -79,6 +79,13 @@ maps standard routes/envelopes to `CallOperationRequest` and
|
|||||||
`CallOperationResponse` without placing transport-specific types in descriptors
|
`CallOperationResponse` without placing transport-specific types in descriptors
|
||||||
or application traits.
|
or application traits.
|
||||||
|
|
||||||
|
## Structured Interface references
|
||||||
|
|
||||||
|
The unreleased 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.
|
||||||
|
|
||||||
## End-to-end example
|
## End-to-end example
|
||||||
|
|
||||||
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
|
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
|
||||||
@@ -91,7 +98,7 @@ runs it. It demonstrates all of the following:
|
|||||||
2. two public traits declared with `#[wip::interface]`;
|
2. two public traits declared with `#[wip::interface]`;
|
||||||
3. a private host struct implementing both traits;
|
3. a private host struct implementing both traits;
|
||||||
4. explicit publication of both interfaces as an ordered set on one object;
|
4. explicit publication of both interfaces as an ordered set on one object;
|
||||||
5. separate observation of opaque interface references, descriptors, and
|
5. separate observation of structured interface references, descriptors, and
|
||||||
descriptor validators;
|
descriptor validators;
|
||||||
6. calls qualified by `target.path`, object validator, interface reference and
|
6. calls qualified by `target.path`, object validator, interface reference and
|
||||||
validator, operation, and schema-neutral `Value` arguments;
|
validator, operation, and schema-neutral `Value` arguments;
|
||||||
@@ -219,25 +226,44 @@ choose the ordered interface set for each object publication:
|
|||||||
|
|
||||||
```rust,ignore
|
```rust,ignore
|
||||||
let mut interfaces = wip_host::InterfaceRegistry::new();
|
let mut interfaces = wip_host::InterfaceRegistry::new();
|
||||||
let messenger = interfaces.register(MessengerDefinition, None)?;
|
let scope = wip_host::Scope::new("/service")?;
|
||||||
|
let messenger = interfaces.register(&scope, "messenger", MessengerDefinition, None)?;
|
||||||
|
|
||||||
|
// Inside the provider coherent backend boundary, not a stale scope prelookup:
|
||||||
|
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||||
|
|
||||||
let published = wip_host::PublishedObject::new(request_scoped_object)
|
let published = wip_host::PublishedObject::new(request_scoped_object)
|
||||||
.expose(&messenger)?;
|
.expose(&messenger)?
|
||||||
let protocol_object = published.protocol_object();
|
.bind_scope(current);
|
||||||
|
let protocol_object = published.protocol_object_at("/service/item")?;
|
||||||
```
|
```
|
||||||
|
|
||||||
`expose` compiles only when the object implements the generated definition's
|
`expose` compiles only when the object implements the generated definition's
|
||||||
interface trait. Implementing a trait does not expose it automatically, empty
|
interface trait. Implementing a trait does not expose it automatically, empty
|
||||||
interface sets are valid, insertion order is preserved, and duplicate
|
interface sets are valid, insertion order is preserved, and duplicate
|
||||||
references are rejected. Registry references are opaque and remain outside the
|
references are rejected. Registry collisions use exact `(scope, name)` pairs,
|
||||||
descriptor; generated descriptors contain neither those references nor Rust
|
not Rust Definition identity; the same Definition may be registered at multiple
|
||||||
trait identity.
|
coordinates. Structured references remain outside the Descriptor.
|
||||||
|
|
||||||
|
`Scope` carries a private Object publication lifetime. Keep it for ordinary state
|
||||||
|
updates; retire it with `remove_scope` and create a fresh Scope on deletion,
|
||||||
|
replacement or movement, even at the same path. Providers attach a current
|
||||||
|
`ScopeObservation` from the scope Object inside the coherent target/backend
|
||||||
|
boundary, never from a separate prelookup. The scope Object need not expose any
|
||||||
|
interfaces. Fetch uses `ObjectProvider::fetch_interface(&provider, ®istry,
|
||||||
|
&reference)` to observe the visible scope Object and return its optional ref,
|
||||||
|
Descriptor and Interface validator together; retained artifacts alone are not
|
||||||
|
publication. Calls require the current attached lifetime and check supplied
|
||||||
|
`scope_ref` at membership precedence; omission adds no identity precondition.
|
||||||
|
Refs, Object validators and Interface validators retain distinct responsibilities.
|
||||||
|
See [MIGRATION.md](MIGRATION.md) for the provider atomicity and lifecycle contract.
|
||||||
|
|
||||||
Dispatch is qualified by interface reference and operation name and targets a
|
Dispatch is qualified by interface reference and operation name and targets a
|
||||||
canonical Worldspace path. An `ObjectProvider` validates that path before
|
canonical Worldspace path. An `ObjectProvider` validates that path before
|
||||||
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
||||||
exactly once inside `with_object`; the host checks the supplied object validator,
|
exactly once inside `with_object`; the host checks the supplied object validator,
|
||||||
current interface membership, interface validator, descriptor operation,
|
current scope lifetime/optional scope_ref and interface membership, Interface
|
||||||
|
validator, Descriptor operation,
|
||||||
operation-specific object-validator policy, arguments, and authorization in
|
operation-specific object-validator policy, arguments, and authorization in
|
||||||
canonical precedence. Path and children remain provider resolution context and
|
canonical precedence. Path and children remain provider resolution context and
|
||||||
are never projected by `WipObject`; host-private fields and backend handles are
|
are never projected by `WipObject`; host-private fields and backend handles are
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ wip-client = "0.1.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.
|
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.
|
||||||
|
|
||||||
|
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Interface caches and Object membership compare the complete structured value, so equal names in different scopes remain distinct. Interface observations retain the optional `scope_ref`, and calls copy it into `InterfaceTarget` unchanged. Scope-identity recovery is not implemented by this mechanical migration.
|
||||||
|
|
||||||
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
||||||
|
|
||||||
Licensed under either of Apache-2.0 or MIT at your option.
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ use wip_http::{
|
|||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
||||||
InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
|
||||||
OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Monotonic identity assigned to one logical request.
|
/// Monotonic identity assigned to one logical request.
|
||||||
@@ -226,12 +226,14 @@ pub struct TreeObservation {
|
|||||||
/// Cached interface descriptor observation.
|
/// Cached interface descriptor observation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceObservation {
|
pub struct InterfaceObservation {
|
||||||
/// Opaque interface reference.
|
/// Structured interface reference.
|
||||||
pub reference: String,
|
pub reference: InterfaceReference,
|
||||||
/// Last accepted descriptor, if one has been received.
|
/// Last accepted descriptor, if one has been received.
|
||||||
pub descriptor: Option<InterfaceDescriptor>,
|
pub descriptor: Option<InterfaceDescriptor>,
|
||||||
/// Optional interface validator. Absence makes the reference immutable.
|
/// Optional interface validator. Absence makes the reference immutable.
|
||||||
pub validator: Option<Vec<u8>>,
|
pub validator: Option<Vec<u8>>,
|
||||||
|
/// Optional opaque identity of the interface scope Object.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Current observation lifecycle.
|
/// Current observation lifecycle.
|
||||||
pub state: ObservationState,
|
pub state: ObservationState,
|
||||||
}
|
}
|
||||||
@@ -404,7 +406,7 @@ struct Session {
|
|||||||
objects: BTreeMap<u64, ObjectRecord>,
|
objects: BTreeMap<u64, ObjectRecord>,
|
||||||
refs: BTreeMap<String, u64>,
|
refs: BTreeMap<String, u64>,
|
||||||
trees: BTreeMap<String, TreeObservation>,
|
trees: BTreeMap<String, TreeObservation>,
|
||||||
interfaces: BTreeMap<String, InterfaceRecord>,
|
interfaces: BTreeMap<InterfaceReference, InterfaceRecord>,
|
||||||
calls: VecDeque<CallRecord>,
|
calls: VecDeque<CallRecord>,
|
||||||
diagnostics: VecDeque<Diagnostic>,
|
diagnostics: VecDeque<Diagnostic>,
|
||||||
next_object_key: u64,
|
next_object_key: u64,
|
||||||
@@ -596,7 +598,11 @@ impl Client {
|
|||||||
|
|
||||||
/// Returns the current interface observation.
|
/// Returns the current interface observation.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn interface(&self, session: &SessionId, reference: &str) -> Option<&InterfaceObservation> {
|
pub fn interface(
|
||||||
|
&self,
|
||||||
|
session: &SessionId,
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Option<&InterfaceObservation> {
|
||||||
self.sessions
|
self.sessions
|
||||||
.get(session)?
|
.get(session)?
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -762,9 +768,8 @@ impl Client {
|
|||||||
pub fn ensure_interface(
|
pub fn ensure_interface(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: impl Into<String>,
|
reference: InterfaceReference,
|
||||||
) -> Result<Option<PreparedRequest>, ClientError> {
|
) -> Result<Option<PreparedRequest>, ClientError> {
|
||||||
let reference = reference.into();
|
|
||||||
let state = self.session(session)?;
|
let state = self.session(session)?;
|
||||||
if state.interfaces.contains_key(&reference)
|
if state.interfaces.contains_key(&reference)
|
||||||
|| state.interfaces.len() >= self.limits.interfaces_per_session
|
|| state.interfaces.len() >= self.limits.interfaces_per_session
|
||||||
@@ -782,11 +787,11 @@ impl Client {
|
|||||||
pub fn prepare_interface(
|
pub fn prepare_interface(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: impl Into<String>,
|
reference: InterfaceReference,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
self.ensure_capacity()?;
|
self.ensure_capacity()?;
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: reference.into(),
|
interface: reference,
|
||||||
};
|
};
|
||||||
let endpoint = session_endpoint(session)?;
|
let endpoint = session_endpoint(session)?;
|
||||||
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
||||||
@@ -804,6 +809,7 @@ impl Client {
|
|||||||
.or_insert(InterfaceRecord {
|
.or_insert(InterfaceRecord {
|
||||||
observation: InterfaceObservation {
|
observation: InterfaceObservation {
|
||||||
reference: request.interface.clone(),
|
reference: request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: None,
|
descriptor: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Loading(id),
|
state: ObservationState::Loading(id),
|
||||||
@@ -833,7 +839,7 @@ impl Client {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
path: &str,
|
path: &str,
|
||||||
interface: &str,
|
interface: &InterfaceReference,
|
||||||
operation: impl Into<String>,
|
operation: impl Into<String>,
|
||||||
arguments: BTreeMap<String, Value>,
|
arguments: BTreeMap<String, Value>,
|
||||||
) -> Result<PreparedRequest, ClientError> {
|
) -> Result<PreparedRequest, ClientError> {
|
||||||
@@ -900,7 +906,8 @@ impl Client {
|
|||||||
validator: object.validator.clone(),
|
validator: object.validator.clone(),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: interface.to_owned(),
|
reference: interface.clone(),
|
||||||
|
scope_ref: interface_observation.scope_ref.clone(),
|
||||||
validator: interface_observation.validator.clone(),
|
validator: interface_observation.validator.clone(),
|
||||||
},
|
},
|
||||||
operation,
|
operation,
|
||||||
@@ -1182,6 +1189,7 @@ impl Client {
|
|||||||
let touched = state.tick();
|
let touched = state.tick();
|
||||||
let observation = InterfaceObservation {
|
let observation = InterfaceObservation {
|
||||||
reference: value.interface,
|
reference: value.interface,
|
||||||
|
scope_ref: value.scope_ref,
|
||||||
descriptor: Some(value.descriptor),
|
descriptor: Some(value.descriptor),
|
||||||
validator: value.validator,
|
validator: value.validator,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
@@ -1466,7 +1474,7 @@ impl Client {
|
|||||||
fn interface_request_is_current(
|
fn interface_request_is_current(
|
||||||
&self,
|
&self,
|
||||||
session: &SessionId,
|
session: &SessionId,
|
||||||
reference: &str,
|
reference: &InterfaceReference,
|
||||||
id: RequestId,
|
id: RequestId,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -1511,7 +1519,7 @@ impl Client {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum RetrievalSubject {
|
enum RetrievalSubject {
|
||||||
Observe(ObserveRequest),
|
Observe(ObserveRequest),
|
||||||
Interface(String),
|
Interface(InterfaceReference),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -1617,7 +1625,7 @@ fn set_observe_subject_state(
|
|||||||
|
|
||||||
fn ensure_interface_slot(
|
fn ensure_interface_slot(
|
||||||
state: &mut Session,
|
state: &mut Session,
|
||||||
reference: &str,
|
reference: &InterfaceReference,
|
||||||
maximum: usize,
|
maximum: usize,
|
||||||
) -> Result<(), ClientError> {
|
) -> Result<(), ClientError> {
|
||||||
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use wip_client::{
|
use wip_client::{
|
||||||
@@ -39,7 +46,7 @@ fn object() -> Object {
|
|||||||
Object {
|
Object {
|
||||||
name: "item".into(),
|
name: "item".into(),
|
||||||
description: None,
|
description: None,
|
||||||
interfaces: vec!["math".into()],
|
interfaces: vec![root_reference("math")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
}
|
}
|
||||||
@@ -72,7 +79,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
|
|||||||
let cases: &[&[u8]] = &[
|
let cases: &[&[u8]] = &[
|
||||||
b"{not json",
|
b"{not json",
|
||||||
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
||||||
br#"{"name":"item","interfaces":["math","math"]}"#,
|
br#"{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/","name":"math"}]}"#,
|
||||||
br#"{"name":"wrong","interfaces":[]}"#,
|
br#"{"name":"wrong","interfaces":[]}"#,
|
||||||
];
|
];
|
||||||
for body in cases {
|
for body in cases {
|
||||||
@@ -181,12 +188,15 @@ fn non_success_without_valid_envelope_remains_transport_failure() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||||
let (mut client, session) = setup();
|
let (mut client, session) = setup();
|
||||||
let request = client.prepare_interface(&session, "math").unwrap();
|
let request = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
let logical = FetchInterfaceRequest {
|
let logical = FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
};
|
};
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor("future-format/2"),
|
descriptor: descriptor("future-format/2"),
|
||||||
validator: Some(b"v2".to_vec()),
|
validator: Some(b"v2".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -216,16 +226,19 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
let interface = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
client
|
client
|
||||||
.complete(
|
.complete(
|
||||||
interface.id,
|
interface.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&FetchInterfaceResponse {
|
&FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -238,7 +251,7 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
|||||||
.prepare_call(
|
.prepare_call(
|
||||||
&session,
|
&session,
|
||||||
"/item",
|
"/item",
|
||||||
"math",
|
&root_reference("math"),
|
||||||
"calculate",
|
"calculate",
|
||||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||||
)
|
)
|
||||||
@@ -293,16 +306,19 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
let interface = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
client
|
client
|
||||||
.complete(
|
.complete(
|
||||||
interface.id,
|
interface.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&FetchInterfaceResponse {
|
&FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -315,7 +331,7 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
|||||||
.prepare_call(
|
.prepare_call(
|
||||||
&session,
|
&session,
|
||||||
"/item",
|
"/item",
|
||||||
"math",
|
&root_reference("math"),
|
||||||
"calculate",
|
"calculate",
|
||||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use wip_client::{
|
use wip_client::{
|
||||||
@@ -65,7 +72,7 @@ fn object(name: &str, reference: Option<&str>, validator: &[u8]) -> Object {
|
|||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some(format!("object {name}")),
|
description: Some(format!("object {name}")),
|
||||||
interfaces: vec!["math".into()],
|
interfaces: vec![root_reference("math")],
|
||||||
r#ref: reference.map(str::to_owned),
|
r#ref: reference.map(str::to_owned),
|
||||||
validator: Some(validator.to_vec()),
|
validator: Some(validator.to_vec()),
|
||||||
}
|
}
|
||||||
@@ -296,12 +303,18 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let request = client
|
let request = client
|
||||||
.ensure_interface(&session, "math")
|
.ensure_interface(&session, root_reference("math"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("missing descriptor starts a fetch");
|
.expect("missing descriptor starts a fetch");
|
||||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"math-v1".to_vec()),
|
validator: Some(b"math-v1".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -310,7 +323,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
request.id,
|
request.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&value,
|
&value,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -318,10 +331,15 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
assert!(
|
||||||
|
client
|
||||||
|
.ensure_interface(&session, root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
let failed = client
|
let failed = client
|
||||||
.ensure_interface(&session, "broken")
|
.ensure_interface(&session, root_reference("broken"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("new descriptor starts a fetch");
|
.expect("new descriptor starts a fetch");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -333,7 +351,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
client
|
client
|
||||||
.ensure_interface(&session, "broken")
|
.ensure_interface(&session, root_reference("broken"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"retained interface failures require an explicit refresh"
|
"retained interface failures require an explicit refresh"
|
||||||
@@ -348,7 +366,10 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let pending_object = client.refresh_object(&session, "/busy").unwrap();
|
let pending_object = client.refresh_object(&session, "/busy").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
client.ensure_interface(&session, "math").unwrap().is_none(),
|
client
|
||||||
|
.ensure_interface(&session, root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"lookahead waits while the in-flight bound is full"
|
"lookahead waits while the in-flight bound is full"
|
||||||
);
|
);
|
||||||
client
|
client
|
||||||
@@ -359,11 +380,12 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let request = client
|
let request = client
|
||||||
.ensure_interface(&session, "math")
|
.ensure_interface(&session, root_reference("math"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("lookahead resumes after capacity becomes available");
|
.expect("lookahead resumes after capacity becomes available");
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
};
|
};
|
||||||
@@ -372,7 +394,7 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
request.id,
|
request.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&value,
|
&value,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -383,18 +405,32 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
client
|
client
|
||||||
.ensure_interface(&session, "other")
|
.ensure_interface(&session, root_reference("other"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"lookahead does not churn a full descriptor cache"
|
"lookahead does not churn a full descriptor cache"
|
||||||
);
|
);
|
||||||
assert!(client.interface(&session, "math").is_some());
|
assert!(
|
||||||
assert!(client.interface(&session, "other").is_none());
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root_reference("other"))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
let explicit = client.prepare_interface(&session, "other").unwrap();
|
let explicit = client
|
||||||
assert!(client.interface(&session, "math").is_none());
|
.prepare_interface(&session, root_reference("other"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.interface(&session, "other").unwrap().state,
|
client.interface(&session, &root_reference("other")).unwrap().state,
|
||||||
ObservationState::Loading(id) if id == explicit.id
|
ObservationState::Loading(id) if id == explicit.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -408,7 +444,9 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
|||||||
|
|
||||||
let first = client.refresh_observed(&session, "/one", 0).unwrap();
|
let first = client.refresh_observed(&session, "/one", 0).unwrap();
|
||||||
let second = client.refresh_observed(&session, "/two", 0).unwrap();
|
let second = client.refresh_observed(&session, "/two", 0).unwrap();
|
||||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
let interface = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
|
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
|
||||||
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
|
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
|
||||||
@@ -426,10 +464,11 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
|||||||
interface.id,
|
interface.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&FetchInterfaceResponse {
|
&FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"math-v1".to_vec()),
|
validator: Some(b"math-v1".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -454,7 +493,10 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
|||||||
ObservationState::Fresh
|
ObservationState::Fresh
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.interface(&session, "math").unwrap().state,
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
ObservationState::Fresh
|
ObservationState::Fresh
|
||||||
);
|
);
|
||||||
assert_eq!(client.in_flight_len(), 0);
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
@@ -941,14 +983,21 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(7),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
@@ -965,7 +1014,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::String),
|
descriptor: descriptor(TypeExpr::String),
|
||||||
validator: Some(b"interface-v2".to_vec()),
|
validator: Some(b"interface-v2".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -981,7 +1031,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
},
|
},
|
||||||
interface: wip_protocol::InterfaceTarget {
|
interface: wip_protocol::InterfaceTarget {
|
||||||
reference: "math".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("math"),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
operation: "calculate".into(),
|
operation: "calculate".into(),
|
||||||
@@ -1015,7 +1066,10 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
|||||||
);
|
);
|
||||||
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
|
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.interface(&session, "math").unwrap().descriptor,
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.descriptor,
|
||||||
Some(descriptor(TypeExpr::String))
|
Some(descriptor(TypeExpr::String))
|
||||||
);
|
);
|
||||||
let observation = client.object(&session, "/item").unwrap();
|
let observation = client.object(&session, "/item").unwrap();
|
||||||
@@ -1039,14 +1093,21 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = client
|
let request = client
|
||||||
.pending_call_context(call.id)
|
.pending_call_context(call.id)
|
||||||
@@ -1080,7 +1141,13 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
|||||||
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
|
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
|
||||||
|
|
||||||
let next = client
|
let next = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(2),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
@@ -1109,14 +1176,21 @@ fn newer_observation_prevents_older_success_validator_overwrite() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = client
|
let request = client
|
||||||
.pending_call_context(call.id)
|
.pending_call_context(call.id)
|
||||||
@@ -1173,7 +1247,8 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -1181,7 +1256,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
|
|
||||||
let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
|
let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(7),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = client
|
let request = client
|
||||||
.pending_call_context(call.id)
|
.pending_call_context(call.id)
|
||||||
@@ -1227,7 +1308,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
assert_eq!(alias.validator, Some(b"object-v2".to_vec()));
|
assert_eq!(alias.validator, Some(b"object-v2".to_vec()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.prepare_call(&session, "/alias/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/alias/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1)
|
||||||
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.kind,
|
.kind,
|
||||||
ClientErrorKind::MissingObservation
|
ClientErrorKind::MissingObservation
|
||||||
@@ -1243,7 +1330,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
|||||||
.refresh_observed(&session, "/second/item", 0)
|
.refresh_observed(&session, "/second/item", 0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(8))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(8),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
let unknown = ProtocolError {
|
let unknown = ProtocolError {
|
||||||
@@ -1292,13 +1385,20 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"interface-v1".to_vec()),
|
validator: Some(b"interface-v1".to_vec()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(7),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
|
|
||||||
@@ -1350,13 +1450,20 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"i1".to_vec()),
|
validator: Some(b"i1".to_vec()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
let error = ProtocolError {
|
let error = ProtocolError {
|
||||||
@@ -1369,7 +1476,10 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
|||||||
client.complete(call.id, response).unwrap();
|
client.complete(call.id, response).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.interface(&session, "math").unwrap().state,
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
ObservationState::Stale
|
ObservationState::Stale
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1384,7 +1494,8 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"i2".to_vec()),
|
validator: Some(b"i2".to_vec()),
|
||||||
},
|
},
|
||||||
@@ -1404,14 +1515,21 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = client
|
let call = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(1),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(call.id).unwrap();
|
client.mark_dispatched(call.id).unwrap();
|
||||||
let unknown = ProtocolError {
|
let unknown = ProtocolError {
|
||||||
@@ -1447,7 +1565,13 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
|||||||
|
|
||||||
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
|
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
|
||||||
let second = client
|
let second = client
|
||||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
&root_reference("math"),
|
||||||
|
"calculate",
|
||||||
|
args(2),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.mark_dispatched(second.id).unwrap();
|
client.mark_dispatched(second.id).unwrap();
|
||||||
let completion = client
|
let completion = client
|
||||||
@@ -1695,8 +1819,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let old = client.prepare_interface(&session, "math").unwrap();
|
let old = client
|
||||||
let newer = client.prepare_interface(&session, "math").unwrap();
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
|
let newer = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.fail_transport(old.id, "old connection failed")
|
.fail_transport(old.id, "old connection failed")
|
||||||
@@ -1704,11 +1832,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
|||||||
Completion::StaleResponseRejected(old.id)
|
Completion::StaleResponseRejected(old.id)
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
client.interface(&session, "math").unwrap().state,
|
client.interface(&session, &root_reference("math")).unwrap().state,
|
||||||
ObservationState::Loading(id) if id == newer.id
|
ObservationState::Loading(id) if id == newer.id
|
||||||
));
|
));
|
||||||
let value = FetchInterfaceResponse {
|
let value = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"current".to_vec()),
|
validator: Some(b"current".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -1717,7 +1846,7 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
|||||||
newer.id,
|
newer.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&value,
|
&value,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -1737,15 +1866,19 @@ fn validator_free_interface_observation_remains_immutable() {
|
|||||||
&mut client,
|
&mut client,
|
||||||
&session,
|
&session,
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let refresh = client.prepare_interface(&session, "math").unwrap();
|
let refresh = client
|
||||||
|
.prepare_interface(&session, root_reference("math"))
|
||||||
|
.unwrap();
|
||||||
let contradictory = FetchInterfaceResponse {
|
let contradictory = FetchInterfaceResponse {
|
||||||
interface: "math".into(),
|
scope_ref: None,
|
||||||
|
interface: root_reference("math"),
|
||||||
descriptor: descriptor(TypeExpr::Integer),
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
validator: Some(b"became-mutable".to_vec()),
|
validator: Some(b"became-mutable".to_vec()),
|
||||||
};
|
};
|
||||||
@@ -1754,7 +1887,7 @@ fn validator_free_interface_observation_remains_immutable() {
|
|||||||
refresh.id,
|
refresh.id,
|
||||||
encode_fetch_interface_response(
|
encode_fetch_interface_response(
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "math".into(),
|
interface: root_reference("math"),
|
||||||
},
|
},
|
||||||
&contradictory,
|
&contradictory,
|
||||||
wire_limits(),
|
wire_limits(),
|
||||||
@@ -1763,7 +1896,13 @@ fn validator_free_interface_observation_remains_immutable() {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
||||||
assert_eq!(client.interface(&session, "math").unwrap().validator, None);
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.interface(&session, &root_reference("math"))
|
||||||
|
.unwrap()
|
||||||
|
.validator,
|
||||||
|
None
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1876,3 +2015,64 @@ fn in_flight_and_history_are_bounded() {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_interface_keys_membership_and_scope_ref_are_preserved() {
|
||||||
|
let mut client = client(8, 8, 8, 8);
|
||||||
|
let session = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||||
|
.unwrap();
|
||||||
|
let root = root_reference("same#::操作");
|
||||||
|
let local = wip_protocol::InterfaceReference {
|
||||||
|
scope: "/item".into(),
|
||||||
|
name: root.name.clone(),
|
||||||
|
};
|
||||||
|
let mut value = object("item", None, b"object-v1");
|
||||||
|
value.interfaces = vec![local.clone()];
|
||||||
|
observe_object(&mut client, &session, "/item", value);
|
||||||
|
for (reference, scope_ref) in [
|
||||||
|
(root.clone(), None),
|
||||||
|
(local.clone(), Some("scope-identity".into())),
|
||||||
|
] {
|
||||||
|
observe_interface(
|
||||||
|
&mut client,
|
||||||
|
&session,
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: reference,
|
||||||
|
descriptor: descriptor(TypeExpr::Integer),
|
||||||
|
scope_ref,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(client.interface(&session, &root).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.interface(&session, &local)
|
||||||
|
.unwrap()
|
||||||
|
.scope_ref
|
||||||
|
.as_deref(),
|
||||||
|
Some("scope-identity")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.prepare_call(&session, "/item", &root, "calculate", args(1))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
let call = client
|
||||||
|
.prepare_call(&session, "/item", &local, "calculate", args(1))
|
||||||
|
.unwrap();
|
||||||
|
let context = client.pending_call_context(call.id).unwrap();
|
||||||
|
assert_eq!(context.request().interface.reference, local);
|
||||||
|
assert_eq!(
|
||||||
|
context.request().interface.scope_ref.as_deref(),
|
||||||
|
Some("scope-identity")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
context.interface().scope_ref.as_deref(),
|
||||||
|
Some("scope-identity")
|
||||||
|
);
|
||||||
|
assert_eq!(client.interface(&session, &root).unwrap().scope_ref, None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ For an external backend/composition:
|
|||||||
protected state. Implement `FilesystemBackend` to resolve one connected
|
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||||
bounded children, and execute against that already-resolved lineage.
|
bounded children, and execute against that already-resolved lineage.
|
||||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
|
||||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
register generated definitions with `registry.register(&scope, name,
|
||||||
reusable built-in tokens; using those tokens does not force their publication.
|
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
|
||||||
|
optionally supplies reusable built-in tokens named `file.read`,
|
||||||
|
`symlink.inspect`, and `file.write`; tokens do not force publication. The
|
||||||
|
current engine binds only the root scope and fails closed for non-root
|
||||||
|
registrations; broader FS scope semantics remain follow-up work.
|
||||||
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
||||||
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||||
rules. `PublishedObject::expose` proves each implementation at compile time.
|
rules. `PublishedObject::expose` proves each implementation at compile time.
|
||||||
@@ -61,7 +65,26 @@ from one connected backend traversal/snapshot, not independent path-prefix
|
|||||||
resolutions. A root target has no ancestors; other targets retain the root first,
|
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||||
then intermediate parent containers. This is backend-private context, never wire
|
then intermediate parent containers. This is backend-private context, never wire
|
||||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||||
compositions still receive only the exact scoped target `Entry`.
|
compositions receive exact retained entries, including publication of the root
|
||||||
|
scope Object while another target is executing, never independent raw-path
|
||||||
|
lookups. Root publication must be deterministic and side-effect-free.
|
||||||
|
|
||||||
|
Every lineage must retain the same deployment-pinned root Object. Replacing that
|
||||||
|
Object requires a new projection and fresh `Scope`, not reuse of registrations
|
||||||
|
at the same path. The engine constructs `ScopeObservation` from the published
|
||||||
|
root Object's metadata using the refreshed retained root capability **inside**
|
||||||
|
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
|
||||||
|
It never independently looks up the raw configured root path. Root Objects need
|
||||||
|
not expose any interfaces. Calls check both the private lifetime and optional
|
||||||
|
`scope_ref`; normal root state updates do not retire the lifetime.
|
||||||
|
|
||||||
|
`with_object` (including fetch) and individual tree Object publications also
|
||||||
|
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
|
||||||
|
as an adapter to the existing execution API; no typed operation runs and no
|
||||||
|
commit is implied. Tree observations are bounded traversals, not one global tree
|
||||||
|
transaction. Fetch resolves the visible scope Object through the provider and
|
||||||
|
returns its ref, Descriptor and Interface validator together; retained registry
|
||||||
|
artifacts alone never establish publication.
|
||||||
|
|
||||||
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||||
refresh **all** lineage state/validators inside its transaction or lock before
|
refresh **all** lineage state/validators inside its transaction or lock before
|
||||||
@@ -195,13 +218,17 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
|||||||
--data '{"path":"/","depth":2}'
|
--data '{"path":"/","depth":2}'
|
||||||
```
|
```
|
||||||
|
|
||||||
A regular-file observation returns the opaque read interface reference and an object
|
A regular-file observation returns a structured read interface reference and an
|
||||||
validator. Retrieve that descriptor with:
|
object validator. Built-ins explicitly use scope `/` and the local names
|
||||||
|
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||||
|
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
|
||||||
|
binds fetch and call to the pinned deployment root. Copy the complete reference
|
||||||
|
from the observation to retrieve that descriptor:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{"interface":"<read-interface-from-observation>"}'
|
--data '{"interface":{"scope":"/","name":"file.read"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then call a bounded range read:
|
Then call a bounded range read:
|
||||||
@@ -211,7 +238,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
|||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":"<read-interface>"},
|
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||||
"operation":"read",
|
"operation":"read",
|
||||||
"arguments":{"offset":0,"length":4096}
|
"arguments":{"offset":0,"length":4096}
|
||||||
}'
|
}'
|
||||||
@@ -225,7 +252,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
|||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":"<write-interface>"},
|
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||||
"operation":"replace",
|
"operation":"replace",
|
||||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
|||||||
use crate::{AccessMode, ResourceLimits};
|
use crate::{AccessMode, ResourceLimits};
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
|
|
||||||
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
|
|||||||
pub write: RegisteredInterface<FileWriteDefinition>,
|
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||||
}
|
}
|
||||||
impl DefaultInterfaces {
|
impl DefaultInterfaces {
|
||||||
/// Register reusable default descriptors without publishing any objects.
|
/// Register reusable descriptors at an explicit publication scope.
|
||||||
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
///
|
||||||
|
/// The current FS engine binds only its deployment root scope (`/`).
|
||||||
|
pub fn register(
|
||||||
|
registry: &mut InterfaceRegistry,
|
||||||
|
scope: &Scope,
|
||||||
|
) -> Result<Self, RegistrationError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
read: registry.register(FileReadDefinition, None)?,
|
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
|
||||||
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
|
||||||
write: registry.register(FileWriteDefinition, None)?,
|
write: registry.register(scope, "file.write", FileWriteDefinition, None)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,9 @@ pub fn default_projection(
|
|||||||
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||||
let backend = LinuxBackend::open(root, access, limits)?;
|
let backend = LinuxBackend::open(root, access, limits)?;
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?;
|
let scope = Scope::new("/").map_err(std::io::Error::other)?;
|
||||||
|
let interfaces =
|
||||||
|
DefaultInterfaces::register(&mut registry, &scope).map_err(std::io::Error::other)?;
|
||||||
Ok(Projection::new(
|
Ok(Projection::new(
|
||||||
backend,
|
backend,
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::ResourceLimits;
|
use crate::ResourceLimits;
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||||
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
|
||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||||
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
|
|||||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||||
/// backend paths, credentials or hidden metadata.
|
/// backend paths, credentials or hidden metadata.
|
||||||
///
|
///
|
||||||
|
/// The root is a deployment-pinned Object: every resolution/execution lineage
|
||||||
|
/// must retain that same root, even if its textual backend path is renamed or
|
||||||
|
/// replaced. Replacing the deployment root requires a new projection and Scope
|
||||||
|
/// lifetime. The engine currently binds only `/`; non-root registrations are
|
||||||
|
/// not published until a backend can attest their Object lifetimes.
|
||||||
|
///
|
||||||
/// `execute` must retain the same target and complete lineage and serialize
|
/// `execute` must retain the same target and complete lineage and serialize
|
||||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||||
@@ -123,7 +129,10 @@ pub trait Composition<E: BackendEntry> {
|
|||||||
/// Build a request-scoped publication using this exact capability/context.
|
/// Build a request-scoped publication using this exact capability/context.
|
||||||
///
|
///
|
||||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||||
/// backend. A publication failure is a pre-dispatch rejection, never a
|
/// backend. This hook may also publish the retained root to observe its scope
|
||||||
|
/// metadata while another target is executing. Root publication must be
|
||||||
|
/// deterministic within the view and use this same retained capability.
|
||||||
|
/// A publication failure is a pre-dispatch rejection, never a
|
||||||
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||||
fn publish(
|
fn publish(
|
||||||
&self,
|
&self,
|
||||||
@@ -286,12 +295,21 @@ where
|
|||||||
fn publication(
|
fn publication(
|
||||||
&self,
|
&self,
|
||||||
entry: B::Entry,
|
entry: B::Entry,
|
||||||
|
root: &B::Entry,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||||
|
let is_root = entry.name().is_empty();
|
||||||
let publication = self
|
let publication = self
|
||||||
.projection
|
.projection
|
||||||
.composition
|
.composition
|
||||||
.publish(entry.clone(), context)?;
|
.publish(entry.clone(), context)?;
|
||||||
|
// Scope refs describe the published root Object, not an independently
|
||||||
|
// resolved raw path or the backend entry's possibly different metadata.
|
||||||
|
let root_publication = if is_root {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.projection.composition.publish(root.clone(), context)?)
|
||||||
|
};
|
||||||
let available = publication
|
let available = publication
|
||||||
.protocol_object()
|
.protocol_object()
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -308,17 +326,81 @@ where
|
|||||||
.policy
|
.policy
|
||||||
.interfaces(self.principal, &entry, &available);
|
.interfaces(self.principal, &entry, &available);
|
||||||
selected.retain(|reference| {
|
selected.retain(|reference| {
|
||||||
self.projection
|
// Non-root lifetimes are not attested by this engine yet. Never
|
||||||
.policy
|
// publish an artifact merely because the registry retains it.
|
||||||
.interface_visible(self.principal, reference)
|
reference.protocol_reference().scope == "/"
|
||||||
|
&& self
|
||||||
|
.projection
|
||||||
|
.policy
|
||||||
|
.interface_visible(self.principal, reference)
|
||||||
});
|
});
|
||||||
publication
|
let mut publication = publication
|
||||||
.project(&selected, |reference, operation| {
|
.project(&selected, |reference, operation| {
|
||||||
self.projection
|
self.projection
|
||||||
.policy
|
.policy
|
||||||
.operation_visible(self.principal, reference, operation)
|
.operation_visible(self.principal, reference, operation)
|
||||||
})
|
})
|
||||||
.map_err(|_| internal())
|
.map_err(|_| internal())?;
|
||||||
|
// Called only inside backend.execute, with its refreshed pinned lineage.
|
||||||
|
// scopes() may repeat the handle for different names; bind_scope replaces
|
||||||
|
// the same-path snapshot rather than treating repetition as a collision.
|
||||||
|
for scope in self
|
||||||
|
.projection
|
||||||
|
.registry
|
||||||
|
.scopes()
|
||||||
|
.filter(|scope| scope.path() == "/")
|
||||||
|
{
|
||||||
|
let object = root_publication
|
||||||
|
.as_ref()
|
||||||
|
.map_or(publication.implementation(), |root| root.implementation());
|
||||||
|
let current = ScopeObservation::new(scope, object);
|
||||||
|
publication = publication.bind_scope(current);
|
||||||
|
}
|
||||||
|
Ok(publication)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapt read-only callbacks to the existing backend execution boundary. The
|
||||||
|
// sentinel is NotStarted: no typed operation ran and no commit/rollback may
|
||||||
|
// be inferred. The actual callback result never escapes until execute exits.
|
||||||
|
fn with_publication<R>(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
resolved: ResolvedEntry<B::Entry>,
|
||||||
|
operation: impl FnOnce(&PublishedObject<C::Object>) -> R,
|
||||||
|
) -> ProviderResult<R> {
|
||||||
|
let mut observed = None;
|
||||||
|
let boundary = self
|
||||||
|
.projection
|
||||||
|
.backend
|
||||||
|
.execute(context, resolved, |resolved| {
|
||||||
|
if !lineage_matches_path(&resolved, path) {
|
||||||
|
observed = Some(ProviderResult::Failure(internal()));
|
||||||
|
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||||
|
}
|
||||||
|
if !self.lineage_visible(&resolved) {
|
||||||
|
observed = Some(ProviderResult::NotFound);
|
||||||
|
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||||
|
}
|
||||||
|
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||||
|
observed = Some(
|
||||||
|
match preflight(context).and_then(|()| {
|
||||||
|
self.publication(resolved.target().clone(), root, context)
|
||||||
|
.map(|publication| operation(&publication))
|
||||||
|
}) {
|
||||||
|
Ok(result) => ProviderResult::Found(result),
|
||||||
|
Err(error) => ProviderResult::Failure(error),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
OperationAttempt::reject(wip_host::CallErrorKind::Internal)
|
||||||
|
});
|
||||||
|
match boundary {
|
||||||
|
ProviderResult::Found(_) => {
|
||||||
|
observed.unwrap_or_else(|| ProviderResult::Failure(internal()))
|
||||||
|
}
|
||||||
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the descriptor with precisely this principal's visible operations.
|
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||||
@@ -333,10 +415,8 @@ where
|
|||||||
&self,
|
&self,
|
||||||
request: &FetchInterfaceRequest,
|
request: &FetchInterfaceRequest,
|
||||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
let observation = self
|
let observation =
|
||||||
.projection
|
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
|
||||||
.registry
|
|
||||||
.fetch_interface(&request.interface)?;
|
|
||||||
if !self
|
if !self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
@@ -387,10 +467,10 @@ where
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||||
};
|
};
|
||||||
let (_ancestors, entry) = entry.into_parts();
|
|
||||||
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||||
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
let response =
|
||||||
|
self.observe_entry(context, &request.path, entry, request.depth, &mut remaining)?;
|
||||||
request.validate_response(&response).map_err(|_| {
|
request.validate_response(&response).map_err(|_| {
|
||||||
protocol(
|
protocol(
|
||||||
ProtocolErrorCode::Internal,
|
ProtocolErrorCode::Internal,
|
||||||
@@ -403,7 +483,8 @@ where
|
|||||||
fn observe_entry(
|
fn observe_entry(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
entry: B::Entry,
|
path: &str,
|
||||||
|
resolved: ResolvedEntry<B::Entry>,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
remaining: &mut u64,
|
remaining: &mut u64,
|
||||||
) -> Result<ObjectObservation, ProtocolError> {
|
) -> Result<ObjectObservation, ProtocolError> {
|
||||||
@@ -412,10 +493,19 @@ where
|
|||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
}
|
}
|
||||||
*remaining -= 1;
|
*remaining -= 1;
|
||||||
let object = self
|
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
|
||||||
.publication(entry.clone(), context)
|
publication.protocol_object_at(path)
|
||||||
.map_err(read_error)?
|
}) {
|
||||||
.protocol_object();
|
ProviderResult::Found(object) => object.map_err(|error| error.protocol_error())?,
|
||||||
|
ProviderResult::NotFound => {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::NotFound,
|
||||||
|
"target path is not published",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||||
|
};
|
||||||
|
let (mut ancestors, entry) = resolved.into_parts();
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return Ok(ObjectObservation {
|
return Ok(ObjectObservation {
|
||||||
object,
|
object,
|
||||||
@@ -436,13 +526,25 @@ where
|
|||||||
if entries.len() as u64 > *remaining {
|
if entries.len() as u64 > *remaining {
|
||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
}
|
}
|
||||||
|
ancestors.push(entry.clone());
|
||||||
for child in entries {
|
for child in entries {
|
||||||
if self
|
if self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
.object_visible(self.principal, &child)
|
.object_visible(self.principal, &child)
|
||||||
{
|
{
|
||||||
children.push(self.observe_entry(context, child, depth - 1, remaining)?);
|
let child_path = if path == "/" {
|
||||||
|
format!("/{}", child.name())
|
||||||
|
} else {
|
||||||
|
format!("{path}/{}", child.name())
|
||||||
|
};
|
||||||
|
children.push(self.observe_entry(
|
||||||
|
context,
|
||||||
|
&child_path,
|
||||||
|
ResolvedEntry::new(ancestors.clone(), child),
|
||||||
|
depth - 1,
|
||||||
|
remaining,
|
||||||
|
)?);
|
||||||
} else {
|
} else {
|
||||||
if *remaining == 0 {
|
if *remaining == 0 {
|
||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
@@ -473,11 +575,7 @@ where
|
|||||||
) -> ProviderResult<R> {
|
) -> ProviderResult<R> {
|
||||||
match self.resolve(context, path) {
|
match self.resolve(context, path) {
|
||||||
ProviderResult::Found(resolved) => {
|
ProviderResult::Found(resolved) => {
|
||||||
let (_ancestors, entry) = resolved.into_parts();
|
self.with_publication(context, path, resolved, operation)
|
||||||
match self.publication(entry, context) {
|
|
||||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
|
||||||
Err(error) => ProviderResult::Failure(error),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
@@ -503,7 +601,8 @@ where
|
|||||||
if !self.lineage_visible(&resolved) {
|
if !self.lineage_visible(&resolved) {
|
||||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||||
}
|
}
|
||||||
let (_ancestors, entry) = resolved.into_parts();
|
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||||
|
let entry = resolved.target().clone();
|
||||||
if let Err(error) = preflight(context) {
|
if let Err(error) = preflight(context) {
|
||||||
return OperationAttempt::reject(match error.kind() {
|
return OperationAttempt::reject(match error.kind() {
|
||||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
@@ -512,7 +611,7 @@ where
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
match self.publication(entry, context) {
|
match self.publication(entry, root, context) {
|
||||||
Ok(publication) => operation(&publication),
|
Ok(publication) => operation(&publication),
|
||||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
|
|||||||
@@ -17,6 +17,18 @@
|
|||||||
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||||
//! existing standard HTTP binding to a deployment-established principal view.
|
//! existing standard HTTP binding to a deployment-established principal view.
|
||||||
//!
|
//!
|
||||||
|
//! # Scope publication migration
|
||||||
|
//!
|
||||||
|
//! Registration uses `registry.register(&scope, name, Definition, validator)`.
|
||||||
|
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
|
||||||
|
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
|
||||||
|
//! binds only its deployment-pinned root lifetime, inside the backend execution
|
||||||
|
//! boundary, using the current published root Object from the retained lineage.
|
||||||
|
//! Observation/fetch callbacks enter that same coherent boundary. Non-root
|
||||||
|
//! registrations fail closed until their Object lifetimes can be attested.
|
||||||
|
//! The Linux root survives textual root renames and does not issue an Object ref;
|
||||||
|
//! fetch therefore omits `scope_ref` without weakening the private lifetime check.
|
||||||
|
//!
|
||||||
//! Backends and Operations are trusted code, responsible for truthful effects,
|
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||||
//! stable within a view; changing descriptor visibility requires new reference
|
//! stable within a view; changing descriptor visibility requires new reference
|
||||||
|
|||||||
@@ -827,7 +827,7 @@ mod tests {
|
|||||||
projection: &DefaultProjection,
|
projection: &DefaultProjection,
|
||||||
path: &str,
|
path: &str,
|
||||||
validator: Option<Vec<u8>>,
|
validator: Option<Vec<u8>>,
|
||||||
interface: String,
|
interface: wip_protocol::InterfaceReference,
|
||||||
operation: &str,
|
operation: &str,
|
||||||
arguments: BTreeMap<String, Value>,
|
arguments: BTreeMap<String, Value>,
|
||||||
) -> Result<wip_protocol::CallOperationResponse, wip_host::CallError> {
|
) -> Result<wip_protocol::CallOperationResponse, wip_host::CallError> {
|
||||||
@@ -837,6 +837,7 @@ mod tests {
|
|||||||
validator,
|
validator,
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: interface,
|
reference: interface,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -1611,6 +1612,55 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
|
||||||
|
let temporary = tempdir().expect("temporary parent");
|
||||||
|
let configured = temporary.path().join("root");
|
||||||
|
let moved = temporary.path().join("moved");
|
||||||
|
fs::create_dir(&configured).unwrap();
|
||||||
|
fs::write(configured.join("file"), b"original").unwrap();
|
||||||
|
let engine = projection(&configured, AccessMode::ReadOnly);
|
||||||
|
let object = engine.observe_object("/file").unwrap();
|
||||||
|
let reference = object.interfaces[0].clone();
|
||||||
|
assert_eq!(reference.scope, "/");
|
||||||
|
assert_eq!(reference.name, "file.read");
|
||||||
|
fs::rename(&configured, &moved).unwrap();
|
||||||
|
fs::create_dir(&configured).unwrap();
|
||||||
|
fs::write(configured.join("file"), b"replacement").unwrap();
|
||||||
|
let fetched = engine
|
||||||
|
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
|
||||||
|
interface: reference.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(fetched.scope_ref.is_none());
|
||||||
|
let result = call(
|
||||||
|
&engine,
|
||||||
|
"/file",
|
||||||
|
object.validator,
|
||||||
|
reference.clone(),
|
||||||
|
"read",
|
||||||
|
BTreeMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
|
||||||
|
let error = engine
|
||||||
|
.call_operation(wip_host::CallRequest {
|
||||||
|
target: wip_host::Target {
|
||||||
|
path: "/file".into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: wip_host::InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref: Some("invented-root-ref".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_lock_wait_observes_the_operation_deadline() {
|
fn file_lock_wait_observes_the_operation_deadline() {
|
||||||
let temporary = tempdir().expect("temporary root");
|
let temporary = tempdir().expect("temporary root");
|
||||||
@@ -1621,8 +1671,6 @@ mod tests {
|
|||||||
.write(true)
|
.write(true)
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.expect("open lock owner");
|
.expect("open lock owner");
|
||||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
|
||||||
|
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
request_timeout: std::time::Duration::from_millis(20),
|
request_timeout: std::time::Duration::from_millis(20),
|
||||||
..ResourceLimits::default()
|
..ResourceLimits::default()
|
||||||
@@ -1630,6 +1678,9 @@ mod tests {
|
|||||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||||
.expect("open projection");
|
.expect("open projection");
|
||||||
let observed = projection.observe_object("/file").expect("observe file");
|
let observed = projection.observe_object("/file").expect("observe file");
|
||||||
|
// Observation also enters the coherent backend boundary now. Acquire
|
||||||
|
// the interfering lock only after obtaining Client-known metadata.
|
||||||
|
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let error = call(
|
let error = call(
|
||||||
&projection,
|
&projection,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
|||||||
validator: file.validator.clone(),
|
validator: file.validator.clone(),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: file.interfaces[0].clone(),
|
reference: file.interfaces[0].clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -141,6 +142,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
|||||||
validator: file.validator,
|
validator: file.validator,
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: file.interfaces[1].clone(),
|
reference: file.interfaces[1].clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
@@ -412,7 +414,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
|
|||||||
let config = ServerConfig::for_root(temporary.path());
|
let config = ServerConfig::for_root(temporary.path());
|
||||||
let server = WipFsServer::new(config).expect("build server");
|
let server = WipFsServer::new(config).expect("build server");
|
||||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":{"scope":"/","name":"unknown"}},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||||
let request = http::Request::builder()
|
let request = http::Request::builder()
|
||||||
.method(http::Method::POST)
|
.method(http::Method::POST)
|
||||||
.uri(endpoint.route(Route::CallOperation).as_str())
|
.uri(endpoint.route(Route::CallOperation).as_str())
|
||||||
@@ -490,6 +492,7 @@ fn read_response_size_failure_is_transport_local() {
|
|||||||
validator: file.validator,
|
validator: file.validator,
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
|
scope_ref: None,
|
||||||
reference: file.interfaces[0].clone(),
|
reference: file.interfaces[0].clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
|||||||
use wip_host::{
|
use wip_host::{
|
||||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||||
RegisteredInterface, Validator, WipObject,
|
RegisteredInterface, Scope, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_http::{
|
use wip_http::{
|
||||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
|||||||
fn description(&self) -> Option<&str> {
|
fn description(&self) -> Option<&str> {
|
||||||
Some("public memory entry")
|
Some("public memory entry")
|
||||||
}
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
(self.path == "/").then_some("entry-root")
|
||||||
|
}
|
||||||
fn validator(&self) -> Option<&Validator> {
|
fn validator(&self) -> Option<&Validator> {
|
||||||
Some(&self.validator)
|
Some(&self.validator)
|
||||||
}
|
}
|
||||||
@@ -157,7 +160,13 @@ impl Shared {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct MemoryBackend(Arc<Shared>);
|
struct MemoryBackend(Arc<Shared>, Arc<Node>);
|
||||||
|
impl MemoryBackend {
|
||||||
|
fn new(shared: Arc<Shared>) -> Self {
|
||||||
|
let root = shared.node("/");
|
||||||
|
Self(shared, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||||
if context.cancellation().is_cancelled()
|
if context.cancellation().is_cancelled()
|
||||||
|| context
|
|| context
|
||||||
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
// One connected map snapshot, serialized with participating mutations.
|
// One connected map snapshot, serialized with participating mutations.
|
||||||
let _transaction = self.0.transaction.lock().unwrap();
|
let _transaction = self.0.transaction.lock().unwrap();
|
||||||
let nodes = self.0.nodes.lock().unwrap();
|
let nodes = self.0.nodes.lock().unwrap();
|
||||||
|
if !nodes
|
||||||
|
.get("/")
|
||||||
|
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||||
|
{
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
let Some(node) = nodes.get(path) else {
|
let Some(node) = nodes.get(path) else {
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
};
|
};
|
||||||
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
// Verify lineage identity/membership within the same transaction. The
|
// Verify lineage identity/membership within the same transaction. The
|
||||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||||
let nodes = self.0.nodes.lock().unwrap();
|
let nodes = self.0.nodes.lock().unwrap();
|
||||||
|
if !nodes
|
||||||
|
.get("/")
|
||||||
|
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||||
|
{
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
for ancestor in &ancestors {
|
for ancestor in &ancestors {
|
||||||
if !nodes
|
if !nodes
|
||||||
.get(&ancestor.path)
|
.get(&ancestor.path)
|
||||||
@@ -371,6 +392,11 @@ impl WipObject for Object {
|
|||||||
fn description(&self) -> Option<&str> {
|
fn description(&self) -> Option<&str> {
|
||||||
self.entry.description()
|
self.entry.description()
|
||||||
}
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
// Public representation identity need not be the backend entry ref.
|
||||||
|
// ScopeObservation must capture this published Object's ref.
|
||||||
|
(self.entry.path == "/").then_some("memory-root")
|
||||||
|
}
|
||||||
fn validator(&self) -> Option<&Validator> {
|
fn validator(&self) -> Option<&Validator> {
|
||||||
self.entry.validator()
|
self.entry.validator()
|
||||||
}
|
}
|
||||||
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
|||||||
for panic_object in [true, false] {
|
for panic_object in [true, false] {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
let scope = Scope::new("/").unwrap();
|
||||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||||
let reference = custom.reference().as_str().to_owned();
|
let custom = registry
|
||||||
|
.register(
|
||||||
|
&scope,
|
||||||
|
"memory.operations",
|
||||||
|
MemoryOperationsDefinition,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reference = custom.reference().protocol_reference();
|
||||||
let engine = Projection::new(
|
let engine = Projection::new(
|
||||||
MemoryBackend(fixture.shared.clone()),
|
MemoryBackend::new(fixture.shared.clone()),
|
||||||
registry,
|
registry,
|
||||||
MemoryComposition {
|
MemoryComposition {
|
||||||
defaults,
|
defaults,
|
||||||
@@ -616,9 +650,11 @@ type Engine = Projection<MemoryBackend, MemoryComposition, Policy>;
|
|||||||
struct Fixture {
|
struct Fixture {
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
custom: String,
|
custom: wip_protocol::InterfaceReference,
|
||||||
read: String,
|
read: wip_protocol::InterfaceReference,
|
||||||
limits: ResourceLimits,
|
limits: ResourceLimits,
|
||||||
|
// Client-known descriptors acquired coherently before execution fault injection.
|
||||||
|
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||||
}
|
}
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
@@ -669,9 +705,15 @@ impl Fixture {
|
|||||||
seen: Mutex::new(vec![]),
|
seen: Mutex::new(vec![]),
|
||||||
});
|
});
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
let scope = Scope::new("/").unwrap();
|
||||||
|
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||||
let custom = registry
|
let custom = registry
|
||||||
.register(MemoryOperationsDefinition, interface_validator)
|
.register(
|
||||||
|
&scope,
|
||||||
|
"memory.operations",
|
||||||
|
MemoryOperationsDefinition,
|
||||||
|
interface_validator,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let read_ref = defaults.read.reference().clone();
|
let read_ref = defaults.read.reference().clone();
|
||||||
let custom_ref = custom.reference().clone();
|
let custom_ref = custom.reference().clone();
|
||||||
@@ -687,18 +729,38 @@ impl Fixture {
|
|||||||
custom,
|
custom,
|
||||||
shared: shared.clone(),
|
shared: shared.clone(),
|
||||||
};
|
};
|
||||||
Self {
|
let engine = Projection::new(
|
||||||
engine: Projection::new(
|
MemoryBackend::new(shared.clone()),
|
||||||
MemoryBackend(shared.clone()),
|
registry,
|
||||||
registry,
|
composition,
|
||||||
composition,
|
policy,
|
||||||
policy,
|
|
||||||
limits,
|
|
||||||
),
|
|
||||||
shared,
|
|
||||||
custom: custom_ref.as_str().into(),
|
|
||||||
read: read_ref.as_str().into(),
|
|
||||||
limits,
|
limits,
|
||||||
|
);
|
||||||
|
let mut descriptors = BTreeMap::new();
|
||||||
|
if !invalid_selection {
|
||||||
|
for principal in [Principal::Admin, Principal::Limited] {
|
||||||
|
for reference in [&read_ref, &custom_ref] {
|
||||||
|
let reference = reference.protocol_reference();
|
||||||
|
if let Ok(observation) = engine
|
||||||
|
.view(&principal)
|
||||||
|
.fetch_interface(&fetch_request(&reference))
|
||||||
|
{
|
||||||
|
descriptors.insert(
|
||||||
|
(matches!(principal, Principal::Admin), reference),
|
||||||
|
observation.descriptor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shared.seen.lock().unwrap().clear();
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
shared,
|
||||||
|
custom: custom_ref.protocol_reference(),
|
||||||
|
read: read_ref.protocol_reference(),
|
||||||
|
limits,
|
||||||
|
descriptors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||||
@@ -707,17 +769,17 @@ impl Fixture {
|
|||||||
.observe(&ExecutionContext::new(), &observe_request(path, depth))
|
.observe(&ExecutionContext::new(), &observe_request(path, depth))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
fn descriptor(&self, principal: &Principal, reference: &str) -> InterfaceDescriptor {
|
fn descriptor(
|
||||||
self.engine
|
&self,
|
||||||
.view(principal)
|
principal: &Principal,
|
||||||
.fetch_interface(&fetch_request(reference))
|
reference: &wip_protocol::InterfaceReference,
|
||||||
.unwrap()
|
) -> InterfaceDescriptor {
|
||||||
.descriptor
|
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||||
}
|
}
|
||||||
fn request(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
reference: &str,
|
reference: &wip_protocol::InterfaceReference,
|
||||||
operation: &str,
|
operation: &str,
|
||||||
validator: Option<Vec<u8>>,
|
validator: Option<Vec<u8>>,
|
||||||
) -> CallOperationRequest {
|
) -> CallOperationRequest {
|
||||||
@@ -727,7 +789,8 @@ impl Fixture {
|
|||||||
validator,
|
validator,
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: reference.into(),
|
scope_ref: None,
|
||||||
|
reference: reference.clone(),
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
@@ -866,9 +929,9 @@ fn observe_request(path: &str, depth: u32) -> ObserveRequest {
|
|||||||
depth,
|
depth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn fetch_request(reference: &str) -> FetchInterfaceRequest {
|
fn fetch_request(reference: &wip_protocol::InterfaceReference) -> FetchInterfaceRequest {
|
||||||
FetchInterfaceRequest {
|
FetchInterfaceRequest {
|
||||||
interface: reference.into(),
|
interface: reference.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn success<T: std::fmt::Debug>(response: DecodedResponse<T>) -> T {
|
fn success<T: std::fmt::Debug>(response: DecodedResponse<T>) -> T {
|
||||||
@@ -925,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
|||||||
Some("public memory entry")
|
Some("public memory entry")
|
||||||
);
|
);
|
||||||
assert!(observation.children.is_none());
|
assert!(observation.children.is_none());
|
||||||
assert!(observation.object.r#ref.is_none());
|
assert_eq!(
|
||||||
|
observation.object.r#ref.as_deref(),
|
||||||
|
(path == "/").then_some("memory-root")
|
||||||
|
);
|
||||||
for reference in &observation.object.interfaces {
|
for reference in &observation.object.interfaces {
|
||||||
let fetched = fixture
|
let fetched = fixture
|
||||||
.engine
|
.engine
|
||||||
@@ -1378,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
|||||||
ProtocolErrorCode::Internal
|
ProtocolErrorCode::Internal
|
||||||
);
|
);
|
||||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
||||||
|
// This deliberately invalid publication cannot supply a fetch response.
|
||||||
|
// Use the application's authored schema solely to encode the test request.
|
||||||
let (response, decoded) = fixture.http_call(
|
let (response, decoded) = fixture.http_call(
|
||||||
&Principal::Admin,
|
&Principal::Admin,
|
||||||
&request,
|
&request,
|
||||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
&MemoryOperationsDefinition::descriptor(),
|
||||||
&ExecutionContext::new(),
|
&ExecutionContext::new(),
|
||||||
);
|
);
|
||||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||||
@@ -1804,8 +1872,8 @@ fn policy_panics_and_guessed_operation_diagnostics_do_not_leak_to_http() {
|
|||||||
// This valid metadata names a nonexistent operation; bypass client-side
|
// This valid metadata names a nonexistent operation; bypass client-side
|
||||||
// descriptor validation to exercise server diagnostics for untrusted names.
|
// descriptor validation to exercise server diagnostics for untrusted names.
|
||||||
let body = format!(
|
let body = format!(
|
||||||
r#"{{"target":{{"path":"/added"}},"interface":{{"reference":"{}"}},"operation":"{}","arguments":{{}}}}"#,
|
r#"{{"target":{{"path":"/added"}},"interface":{{"reference":{{"scope":"{}","name":"{}"}}}},"operation":"{}","arguments":{{}}}}"#,
|
||||||
fixture.custom, POLICY_SECRET
|
fixture.custom.scope, fixture.custom.name, POLICY_SECRET
|
||||||
);
|
);
|
||||||
let request = http::Request::builder()
|
let request = http::Request::builder()
|
||||||
.method(http::Method::POST)
|
.method(http::Method::POST)
|
||||||
@@ -2031,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
|
|||||||
);
|
);
|
||||||
no_secrets(&response);
|
no_secrets(&response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let view = fixture.engine.view(&Principal::Admin);
|
||||||
|
let fetched = view
|
||||||
|
.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.observe(&Principal::Admin, "/", 0)
|
||||||
|
.object
|
||||||
|
.interfaces
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||||
|
request.interface.scope_ref = Some("wrong-root".into());
|
||||||
|
assert_eq!(
|
||||||
|
view.call_operation(request.clone()).unwrap_err().kind(),
|
||||||
|
wip_host::CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.shared
|
||||||
|
.node("/added")
|
||||||
|
.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordinary state updates do not replace the publication lifetime or ref.
|
||||||
|
fixture.shared.node("/").data.lock().unwrap().revision += 1;
|
||||||
|
request.interface.scope_ref = fetched.scope_ref;
|
||||||
|
view.call_operation(request).unwrap();
|
||||||
|
|
||||||
|
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
|
||||||
|
// same boundary even though resolve initially sees a visible root.
|
||||||
|
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
|
||||||
|
assert_eq!(
|
||||||
|
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_scope_replacement_during_call_rejects_the_prelookup() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let view = fixture.engine.view(&Principal::Admin);
|
||||||
|
// Simulate a retained non-root lineage whose root is replaced before call.
|
||||||
|
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||||
|
let request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||||
|
assert_eq!(
|
||||||
|
view.call_operation(request).unwrap_err().kind(),
|
||||||
|
wip_host::CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.shared
|
||||||
|
.node("/added")
|
||||||
|
.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.engine
|
||||||
|
.view(&Principal::Admin)
|
||||||
|
.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let root = Scope::new("/").unwrap();
|
||||||
|
let local = Scope::new("/added").unwrap();
|
||||||
|
let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap();
|
||||||
|
let custom = registry
|
||||||
|
.register(
|
||||||
|
&local,
|
||||||
|
"memory.operations",
|
||||||
|
MemoryOperationsDefinition,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reference = custom.reference().protocol_reference();
|
||||||
|
let policy = Policy {
|
||||||
|
custom: custom.reference().clone(),
|
||||||
|
read: defaults.read.reference().clone(),
|
||||||
|
hide_read: false,
|
||||||
|
invalid_selection: false,
|
||||||
|
panic_authorize: false,
|
||||||
|
};
|
||||||
|
let engine = Projection::new(
|
||||||
|
MemoryBackend::new(fixture.shared.clone()),
|
||||||
|
registry,
|
||||||
|
MemoryComposition {
|
||||||
|
defaults,
|
||||||
|
custom,
|
||||||
|
shared: fixture.shared.clone(),
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
fixture.limits,
|
||||||
|
);
|
||||||
|
let view = engine.view(&Principal::Admin);
|
||||||
|
let object = view.observe_object("/added").unwrap();
|
||||||
|
assert!(!object.interfaces.contains(&reference));
|
||||||
|
assert_eq!(
|
||||||
|
view.fetch_interface(&fetch_request(&reference))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
let request = fixture.request("/added", &reference, "denied", None);
|
||||||
|
assert_eq!(
|
||||||
|
view.call_operation(request).unwrap_err().kind(),
|
||||||
|
wip_host::CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.shared
|
||||||
|
.node("/added")
|
||||||
|
.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ wip-host = "0.1.0"
|
|||||||
wip-interface = "0.1.0"
|
wip-interface = "0.1.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`.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
See the [API documentation](https://docs.rs/wip-host) for publication, provider, execution-context, and effect-guarantee boundaries.
|
See the [API documentation](https://docs.rs/wip-host) for publication, provider, execution-context, and effect-guarantee boundaries.
|
||||||
|
|
||||||
Licensed under either of Apache-2.0 or MIT at your option.
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
|
|||||||
+343
-106
@@ -3,7 +3,7 @@
|
|||||||
//! # Responsibility boundaries
|
//! # Responsibility boundaries
|
||||||
//!
|
//!
|
||||||
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
||||||
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
|
//! descriptor at an explicit scope and local name, and leaves that reference
|
||||||
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
||||||
//! request-scoped Rust object with a registered interface; its
|
//! request-scoped Rust object with a registered interface; its
|
||||||
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
//! and a publication with no interfaces is valid.
|
//! and a publication with no interfaces is valid.
|
||||||
//!
|
//!
|
||||||
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
||||||
//! keeps the resulting object, interface membership, descriptor, implementation,
|
//! keeps the resulting object, current scope Object association/ref, interface
|
||||||
|
//! membership, descriptor, implementation,
|
||||||
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
||||||
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
||||||
//! The same frozen operation declaration validates both arguments and results.
|
//! The same frozen operation declaration validates both arguments and results.
|
||||||
@@ -29,14 +30,13 @@
|
|||||||
|
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
use std::any::TypeId;
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{self, Display, Formatter};
|
use std::fmt::{self, Display, Formatter};
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use wip_interface::DispatchError;
|
use wip_interface::DispatchError;
|
||||||
@@ -49,8 +49,8 @@ pub use wip_protocol::{
|
|||||||
Target,
|
Target,
|
||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError,
|
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, InterfaceReference, Object,
|
||||||
ProtocolErrorCode, ValidationError, Value,
|
ProtocolError, ProtocolErrorCode, ValidationError, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Opaque bytes used as a conditional execution precondition.
|
/// Opaque bytes used as a conditional execution precondition.
|
||||||
@@ -187,25 +187,104 @@ impl ExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque host-assigned reference to one registered interface artifact.
|
/// One publication lifetime of the Object at a canonical scope path.
|
||||||
|
///
|
||||||
|
/// Clone while the same Object remains published, including state updates. Create
|
||||||
|
/// a new handle after deletion, replacement or movement, even with the same ref.
|
||||||
|
/// This private lifetime is not a Protocol identity component or generated ref.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Scope {
|
||||||
|
path: String,
|
||||||
|
lifetime: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
/// Creates a fresh lifetime without implicitly publishing an Object.
|
||||||
|
pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
|
let path = path.into();
|
||||||
|
wip_protocol::validate_path(&path)?;
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
lifetime: Arc::new(AtomicBool::new(true)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the client-facing canonical scope path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &str {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_lifetime(&self, other: &Self) -> bool {
|
||||||
|
self.path == other.path && Arc::ptr_eq(&self.lifetime, &other.lifetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active(&self) -> bool {
|
||||||
|
self.lifetime.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-attested current scope Object association.
|
||||||
|
///
|
||||||
|
/// Construct only inside a coherent boundary that proves this lifetime belongs
|
||||||
|
/// to the Object at `scope.path()`. A prelookup or matching ref alone is not proof.
|
||||||
|
/// The Object need not expose interfaces. Its validator is not scope identity.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScopeObservation {
|
||||||
|
scope: Scope,
|
||||||
|
reference: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeObservation {
|
||||||
|
/// Attests the association and captures the current Object's optional ref.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(scope: &Scope, object: &impl WipObject) -> Self {
|
||||||
|
Self {
|
||||||
|
scope: scope.clone(),
|
||||||
|
reference: object.reference().map(str::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the observed optional ref.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference(&self) -> Option<&str> {
|
||||||
|
self.reference.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the associated scope publication lifetime.
|
||||||
|
#[must_use]
|
||||||
|
pub fn scope(&self) -> &Scope {
|
||||||
|
&self.scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured client-facing reference to a scoped Interface publication.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct InterfaceRef(String);
|
pub struct InterfaceRef(InterfaceReference);
|
||||||
|
|
||||||
impl InterfaceRef {
|
impl InterfaceRef {
|
||||||
/// Returns the opaque reference for a protocol transport.
|
/// Returns the local name without parsing or adding display delimiters.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the structured Protocol reference.
|
||||||
|
#[must_use]
|
||||||
|
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||||
|
self.0.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct InterfaceObservation {
|
pub struct InterfaceObservation {
|
||||||
/// Opaque reference selected by published objects and operation calls.
|
/// Structured reference selected by published objects and operation calls.
|
||||||
pub reference: InterfaceRef,
|
pub reference: InterfaceRef,
|
||||||
/// Canonical descriptor frozen when the interface was registered.
|
/// Canonical descriptor frozen when the interface was registered.
|
||||||
pub descriptor: InterfaceDescriptor,
|
pub descriptor: InterfaceDescriptor,
|
||||||
|
/// Optional ref of the scope Object from this same observation.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Optional validator. Its presence makes it a required call precondition.
|
/// Optional validator. Its presence makes it a required call precondition.
|
||||||
pub validator: Option<Validator>,
|
pub validator: Option<Validator>,
|
||||||
}
|
}
|
||||||
@@ -229,13 +308,14 @@ impl InterfaceObservation {
|
|||||||
/// local validation error.
|
/// local validation error.
|
||||||
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
let response = FetchInterfaceResponse {
|
let response = FetchInterfaceResponse {
|
||||||
interface: self.reference.0.clone(),
|
interface: self.reference.protocol_reference(),
|
||||||
descriptor: self.descriptor.clone(),
|
descriptor: self.descriptor.clone(),
|
||||||
|
scope_ref: self.scope_ref.clone(),
|
||||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||||
};
|
};
|
||||||
response
|
response
|
||||||
.validate_for(&FetchInterfaceRequest {
|
.validate_for(&FetchInterfaceRequest {
|
||||||
interface: self.reference.0.clone(),
|
interface: self.reference.protocol_reference(),
|
||||||
})
|
})
|
||||||
.map_err(|_| ProtocolError {
|
.map_err(|_| ProtocolError {
|
||||||
code: ProtocolErrorCode::Internal,
|
code: ProtocolErrorCode::Internal,
|
||||||
@@ -247,6 +327,7 @@ impl InterfaceObservation {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct InterfaceArtifact {
|
struct InterfaceArtifact {
|
||||||
|
scope: Scope,
|
||||||
reference: InterfaceRef,
|
reference: InterfaceRef,
|
||||||
descriptor: InterfaceDescriptor,
|
descriptor: InterfaceDescriptor,
|
||||||
validator: Option<Validator>,
|
validator: Option<Validator>,
|
||||||
@@ -255,7 +336,7 @@ struct InterfaceArtifact {
|
|||||||
/// A typed registration token for a generated interface definition.
|
/// A typed registration token for a generated interface definition.
|
||||||
///
|
///
|
||||||
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
||||||
/// opaque reference, not the Rust type name or an address.
|
/// scoped reference and a private publication lifetime, not a Rust address.
|
||||||
pub struct RegisteredInterface<D> {
|
pub struct RegisteredInterface<D> {
|
||||||
artifact: Arc<InterfaceArtifact>,
|
artifact: Arc<InterfaceArtifact>,
|
||||||
definition: PhantomData<fn() -> D>,
|
definition: PhantomData<fn() -> D>,
|
||||||
@@ -271,158 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<D> RegisteredInterface<D> {
|
impl<D> RegisteredInterface<D> {
|
||||||
/// Returns the opaque protocol reference assigned by the registry.
|
/// Returns the explicit scope/name reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn reference(&self) -> &InterfaceRef {
|
pub fn reference(&self) -> &InterfaceRef {
|
||||||
&self.artifact.reference
|
&self.artifact.reference
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registry of validated, immutable interface artifacts.
|
/// Registry of frozen definition artifacts and explicitly scoped registrations.
|
||||||
///
|
///
|
||||||
/// Generated descriptors are never rewritten to contain their assigned
|
/// Artifact retention is not proof of current publication. Fetch requires a live
|
||||||
/// references. A validator-free artifact is immutable for the lifetime of its
|
/// provider-attested ScopeObservation; execution requires the same lifetime in
|
||||||
/// reference. A validator-bearing artifact requires that exact validator on a
|
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
|
||||||
/// call. Re-registering a definition is rejected rather than silently changing
|
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
|
||||||
/// the observation associated with an existing reference.
|
#[derive(Debug, Default)]
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct InterfaceRegistry {
|
pub struct InterfaceRegistry {
|
||||||
namespace: u64,
|
|
||||||
next_reference: u64,
|
|
||||||
definitions: HashSet<TypeId>,
|
|
||||||
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1);
|
|
||||||
|
|
||||||
impl Default for InterfaceRegistry {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed),
|
|
||||||
next_reference: 0,
|
|
||||||
definitions: HashSet::new(),
|
|
||||||
artifacts: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InterfaceRegistry {
|
impl InterfaceRegistry {
|
||||||
/// Creates an empty interface registry.
|
/// Creates an empty artifact registry without publishing any Object.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers and freezes one generated interface definition.
|
/// Freezes a Definition at an explicit scope and local name.
|
||||||
pub fn register<D>(
|
pub fn register<D>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
scope: &Scope,
|
||||||
|
name: impl Into<String>,
|
||||||
_definition: D,
|
_definition: D,
|
||||||
validator: Option<Validator>,
|
validator: Option<Validator>,
|
||||||
) -> Result<RegisteredInterface<D>, RegistrationError>
|
) -> Result<RegisteredInterface<D>, RegistrationError>
|
||||||
where
|
where
|
||||||
D: InterfaceDefinition + 'static,
|
D: InterfaceDefinition + 'static,
|
||||||
{
|
{
|
||||||
let definition = TypeId::of::<D>();
|
let reference = InterfaceRef(InterfaceReference {
|
||||||
if self.definitions.contains(&definition) {
|
scope: scope.path.clone(),
|
||||||
|
name: name.into(),
|
||||||
|
});
|
||||||
|
reference
|
||||||
|
.0
|
||||||
|
.validate()
|
||||||
|
.map_err(RegistrationError::InvalidReference)?;
|
||||||
|
if !scope.active() {
|
||||||
|
return Err(RegistrationError::RetiredScope);
|
||||||
|
}
|
||||||
|
if self.artifacts.contains_key(&reference) {
|
||||||
return Err(RegistrationError::AlreadyRegistered);
|
return Err(RegistrationError::AlreadyRegistered);
|
||||||
}
|
}
|
||||||
|
|
||||||
let descriptor = D::descriptor();
|
let descriptor = D::descriptor();
|
||||||
descriptor
|
descriptor
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(RegistrationError::InvalidDescriptor)?;
|
.map_err(RegistrationError::InvalidDescriptor)?;
|
||||||
|
|
||||||
let reference = InterfaceRef(format!(
|
|
||||||
"wip-interface:{}:{}",
|
|
||||||
self.namespace, self.next_reference
|
|
||||||
));
|
|
||||||
self.next_reference = self
|
|
||||||
.next_reference
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(RegistrationError::ReferenceSpaceExhausted)?;
|
|
||||||
let artifact = Arc::new(InterfaceArtifact {
|
let artifact = Arc::new(InterfaceArtifact {
|
||||||
|
scope: scope.clone(),
|
||||||
reference: reference.clone(),
|
reference: reference.clone(),
|
||||||
descriptor,
|
descriptor,
|
||||||
validator,
|
validator,
|
||||||
});
|
});
|
||||||
self.definitions.insert(definition);
|
|
||||||
self.artifacts.insert(reference, Arc::clone(&artifact));
|
self.artifacts.insert(reference, Arc::clone(&artifact));
|
||||||
|
|
||||||
Ok(RegisteredInterface {
|
Ok(RegisteredInterface {
|
||||||
artifact,
|
artifact,
|
||||||
definition: PhantomData,
|
definition: PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a transport-provided opaque reference string in this registry.
|
/// Ends a scope lifetime and releases its registry entries.
|
||||||
|
///
|
||||||
|
/// Retained tokens cannot start new calls/fetches. Started calls retain their
|
||||||
|
/// frozen Descriptor. Re-publication requires a fresh Scope.
|
||||||
|
pub fn remove_scope(&mut self, scope: &Scope) {
|
||||||
|
scope.lifetime.store(false, Ordering::Release);
|
||||||
|
self.artifacts
|
||||||
|
.retain(|_, artifact| !artifact.scope.same_lifetime(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a stored artifact, not evidence of current publication.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
|
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||||
let reference = InterfaceRef(reference.to_owned());
|
let reference = InterfaceRef(reference.clone());
|
||||||
self.artifacts.contains_key(&reference).then_some(reference)
|
self.artifacts.contains_key(&reference).then_some(reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observes a registered interface by its host-side reference.
|
/// Iterates registered scope handles, possibly repeated for multiple names.
|
||||||
#[must_use]
|
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
|
||||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
self.artifacts.values().map(|artifact| &artifact.scope)
|
||||||
self.artifacts.get(reference).map(Self::observation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implements canonical `fetch_interface` semantics for an opaque reference.
|
/// Fetches only with a coherent current scope Object attestation.
|
||||||
///
|
pub fn fetch_interface(
|
||||||
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
|
&self,
|
||||||
/// not object membership errors or authorization failures.
|
reference: &InterfaceReference,
|
||||||
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
|
current: &ScopeObservation,
|
||||||
let reference = InterfaceRef(reference.to_owned());
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
self.artifacts
|
self.artifacts
|
||||||
.get(&reference)
|
.get(&InterfaceRef(reference.clone()))
|
||||||
.map(Self::observation)
|
.filter(|artifact| {
|
||||||
.ok_or_else(|| ProtocolError {
|
artifact.scope.active() && artifact.scope.same_lifetime(¤t.scope)
|
||||||
code: ProtocolErrorCode::InterfaceNotFound,
|
|
||||||
message: "interface reference is not currently published".into(),
|
|
||||||
})
|
})
|
||||||
}
|
.map(|artifact| InterfaceObservation {
|
||||||
|
reference: artifact.reference.clone(),
|
||||||
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
|
descriptor: artifact.descriptor.clone(),
|
||||||
InterfaceObservation {
|
scope_ref: current.reference.clone(),
|
||||||
reference: artifact.reference.clone(),
|
validator: artifact.validator.clone(),
|
||||||
descriptor: artifact.descriptor.clone(),
|
})
|
||||||
validator: artifact.validator.clone(),
|
.ok_or_else(interface_not_found)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure while registering a generated interface definition.
|
fn interface_not_found() -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
|
message: "interface is not currently published".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure while registering a generated interface Definition.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RegistrationError {
|
pub enum RegistrationError {
|
||||||
/// The same generated definition was already assigned a reference.
|
/// The exact scope/name coordinate is already occupied.
|
||||||
AlreadyRegistered,
|
AlreadyRegistered,
|
||||||
/// The generated descriptor is structurally invalid.
|
/// The generated Descriptor is invalid.
|
||||||
InvalidDescriptor(ValidationError),
|
InvalidDescriptor(ValidationError),
|
||||||
/// The registry cannot assign another opaque reference.
|
/// The scope or local name is invalid.
|
||||||
ReferenceSpaceExhausted,
|
InvalidReference(ValidationError),
|
||||||
|
/// The lifetime ended; re-publication needs a fresh Scope.
|
||||||
|
RetiredScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for RegistrationError {
|
impl Display for RegistrationError {
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::AlreadyRegistered => {
|
Self::AlreadyRegistered => {
|
||||||
formatter.write_str("interface definition already registered")
|
formatter.write_str("interface scope/name already registered")
|
||||||
}
|
}
|
||||||
Self::InvalidDescriptor(error) => {
|
Self::InvalidDescriptor(error) => {
|
||||||
write!(formatter, "invalid interface descriptor: {error}")
|
write!(formatter, "invalid interface descriptor: {error}")
|
||||||
}
|
}
|
||||||
Self::ReferenceSpaceExhausted => {
|
Self::InvalidReference(error) => {
|
||||||
formatter.write_str("interface reference space exhausted")
|
write!(formatter, "invalid interface reference: {error}")
|
||||||
}
|
}
|
||||||
|
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error for RegistrationError {
|
impl Error for RegistrationError {
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidDescriptor(error) => Some(error),
|
Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
|
||||||
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
|
Self::AlreadyRegistered | Self::RetiredScope => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,6 +613,7 @@ struct BoundInterface<T> {
|
|||||||
pub struct PublishedObject<T: WipObject> {
|
pub struct PublishedObject<T: WipObject> {
|
||||||
implementation: T,
|
implementation: T,
|
||||||
interfaces: Vec<BoundInterface<T>>,
|
interfaces: Vec<BoundInterface<T>>,
|
||||||
|
scopes: Vec<ScopeObservation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: WipObject> PublishedObject<T> {
|
impl<T: WipObject> PublishedObject<T> {
|
||||||
@@ -538,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
Self {
|
Self {
|
||||||
implementation,
|
implementation,
|
||||||
interfaces: Vec::new(),
|
interfaces: Vec::new(),
|
||||||
|
scopes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches a current scope snapshot inside the provider's atomic boundary.
|
||||||
|
///
|
||||||
|
/// Never populate this from a stale prelookup. Target, scope association,
|
||||||
|
/// Descriptor and backend handles must remain coherent through dispatch and
|
||||||
|
/// commit. A later same-path binding replaces the earlier snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bind_scope(mut self, current: ScopeObservation) -> Self {
|
||||||
|
self.scopes
|
||||||
|
.retain(|old| old.scope.path != current.scope.path);
|
||||||
|
self.scopes.push(current);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_scope(&self, scope: &Scope) -> Option<&ScopeObservation> {
|
||||||
|
self.scopes
|
||||||
|
.iter()
|
||||||
|
.find(|current| scope.active() && scope.same_lifetime(¤t.scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scopes_valid_at(&self, path: &str) -> bool {
|
||||||
|
self.interfaces.iter().all(|bound| {
|
||||||
|
bound.artifact.reference.0.validate_for_path(path).is_ok()
|
||||||
|
&& self
|
||||||
|
.current_scope(&bound.artifact.scope)
|
||||||
|
.is_some_and(|current| {
|
||||||
|
current.scope.path != path
|
||||||
|
|| current.reference() == self.implementation.reference()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Explicitly exposes a registered generated interface in insertion order.
|
/// Explicitly exposes a registered generated interface in insertion order.
|
||||||
///
|
///
|
||||||
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
||||||
/// implements the corresponding Rust interface trait. Duplicate opaque
|
/// implements the corresponding Rust interface trait. Duplicate scoped
|
||||||
/// references are rejected.
|
/// references are rejected.
|
||||||
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
||||||
where
|
where
|
||||||
@@ -597,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
let mut descriptor = bound.artifact.descriptor.clone();
|
let mut descriptor = bound.artifact.descriptor.clone();
|
||||||
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||||
bound.artifact = Arc::new(InterfaceArtifact {
|
bound.artifact = Arc::new(InterfaceArtifact {
|
||||||
|
scope: bound.artifact.scope.clone(),
|
||||||
reference: reference.clone(),
|
reference: reference.clone(),
|
||||||
descriptor,
|
descriptor,
|
||||||
validator: bound.artifact.validator.clone(),
|
validator: bound.artifact.validator.clone(),
|
||||||
@@ -613,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
&self.implementation
|
&self.implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds canonical protocol object metadata in interface insertion order.
|
/// Builds metadata from the explicit exposure set in insertion order.
|
||||||
///
|
///
|
||||||
/// The observation contains no path, children, descriptor, backend handle,
|
/// This path-free projection is useful while composing a publication, but is
|
||||||
|
/// not proof that its scopes are currently published. Use
|
||||||
|
/// [`ObjectProvider::observe_object`] for a validated current observation, or
|
||||||
|
/// [`Self::protocol_object_at`] inside a provider's coherent boundary.
|
||||||
|
/// The metadata contains no path, children, descriptor, backend handle,
|
||||||
/// credential, or provider resolution state.
|
/// credential, or provider resolution state.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn protocol_object(&self) -> Object {
|
pub fn protocol_object(&self) -> Object {
|
||||||
@@ -625,7 +747,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
interfaces: self
|
interfaces: self
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|bound| bound.artifact.reference.0.clone())
|
.map(|bound| bound.artifact.reference.protocol_reference())
|
||||||
.collect(),
|
.collect(),
|
||||||
r#ref: self.implementation.reference().map(str::to_owned),
|
r#ref: self.implementation.reference().map(str::to_owned),
|
||||||
validator: self
|
validator: self
|
||||||
@@ -635,6 +757,30 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a current Object observation inside the provider's boundary.
|
||||||
|
///
|
||||||
|
/// All exposed interfaces must have compatible, live scope associations. The
|
||||||
|
/// provider must still establish those associations atomically; this method
|
||||||
|
/// cannot verify backend lifetime assertions or lock external writers.
|
||||||
|
pub fn protocol_object_at(&self, path: &str) -> Result<Object, CallError> {
|
||||||
|
wip_protocol::validate_path(path)
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
if !self.scopes_valid_at(path) {
|
||||||
|
return Err(CallError::new(
|
||||||
|
CallErrorKind::Internal,
|
||||||
|
"Host published interfaces without current compatible scope Objects",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let object = self.protocol_object();
|
||||||
|
object.validate_at_path(path).map_err(|_| {
|
||||||
|
CallError::new(
|
||||||
|
CallErrorKind::Internal,
|
||||||
|
"Host produced an invalid object response",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_resolved(
|
fn dispatch_resolved(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
@@ -678,10 +824,17 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if interface.reference.validate_for_path(&target.path).is_err() {
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InterfaceMismatch,
|
||||||
|
"interface scope is not an ancestor of the target path",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let Some(bound) = self
|
let Some(bound) = self
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str())
|
.find(|bound| bound.artifact.reference.protocol_reference() == interface.reference)
|
||||||
else {
|
else {
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::InterfaceMismatch,
|
CallErrorKind::InterfaceMismatch,
|
||||||
@@ -689,6 +842,31 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Some(current_scope) = self.current_scope(&bound.artifact.scope) else {
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InterfaceMismatch,
|
||||||
|
"interface scope Object is not currently associated with this publication",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if current_scope.scope.path == target.path
|
||||||
|
&& current_scope.reference() != self.implementation.reference()
|
||||||
|
{
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::Internal,
|
||||||
|
"provider produced inconsistent self-scope metadata",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if interface
|
||||||
|
.scope_ref
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|supplied| current_scope.reference() != Some(supplied))
|
||||||
|
{
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InterfaceMismatch,
|
||||||
|
"scope Object ref does not match the current association",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
match (
|
match (
|
||||||
bound.artifact.validator.as_ref(),
|
bound.artifact.validator.as_ref(),
|
||||||
interface.validator.as_deref(),
|
interface.validator.as_deref(),
|
||||||
@@ -927,7 +1105,12 @@ pub enum ProviderResult<T> {
|
|||||||
///
|
///
|
||||||
/// `with_object` must resolve the current object at `path` exactly once and pass
|
/// `with_object` must resolve the current object at `path` exactly once and pass
|
||||||
/// a publication that pins its current interface set, implementations, and
|
/// a publication that pins its current interface set, implementations, and
|
||||||
/// backend target for the duration of `operation`. The execution context is the
|
/// backend target and all current scope Object associations for the duration of
|
||||||
|
/// `operation`. Attach them with PublishedObject::bind_scope inside this same
|
||||||
|
/// boundary, never via an independent scope prelookup. A new Object at the same
|
||||||
|
/// path needs a fresh Scope; ordinary state changes retain the handle. Providers
|
||||||
|
/// own lifetime/atomicity enforcement, including external writers and backend
|
||||||
|
/// commit/rollback. The execution context is the
|
||||||
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
||||||
/// The borrowed publication cannot escape the callback.
|
/// The borrowed publication cannot escape the callback.
|
||||||
///
|
///
|
||||||
@@ -991,7 +1174,9 @@ pub trait ObjectProvider {
|
|||||||
wip_protocol::validate_path(path)
|
wip_protocol::validate_path(path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.with_object(context, path, PublishedObject::protocol_object)
|
self.with_object(context, path, |publication| {
|
||||||
|
publication.protocol_object_at(path)
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
CallError::new(
|
CallError::new(
|
||||||
@@ -1000,7 +1185,7 @@ pub trait ObjectProvider {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let object = match resolved {
|
let object = match resolved {
|
||||||
ProviderResult::Found(object) => object,
|
ProviderResult::Found(object) => object?,
|
||||||
ProviderResult::NotFound => {
|
ProviderResult::NotFound => {
|
||||||
return Err(CallError::new(
|
return Err(CallError::new(
|
||||||
CallErrorKind::NotFound,
|
CallErrorKind::NotFound,
|
||||||
@@ -1009,15 +1194,58 @@ pub trait ObjectProvider {
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
||||||
};
|
};
|
||||||
object.validate_at_path(path).map_err(|_| {
|
|
||||||
CallError::new(
|
|
||||||
CallErrorKind::Internal,
|
|
||||||
"Host produced an invalid object response",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(object)
|
Ok(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches Descriptor, scope Object ref and Interface validator atomically.
|
||||||
|
///
|
||||||
|
/// The scope Object may expose no interfaces. Its publication must still
|
||||||
|
/// bind the live ScopeObservation. Registry retention is not publication.
|
||||||
|
fn fetch_interface(
|
||||||
|
&self,
|
||||||
|
registry: &InterfaceRegistry,
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let observed = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
self.with_object(
|
||||||
|
&ExecutionContext::default(),
|
||||||
|
&reference.scope,
|
||||||
|
|publication| {
|
||||||
|
publication
|
||||||
|
.scopes
|
||||||
|
.iter()
|
||||||
|
.find(|current| current.scope.path == reference.scope)
|
||||||
|
.ok_or_else(interface_not_found)
|
||||||
|
.and_then(|current| {
|
||||||
|
if current.reference() != publication.implementation.reference() {
|
||||||
|
return Err(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "provider produced inconsistent scope Object metadata"
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registry.fetch_interface(reference, current)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.map_err(|_| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "provider panicked while observing an interface".into(),
|
||||||
|
})?;
|
||||||
|
match observed {
|
||||||
|
ProviderResult::Found(observation) => observation,
|
||||||
|
ProviderResult::NotFound => Err(interface_not_found()),
|
||||||
|
ProviderResult::Failure(error) => {
|
||||||
|
Err(CallError::from_read_failure(error).protocol_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves and executes a canonical path-targeted operation request.
|
/// Resolves and executes a canonical path-targeted operation request.
|
||||||
///
|
///
|
||||||
/// Structural path validation precedes provider resolution. For a valid
|
/// Structural path validation precedes provider resolution. For a valid
|
||||||
@@ -1039,6 +1267,11 @@ pub trait ObjectProvider {
|
|||||||
) -> Result<CallResult, CallError> {
|
) -> Result<CallResult, CallError> {
|
||||||
wip_protocol::validate_path(&request.target.path)
|
wip_protocol::validate_path(&request.target.path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
request
|
||||||
|
.interface
|
||||||
|
.reference
|
||||||
|
.validate()
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let path = request.target.path.clone();
|
let path = request.target.path.clone();
|
||||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.execute_operation(context, &path, |publication| {
|
self.execute_operation(context, &path, |publication| {
|
||||||
@@ -1088,6 +1321,10 @@ pub trait ObjectProvider {
|
|||||||
) -> Result<CallResult, CallError> {
|
) -> Result<CallResult, CallError> {
|
||||||
wip_protocol::validate_path(&target.path)
|
wip_protocol::validate_path(&target.path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
interface
|
||||||
|
.reference
|
||||||
|
.validate()
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let path = target.path.clone();
|
let path = target.path.clone();
|
||||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.execute_operation(context, &path, |publication| {
|
self.execute_operation(context, &path, |publication| {
|
||||||
@@ -1128,7 +1365,7 @@ pub trait ObjectProvider {
|
|||||||
/// Failure to construct an explicit object publication.
|
/// Failure to construct an explicit object publication.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum PublicationError {
|
pub enum PublicationError {
|
||||||
/// The same opaque interface reference was exposed more than once.
|
/// The same scope/name Interface reference was exposed more than once.
|
||||||
DuplicateInterface(InterfaceRef),
|
DuplicateInterface(InterfaceRef),
|
||||||
/// Projection attempted to add an interface without an implementation.
|
/// Projection attempted to add an interface without an implementation.
|
||||||
UnexposedInterface(InterfaceRef),
|
UnexposedInterface(InterfaceRef),
|
||||||
|
|||||||
+177
-23
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
|||||||
use wip_host::{
|
use wip_host::{
|
||||||
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
||||||
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
||||||
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
|
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface::{
|
use wip_interface::{
|
||||||
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
||||||
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
|
|||||||
#[test]
|
#[test]
|
||||||
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let read = registry.register(ReaderDefinition, None).unwrap();
|
let root = Scope::new("/").unwrap();
|
||||||
let write = registry.register(WriterDefinition, None).unwrap();
|
let read = registry
|
||||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
.register(&root, "reader", ReaderDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let write = registry
|
||||||
|
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let hidden = registry
|
||||||
|
.register(&root, "hidden", HiddenDefinition, None)
|
||||||
|
.unwrap();
|
||||||
let publication = || {
|
let publication = || {
|
||||||
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||||
.expose(&read)
|
.expose(&read)
|
||||||
@@ -35,7 +42,10 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected.protocol_object().interfaces,
|
projected.protocol_object().interfaces,
|
||||||
vec![write.reference().as_str(), read.reference().as_str()]
|
vec![
|
||||||
|
write.reference().protocol_reference(),
|
||||||
|
read.reference().protocol_reference()
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
publication()
|
publication()
|
||||||
@@ -58,14 +68,19 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
|||||||
.interfaces
|
.interfaces
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
let full = registry.fetch_interface(read.reference().as_str()).unwrap();
|
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
|
||||||
|
let full = registry
|
||||||
|
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||||
|
.unwrap();
|
||||||
let filtered = full
|
let filtered = full
|
||||||
.clone()
|
.clone()
|
||||||
.project_operations(|operation| operation == "label");
|
.project_operations(|operation| operation == "label");
|
||||||
assert_eq!(filtered.descriptor.operations.len(), 1);
|
assert_eq!(filtered.descriptor.operations.len(), 1);
|
||||||
assert_eq!(full.descriptor.operations.len(), 3);
|
assert_eq!(full.descriptor.operations.len(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.fetch_interface(read.reference().as_str()).unwrap(),
|
registry
|
||||||
|
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||||
|
.unwrap(),
|
||||||
full
|
full
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,6 +317,7 @@ impl Broken for Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TestProvider {
|
struct TestProvider {
|
||||||
|
root: Scope,
|
||||||
reader: RegisteredInterface<ReaderDefinition>,
|
reader: RegisteredInterface<ReaderDefinition>,
|
||||||
writer: RegisteredInterface<WriterDefinition>,
|
writer: RegisteredInterface<WriterDefinition>,
|
||||||
broken: RegisteredInterface<BrokenDefinition>,
|
broken: RegisteredInterface<BrokenDefinition>,
|
||||||
@@ -319,11 +335,13 @@ struct TestProvider {
|
|||||||
|
|
||||||
impl TestProvider {
|
impl TestProvider {
|
||||||
fn new(
|
fn new(
|
||||||
|
root: Scope,
|
||||||
reader: RegisteredInterface<ReaderDefinition>,
|
reader: RegisteredInterface<ReaderDefinition>,
|
||||||
writer: RegisteredInterface<WriterDefinition>,
|
writer: RegisteredInterface<WriterDefinition>,
|
||||||
broken: RegisteredInterface<BrokenDefinition>,
|
broken: RegisteredInterface<BrokenDefinition>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
root,
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
broken,
|
broken,
|
||||||
@@ -355,7 +373,10 @@ impl ObjectProvider for TestProvider {
|
|||||||
self.observed_cancelled
|
self.observed_cancelled
|
||||||
.set(context.cancellation().is_cancelled());
|
.set(context.cancellation().is_cancelled());
|
||||||
self.resolutions.set(self.resolutions.get() + 1);
|
self.resolutions.set(self.resolutions.get() + 1);
|
||||||
|
let scope_object = Document::open("", None, Rc::clone(&self.calls));
|
||||||
|
let current_scope = ScopeObservation::new(&self.root, &scope_object);
|
||||||
let (document, interfaces) = match path {
|
let (document, interfaces) = match path {
|
||||||
|
"/" => (scope_object, 0),
|
||||||
"/document" => (
|
"/document" => (
|
||||||
Document::strict(
|
Document::strict(
|
||||||
"document",
|
"document",
|
||||||
@@ -413,6 +434,7 @@ impl ObjectProvider for TestProvider {
|
|||||||
3 => publication.expose(&self.broken).unwrap(),
|
3 => publication.expose(&self.broken).unwrap(),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
let publication = publication.bind_scope(current_scope);
|
||||||
ProviderResult::Found(operation(&publication))
|
ProviderResult::Found(operation(&publication))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,13 +476,25 @@ fn registry_and_provider() -> (
|
|||||||
RegisteredInterface<HiddenDefinition>,
|
RegisteredInterface<HiddenDefinition>,
|
||||||
) {
|
) {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let root = Scope::new("/").unwrap();
|
||||||
let reader = registry
|
let reader = registry
|
||||||
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
|
.register(
|
||||||
|
&root,
|
||||||
|
"reader",
|
||||||
|
ReaderDefinition,
|
||||||
|
Some(Validator::new(b"schema-v1")),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let writer = registry.register(WriterDefinition, None).unwrap();
|
let writer = registry
|
||||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||||
let broken = registry.register(BrokenDefinition, None).unwrap();
|
.unwrap();
|
||||||
let provider = TestProvider::new(reader, writer, broken);
|
let hidden = registry
|
||||||
|
.register(&root, "hidden", HiddenDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let broken = registry
|
||||||
|
.register(&root, "broken", BrokenDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let provider = TestProvider::new(root, reader, writer, broken);
|
||||||
(registry, provider, hidden)
|
(registry, provider, hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +512,8 @@ fn request(
|
|||||||
validator: object_validator.map(<[u8]>::to_vec),
|
validator: object_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: reference.as_str().to_owned(),
|
reference: reference.protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
@@ -497,17 +532,22 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let reference = provider.reader.reference();
|
let reference = provider.reader.reference();
|
||||||
|
|
||||||
assert_ne!(reference.as_str(), "Reader");
|
assert_ne!(reference.as_str(), "Reader");
|
||||||
let observation = registry.fetch_interface(reference.as_str()).unwrap();
|
let observation = provider
|
||||||
|
.fetch_interface(®istry, &reference.protocol_reference())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(observation.reference, *reference);
|
assert_eq!(observation.reference, *reference);
|
||||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||||
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
||||||
let response = observation.protocol_response().unwrap();
|
let response = observation.protocol_response().unwrap();
|
||||||
assert_eq!(response.interface, reference.as_str());
|
assert_eq!(response.interface, reference.protocol_reference());
|
||||||
|
assert_eq!(response.scope_ref, None);
|
||||||
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
|
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
|
||||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||||
response.descriptor.validate().unwrap();
|
response.descriptor.validate().unwrap();
|
||||||
|
|
||||||
let mut invalid_observation = registry.fetch_interface(reference.as_str()).unwrap();
|
let mut invalid_observation = provider
|
||||||
|
.fetch_interface(®istry, &reference.protocol_reference())
|
||||||
|
.unwrap();
|
||||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||||
invalid_observation
|
invalid_observation
|
||||||
.descriptor
|
.descriptor
|
||||||
@@ -516,7 +556,15 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||||
|
|
||||||
let error = registry.fetch_interface("unknown").unwrap_err();
|
let error = provider
|
||||||
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
|
&wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "unknown".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,8 +580,8 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
object.interfaces,
|
object.interfaces,
|
||||||
vec![
|
vec![
|
||||||
provider.writer.reference().as_str().to_owned(),
|
provider.writer.reference().protocol_reference(),
|
||||||
provider.reader.reference().as_str().to_owned(),
|
provider.reader.reference().protocol_reference(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!format!("{object:?}").contains("secret-handle"));
|
assert!(!format!("{object:?}").contains("secret-handle"));
|
||||||
@@ -814,7 +862,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"stale".to_vec()),
|
validator: Some(b"stale".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: hidden.reference().as_str().into(),
|
reference: hidden.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
"missing".into(),
|
"missing".into(),
|
||||||
@@ -832,7 +881,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"stale".to_vec()),
|
validator: Some(b"stale".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: hidden.reference().as_str().into(),
|
reference: hidden.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
"hidden".into(),
|
"hidden".into(),
|
||||||
@@ -850,7 +900,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: hidden.reference().as_str().into(),
|
reference: hidden.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
},
|
},
|
||||||
"hidden".into(),
|
"hidden".into(),
|
||||||
@@ -868,7 +919,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
|||||||
validator: Some(b"object-v1".to_vec()),
|
validator: Some(b"object-v1".to_vec()),
|
||||||
},
|
},
|
||||||
InterfaceTarget {
|
InterfaceTarget {
|
||||||
reference: provider.reader.reference().as_str().into(),
|
reference: provider.reader.reference().protocol_reference(),
|
||||||
|
scope_ref: None,
|
||||||
validator: Some(b"schema-v1".to_vec()),
|
validator: Some(b"schema-v1".to_vec()),
|
||||||
},
|
},
|
||||||
"add".into(),
|
"add".into(),
|
||||||
@@ -1122,3 +1174,105 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
|||||||
);
|
);
|
||||||
assert_eq!(provider.calls.get(), calls);
|
assert_eq!(provider.calls.get(), calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_boundaries_preserve_names_verbatim_and_match_exact_scope() {
|
||||||
|
let (registry, provider, _) = registry_and_provider();
|
||||||
|
let legacy = provider.writer.reference();
|
||||||
|
let reference = legacy.protocol_reference();
|
||||||
|
assert_eq!(reference.scope, "/");
|
||||||
|
assert_eq!(reference.name, legacy.as_str());
|
||||||
|
assert_eq!(reference.name, "writer::#日本語");
|
||||||
|
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||||
|
let nested = wip_protocol::InterfaceReference {
|
||||||
|
scope: "/open".into(),
|
||||||
|
name: reference.name.clone(),
|
||||||
|
};
|
||||||
|
assert!(registry.resolve(&nested).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &nested)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
let malformed = wip_protocol::InterfaceReference {
|
||||||
|
scope: "relative".into(),
|
||||||
|
name: reference.name,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &malformed)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InvalidRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_reference_shape_precedes_resolution_and_scope_membership_is_exact() {
|
||||||
|
let (_, provider, _) = registry_and_provider();
|
||||||
|
for (scope, name) in [("relative", "valid"), ("/", ""), ("/bad/", "valid")] {
|
||||||
|
let mut call = request(
|
||||||
|
"/open",
|
||||||
|
None,
|
||||||
|
provider.writer.reference(),
|
||||||
|
None,
|
||||||
|
"label",
|
||||||
|
BTreeMap::new(),
|
||||||
|
);
|
||||||
|
call.interface.reference = wip_protocol::InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||||
|
CallErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|_| Ok::<_, ()>(BTreeMap::new()),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InvalidRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(provider.resolutions.get(), 0);
|
||||||
|
// /other is outside the target scope; /open is self but has no legacy artifact.
|
||||||
|
for scope in ["/other", "/open"] {
|
||||||
|
let mut call = request(
|
||||||
|
"/open",
|
||||||
|
None,
|
||||||
|
provider.writer.reference(),
|
||||||
|
None,
|
||||||
|
"label",
|
||||||
|
BTreeMap::new(),
|
||||||
|
);
|
||||||
|
call.interface.reference.scope = scope.into();
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("must not decode") },
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
assert_eq!(provider.authorization_checks.get(), 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use wip_host::{
|
||||||
|
CallErrorKind, CallRequest, ExecutionContext, InterfaceRegistry, InterfaceTarget,
|
||||||
|
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, RegistrationError, Scope,
|
||||||
|
ScopeObservation, Target, Validator, WipObject,
|
||||||
|
};
|
||||||
|
use wip_interface::{OperationResult, interface};
|
||||||
|
use wip_protocol::{InterfaceReference, ProtocolErrorCode, Value};
|
||||||
|
|
||||||
|
#[interface]
|
||||||
|
pub trait Read {
|
||||||
|
fn read(&self, value: String) -> OperationResult<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[interface]
|
||||||
|
pub trait Count {
|
||||||
|
fn read(&self, value: i64) -> OperationResult<i64>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Metadata {
|
||||||
|
name: String,
|
||||||
|
reference: Option<String>,
|
||||||
|
validator: Option<Validator>,
|
||||||
|
}
|
||||||
|
impl WipObject for Metadata {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
self.reference.as_deref()
|
||||||
|
}
|
||||||
|
fn validator(&self) -> Option<&Validator> {
|
||||||
|
self.validator.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnStart = Rc<RefCell<Option<Box<dyn Fn()>>>>;
|
||||||
|
|
||||||
|
struct Implementation {
|
||||||
|
metadata: Metadata,
|
||||||
|
calls: Rc<Cell<usize>>,
|
||||||
|
on_start: OnStart,
|
||||||
|
}
|
||||||
|
impl WipObject for Implementation {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.metadata.name()
|
||||||
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
self.metadata.reference()
|
||||||
|
}
|
||||||
|
fn validator(&self) -> Option<&Validator> {
|
||||||
|
self.metadata.validator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Read for Implementation {
|
||||||
|
fn read(&self, value: String) -> OperationResult<String> {
|
||||||
|
self.calls.set(self.calls.get() + 1);
|
||||||
|
if let Some(action) = self.on_start.borrow_mut().take() {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider {
|
||||||
|
interface: RegisteredInterface<ReadDefinition>,
|
||||||
|
current: Rc<RefCell<Option<(Scope, Metadata)>>>,
|
||||||
|
target_path: String,
|
||||||
|
target_exists: Cell<bool>,
|
||||||
|
member: Cell<bool>,
|
||||||
|
calls: Rc<Cell<usize>>,
|
||||||
|
resolutions: Cell<usize>,
|
||||||
|
on_start: OnStart,
|
||||||
|
}
|
||||||
|
impl Provider {
|
||||||
|
fn new(
|
||||||
|
scope: &Scope,
|
||||||
|
interface: RegisteredInterface<ReadDefinition>,
|
||||||
|
target: &str,
|
||||||
|
reference: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
interface,
|
||||||
|
current: Rc::new(RefCell::new(Some((
|
||||||
|
scope.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: scope.path().rsplit('/').next().unwrap().into(),
|
||||||
|
reference: reference.map(str::to_owned),
|
||||||
|
validator: Some(Validator::new(b"scope-state-v1")),
|
||||||
|
},
|
||||||
|
)))),
|
||||||
|
target_path: target.into(),
|
||||||
|
target_exists: Cell::new(true),
|
||||||
|
member: Cell::new(true),
|
||||||
|
calls: Rc::new(Cell::new(0)),
|
||||||
|
resolutions: Cell::new(0),
|
||||||
|
on_start: Rc::new(RefCell::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn request(&self, scope_ref: Option<&str>) -> CallRequest {
|
||||||
|
CallRequest {
|
||||||
|
target: Target {
|
||||||
|
path: self.target_path.clone(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference: self.interface.reference().protocol_reference(),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
validator: Some(b"descriptor-v1".to_vec()),
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::from([("value".into(), Value::String("original backend".into()))]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ObjectProvider for Provider {
|
||||||
|
type Object = Implementation;
|
||||||
|
fn with_object<R>(
|
||||||
|
&self,
|
||||||
|
_: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
callback: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||||
|
) -> ProviderResult<R> {
|
||||||
|
self.resolutions.set(self.resolutions.get() + 1);
|
||||||
|
// A single boundary captures both the target and the live scope association.
|
||||||
|
let current = self.current.borrow().clone();
|
||||||
|
let target = path == self.target_path;
|
||||||
|
let metadata = if target {
|
||||||
|
if !self.target_exists.get() {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
if let Some((scope, object)) = ¤t
|
||||||
|
&& scope.path() == path
|
||||||
|
{
|
||||||
|
object.clone()
|
||||||
|
} else {
|
||||||
|
Metadata {
|
||||||
|
name: path.rsplit('/').next().unwrap().into(),
|
||||||
|
reference: None,
|
||||||
|
validator: Some(Validator::new(b"target-v1")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((scope, object)) = ¤t {
|
||||||
|
if path != scope.path() {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
object.clone()
|
||||||
|
} else {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
};
|
||||||
|
let mut publication = PublishedObject::new(Implementation {
|
||||||
|
metadata,
|
||||||
|
calls: self.calls.clone(),
|
||||||
|
on_start: self.on_start.clone(),
|
||||||
|
});
|
||||||
|
if target && self.member.get() {
|
||||||
|
publication = publication.expose(&self.interface).unwrap();
|
||||||
|
}
|
||||||
|
if let Some((scope, object)) = current {
|
||||||
|
publication = publication.bind_scope(ScopeObservation::new(&scope, &object));
|
||||||
|
}
|
||||||
|
ProviderResult::Found(callback(&publication))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn setup(
|
||||||
|
scope: &str,
|
||||||
|
target: &str,
|
||||||
|
reference: Option<&str>,
|
||||||
|
) -> (InterfaceRegistry, Scope, Provider) {
|
||||||
|
let scope = Scope::new(scope).unwrap();
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let interface = registry
|
||||||
|
.register(
|
||||||
|
&scope,
|
||||||
|
"read::#日本語",
|
||||||
|
ReadDefinition,
|
||||||
|
Some(Validator::new(b"descriptor-v1")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let provider = Provider::new(&scope, interface, target, reference);
|
||||||
|
(registry, scope, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registrations_use_scope_name_not_definition_type_or_delimiters() {
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let left = Scope::new("/left").unwrap();
|
||||||
|
let right = Scope::new("/right").unwrap();
|
||||||
|
let a = registry
|
||||||
|
.register(&left, "read::#日本語", ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let b = registry
|
||||||
|
.register(&right, "read::#日本語", ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(a.reference(), b.reference());
|
||||||
|
assert_eq!(
|
||||||
|
a.reference().protocol_reference(),
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/left".into(),
|
||||||
|
name: "read::#日本語".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
registry.register(&left, "read::#日本語", CountDefinition, None),
|
||||||
|
Err(RegistrationError::AlreadyRegistered)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
registry.register(&left, "", ReadDefinition, None),
|
||||||
|
Err(RegistrationError::InvalidReference(_))
|
||||||
|
));
|
||||||
|
assert!(Scope::new("/invalid/").is_err());
|
||||||
|
registry
|
||||||
|
.register(&left, "another", ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_root_ancestor_and_membership_are_independent() {
|
||||||
|
for (scope, target) in [
|
||||||
|
("/", "/"),
|
||||||
|
("/", "/target"),
|
||||||
|
("/scope", "/scope"),
|
||||||
|
("/scope", "/scope/target"),
|
||||||
|
] {
|
||||||
|
let (registry, _, provider) = setup(scope, target, Some("scope-id"));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(Some("scope-id")))
|
||||||
|
.unwrap()
|
||||||
|
.result,
|
||||||
|
Value::String("original backend".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap()
|
||||||
|
.scope_ref
|
||||||
|
.as_deref(),
|
||||||
|
Some("scope-id")
|
||||||
|
);
|
||||||
|
provider.member.set(false);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Non-self scope Objects need not implement or expose any Interface.
|
||||||
|
let (registry, _, provider) = setup("/scope", "/scope/target", None);
|
||||||
|
assert!(
|
||||||
|
provider
|
||||||
|
.observe_object("/scope")
|
||||||
|
.unwrap()
|
||||||
|
.interfaces
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap();
|
||||||
|
for target in ["/sibling/target", "/scopelong", "/"] {
|
||||||
|
let (_, _, provider) = setup("/scope", target, None);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
provider.observe_object(target).unwrap_err().kind(),
|
||||||
|
CallErrorKind::Internal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optional_scope_ref_and_state_validator_have_distinct_responsibilities() {
|
||||||
|
for current in [Some("scope-id"), None, Some("")] {
|
||||||
|
let (registry, _, provider) = setup("/scope", "/scope/target", current);
|
||||||
|
let observation = provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
observation
|
||||||
|
.protocol_response()
|
||||||
|
.unwrap()
|
||||||
|
.scope_ref
|
||||||
|
.as_deref(),
|
||||||
|
current
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observation.validator,
|
||||||
|
Some(Validator::new(b"descriptor-v1"))
|
||||||
|
);
|
||||||
|
provider.call_operation(provider.request(None)).unwrap();
|
||||||
|
if let Some(reference) = current {
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(Some(reference)))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before = provider.calls.get();
|
||||||
|
for supplied in ["other", "missing"] {
|
||||||
|
let mut call = provider.request(Some(supplied));
|
||||||
|
call.interface.validator = None;
|
||||||
|
call.operation = "missing".into();
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call).unwrap_err().kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(provider.calls.get(), before);
|
||||||
|
provider.current.borrow_mut().as_mut().unwrap().1.validator =
|
||||||
|
Some(Validator::new(b"scope-state-v2"));
|
||||||
|
provider.call_operation(provider.request(current)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap(),
|
||||||
|
observation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_precondition_is_membership_stage_in_both_dispatch_paths() {
|
||||||
|
let (_, _, provider) = setup("/scope", "/scope/target", Some("current"));
|
||||||
|
let mut call = provider.request(Some("stale"));
|
||||||
|
call.target.validator = Some(b"wrong".to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||||
|
CallErrorKind::ValidatorMismatch
|
||||||
|
);
|
||||||
|
provider.target_exists.set(false);
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call).unwrap_err().kind(),
|
||||||
|
CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
provider.target_exists.set(true);
|
||||||
|
let call = provider.request(Some("stale"));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("decoder must not run") }
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletion_republication_and_movement_never_inherit_retained_artifacts() {
|
||||||
|
let (mut registry, scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||||
|
let reference = provider.request(None).interface.reference;
|
||||||
|
provider.fetch_interface(®istry, &reference).unwrap();
|
||||||
|
*provider.current.borrow_mut() = None;
|
||||||
|
provider.target_exists.set(false);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &reference)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
provider.target_exists.set(true);
|
||||||
|
// Even the same ref is not proof that a replacement inherits registration.
|
||||||
|
let replacement = Scope::new("/scope").unwrap();
|
||||||
|
*provider.current.borrow_mut() = Some((
|
||||||
|
replacement.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: "scope".into(),
|
||||||
|
reference: Some("old".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &reference)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(Some("old")))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
registry.remove_scope(&scope);
|
||||||
|
assert!(matches!(
|
||||||
|
registry.register(&scope, "new", ReadDefinition, None),
|
||||||
|
Err(RegistrationError::RetiredScope)
|
||||||
|
));
|
||||||
|
let fresh = registry
|
||||||
|
.register(&replacement, reference.name.clone(), ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
provider.fetch_interface(®istry, &reference).unwrap();
|
||||||
|
// Old target membership still binds the old lifetime, even after new publication.
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
let new_provider = Provider::new(&replacement, fresh, "/scope/target", Some("new"));
|
||||||
|
let mut new_call = new_provider.request(Some("new"));
|
||||||
|
new_call.interface.validator = None;
|
||||||
|
new_provider.call_operation(new_call).unwrap();
|
||||||
|
let moved = Scope::new("/moved").unwrap();
|
||||||
|
new_provider.target_exists.set(false);
|
||||||
|
*new_provider.current.borrow_mut() = Some((
|
||||||
|
moved.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: "moved".into(),
|
||||||
|
reference: Some("new".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.fetch_interface(®istry, &reference)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.call_operation(new_provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
let moved_interface = registry
|
||||||
|
.register(&moved, reference.name, ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
moved_interface.reference().protocol_reference().scope,
|
||||||
|
"/moved"
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacement_after_fetch_rejects_old_ref_but_omission_and_refless_are_allowed() {
|
||||||
|
for previous_ref in [Some("old"), None] {
|
||||||
|
let (mut registry, old_scope, old_provider) =
|
||||||
|
setup("/scope", "/scope/target", previous_ref);
|
||||||
|
let observed = old_provider
|
||||||
|
.fetch_interface(®istry, &old_provider.request(None).interface.reference)
|
||||||
|
.unwrap();
|
||||||
|
registry.remove_scope(&old_scope);
|
||||||
|
let new_scope = Scope::new("/scope").unwrap();
|
||||||
|
let token = registry
|
||||||
|
.register(
|
||||||
|
&new_scope,
|
||||||
|
"read::#日本語",
|
||||||
|
ReadDefinition,
|
||||||
|
Some(Validator::new(b"descriptor-v1")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let new_provider = Provider::new(&new_scope, token, "/scope/target", None);
|
||||||
|
if let Some(reference) = observed.scope_ref {
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.call_operation(new_provider.request(Some(&reference)))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(new_provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
new_provider
|
||||||
|
.call_operation(new_provider.request(None))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn started_execution_retains_target_and_same_descriptor_through_replacement() {
|
||||||
|
let (registry, old_scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||||
|
let registry = Rc::new(RefCell::new(registry));
|
||||||
|
let current = provider.current.clone();
|
||||||
|
let replacement = Scope::new("/scope").unwrap();
|
||||||
|
let registry_for_action = registry.clone();
|
||||||
|
let replacement_for_action = replacement.clone();
|
||||||
|
*provider.on_start.borrow_mut() = Some(Box::new(move || {
|
||||||
|
let mut registry = registry_for_action.borrow_mut();
|
||||||
|
registry.remove_scope(&old_scope);
|
||||||
|
registry
|
||||||
|
.register(
|
||||||
|
&replacement_for_action,
|
||||||
|
"read::#日本語",
|
||||||
|
CountDefinition,
|
||||||
|
Some(Validator::new(b"descriptor-v2")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
*current.borrow_mut() = Some((
|
||||||
|
replacement_for_action.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: "scope".into(),
|
||||||
|
reference: Some("new".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}));
|
||||||
|
let call = provider.request(Some("old"));
|
||||||
|
let result = provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|descriptor| {
|
||||||
|
assert_eq!(descriptor, &ReadDefinition::descriptor());
|
||||||
|
Ok::<_, ()>(call.arguments)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.result, Value::String("original backend".into()));
|
||||||
|
assert_eq!(provider.calls.get(), 1);
|
||||||
|
assert_eq!(provider.resolutions.get(), 1);
|
||||||
|
let new = provider
|
||||||
|
.fetch_interface(
|
||||||
|
®istry.borrow(),
|
||||||
|
&provider.request(None).interface.reference,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(new.descriptor, CountDefinition::descriptor());
|
||||||
|
assert_eq!(new.scope_ref.as_deref(), Some("new"));
|
||||||
|
// The old bound target can no longer start a call against the new descriptor.
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 1);
|
||||||
|
}
|
||||||
@@ -16,6 +16,6 @@ impl WipObject for Object {
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let search = registry.register(SearchDefinition, None).unwrap();
|
let search = registry.register(&wip_host::Scope::new("/").unwrap(), "search", SearchDefinition, None).unwrap();
|
||||||
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Interface references and scope identity
|
||||||
|
|
||||||
|
Every interface reference is encoded as an exact JSON object with required
|
||||||
|
`scope` and `name` strings, for example `{"scope":"/github","name":"issue.read"}`.
|
||||||
|
This applies to `Object.interfaces` elements, fetch request/response `interface`,
|
||||||
|
and call `interface.reference`. Scope must be a canonical absolute Worldspace
|
||||||
|
path and name must be nonempty. Legacy opaque strings, short-form strings,
|
||||||
|
relative scopes, missing fields, and extra members are rejected. Unicode, `::`,
|
||||||
|
and `#` in names remain literal data; comparison uses decoded field values,
|
||||||
|
independent of JSON member order and escapes. HTTP endpoint prefixes do not
|
||||||
|
change Worldspace scope.
|
||||||
|
|
||||||
|
Fetch responses and call interface targets additionally carry optional
|
||||||
|
`scope_ref`, an opaque string copied from the scope Object's `ref` observation.
|
||||||
|
It is not base64 and is not part of interface identity. Absent values are omitted;
|
||||||
|
null and nonstring values are rejected. Descriptor-independent call metadata
|
||||||
|
validates reference shape, but leaves scope ancestry and identity preconditions
|
||||||
|
to the Host's `InterfaceMismatch` check, preserving protocol error precedence.
|
||||||
|
Invalid success responses remain client-local `InvalidResponse` errors; protocol
|
||||||
|
error envelopes and transport failures retain their existing classification.
|
||||||
|
|
||||||
See the [API documentation](https://docs.rs/wip-http) for codec and failure-boundary details.
|
See the [API documentation](https://docs.rs/wip-http) for codec and failure-boundary details.
|
||||||
|
|
||||||
Licensed under either of Apache-2.0 or MIT at your option.
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
|
|||||||
@@ -296,11 +296,14 @@ pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes a `fetch_interface` HTTP request.
|
/// Encodes a `fetch_interface` HTTP request.
|
||||||
|
///
|
||||||
|
/// Validates reference shape without requiring a target Object path.
|
||||||
pub fn encode_fetch_interface_request(
|
pub fn encode_fetch_interface_request(
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
value: &FetchInterfaceRequest,
|
value: &FetchInterfaceRequest,
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||||
|
value.interface.validate().map_err(CodecError::from)?;
|
||||||
let body = json::serialize(
|
let body = json::serialize(
|
||||||
&json::fetch_interface_request_to_json(value),
|
&json::fetch_interface_request_to_json(value),
|
||||||
BodyKind::Request,
|
BodyKind::Request,
|
||||||
@@ -358,6 +361,10 @@ impl CallOperationMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes descriptor-independent `call_operation` routing metadata.
|
/// Decodes descriptor-independent `call_operation` routing metadata.
|
||||||
|
///
|
||||||
|
/// Reference shape is validated, but scope ancestry and `scope_ref` identity
|
||||||
|
/// are left to Host resolution so `NotFound`, validator failures, and
|
||||||
|
/// `InterfaceMismatch` retain their protocol precedence.
|
||||||
pub fn decode_call_operation_metadata(
|
pub fn decode_call_operation_metadata(
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
|
|||||||
+49
-14
@@ -8,9 +8,9 @@ use serde_json::{Map, Number, Value as Json};
|
|||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object,
|
||||||
ObserveResponse, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BodyKind, CodecError, Limits};
|
use crate::{BodyKind, CodecError, Limits};
|
||||||
@@ -285,8 +285,25 @@ pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest,
|
|||||||
Ok(request)
|
Ok(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn interface_reference_to_json(reference: &InterfaceReference) -> Json {
|
||||||
|
serde_json::json!({"scope": reference.scope, "name": reference.name})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_reference_from_json(
|
||||||
|
value: &Json,
|
||||||
|
field: &str,
|
||||||
|
) -> Result<InterfaceReference, CodecError> {
|
||||||
|
let fields = exact_object(value, field, &["scope", "name"])?;
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: string(required(fields, "scope")?, &qualified(field, "scope"))?,
|
||||||
|
name: string(required(fields, "name")?, &qualified(field, "name"))?,
|
||||||
|
};
|
||||||
|
reference.validate()?;
|
||||||
|
Ok(reference)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json {
|
pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json {
|
||||||
serde_json::json!({"interface": request.interface})
|
serde_json::json!({"interface": interface_reference_to_json(&request.interface)})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn fetch_interface_request_from_json(
|
pub(crate) fn fetch_interface_request_from_json(
|
||||||
@@ -294,7 +311,7 @@ pub(crate) fn fetch_interface_request_from_json(
|
|||||||
) -> Result<FetchInterfaceRequest, CodecError> {
|
) -> Result<FetchInterfaceRequest, CodecError> {
|
||||||
let fields = exact_object(value, "request", &["interface"])?;
|
let fields = exact_object(value, "request", &["interface"])?;
|
||||||
Ok(FetchInterfaceRequest {
|
Ok(FetchInterfaceRequest {
|
||||||
interface: string(required(fields, "interface")?, "interface")?,
|
interface: interface_reference_from_json(required(fields, "interface")?, "interface")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +325,7 @@ pub(crate) fn object_to_json(object: &Object) -> Json {
|
|||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.map(interface_reference_to_json)
|
||||||
.map(Json::String)
|
|
||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -327,7 +343,7 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
|
|||||||
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, value)| string(value, &format!("interfaces[{index}]")))
|
.map(|(index, value)| interface_reference_from_json(value, &format!("interfaces[{index}]")))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let object = Object {
|
let object = Object {
|
||||||
name: string(required(fields, "name")?, "name")?,
|
name: string(required(fields, "name")?, "name")?,
|
||||||
@@ -745,7 +761,11 @@ pub(crate) fn descriptor_from_json(
|
|||||||
|
|
||||||
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
||||||
let mut fields = Map::new();
|
let mut fields = Map::new();
|
||||||
fields.insert("interface".into(), Json::String(response.interface.clone()));
|
fields.insert(
|
||||||
|
"interface".into(),
|
||||||
|
interface_reference_to_json(&response.interface),
|
||||||
|
);
|
||||||
|
insert_optional_string(&mut fields, "scope_ref", &response.scope_ref);
|
||||||
fields.insert(
|
fields.insert(
|
||||||
"descriptor".into(),
|
"descriptor".into(),
|
||||||
descriptor_to_json(&response.descriptor),
|
descriptor_to_json(&response.descriptor),
|
||||||
@@ -757,8 +777,15 @@ pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse
|
|||||||
pub(crate) fn fetch_interface_response_from_json(
|
pub(crate) fn fetch_interface_response_from_json(
|
||||||
value: &Json,
|
value: &Json,
|
||||||
) -> Result<FetchInterfaceResponse, CodecError> {
|
) -> Result<FetchInterfaceResponse, CodecError> {
|
||||||
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
|
let fields = exact_object(
|
||||||
let interface = string(required(fields, "interface")?, "interface")?;
|
value,
|
||||||
|
"response",
|
||||||
|
&["interface", "scope_ref", "descriptor", "validator"],
|
||||||
|
)?;
|
||||||
|
let interface = interface_reference_from_json(required(fields, "interface")?, "interface")?;
|
||||||
|
let scope_ref = optional(fields, "scope_ref")?
|
||||||
|
.map(|value| string(value, "scope_ref"))
|
||||||
|
.transpose()?;
|
||||||
let validator = optional(fields, "validator")?
|
let validator = optional(fields, "validator")?
|
||||||
.map(|value| base64_decode(value, "validator"))
|
.map(|value| base64_decode(value, "validator"))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
@@ -780,6 +807,7 @@ pub(crate) fn fetch_interface_response_from_json(
|
|||||||
};
|
};
|
||||||
Ok(FetchInterfaceResponse {
|
Ok(FetchInterfaceResponse {
|
||||||
interface,
|
interface,
|
||||||
|
scope_ref,
|
||||||
descriptor,
|
descriptor,
|
||||||
validator,
|
validator,
|
||||||
})
|
})
|
||||||
@@ -1121,18 +1149,25 @@ fn target_from_json(value: &Json, field: &str) -> Result<Target, CodecError> {
|
|||||||
|
|
||||||
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
||||||
let mut fields = Map::new();
|
let mut fields = Map::new();
|
||||||
fields.insert("reference".into(), Json::String(target.reference.clone()));
|
fields.insert(
|
||||||
|
"reference".into(),
|
||||||
|
interface_reference_to_json(&target.reference),
|
||||||
|
);
|
||||||
|
insert_optional_string(&mut fields, "scope_ref", &target.scope_ref);
|
||||||
insert_optional_bytes(&mut fields, "validator", &target.validator);
|
insert_optional_bytes(&mut fields, "validator", &target.validator);
|
||||||
Json::Object(fields)
|
Json::Object(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interface_target_from_json(value: &Json, field: &str) -> Result<InterfaceTarget, CodecError> {
|
fn interface_target_from_json(value: &Json, field: &str) -> Result<InterfaceTarget, CodecError> {
|
||||||
let fields = exact_object(value, field, &["reference", "validator"])?;
|
let fields = exact_object(value, field, &["reference", "scope_ref", "validator"])?;
|
||||||
Ok(InterfaceTarget {
|
Ok(InterfaceTarget {
|
||||||
reference: string(
|
reference: interface_reference_from_json(
|
||||||
required(fields, "reference")?,
|
required(fields, "reference")?,
|
||||||
&qualified(field, "reference"),
|
&qualified(field, "reference"),
|
||||||
)?,
|
)?,
|
||||||
|
scope_ref: optional(fields, "scope_ref")?
|
||||||
|
.map(|value| string(value, &qualified(field, "scope_ref")))
|
||||||
|
.transpose()?,
|
||||||
validator: optional(fields, "validator")?
|
validator: optional(fields, "validator")?
|
||||||
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
||||||
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
||||||
//!
|
//!
|
||||||
|
//! Interface references use exact JSON objects with required `scope` and `name`
|
||||||
|
//! strings in every position. Legacy strings and display shorthand are rejected.
|
||||||
|
//! Scope is a canonical absolute Worldspace path, not an HTTP endpoint prefix.
|
||||||
|
//! Optional `scope_ref` metadata on fetch responses and call interface targets is
|
||||||
|
//! an opaque string, never base64; absent fields are omitted and null is rejected.
|
||||||
|
//! Call metadata validates reference shape without resolving scope ancestry or
|
||||||
|
//! identity, which remain Host checks with `InterfaceMismatch` precedence.
|
||||||
|
//!
|
||||||
//! ```
|
//! ```
|
||||||
//! use wip_http::{Endpoint, Limits, encode_observe_request};
|
//! use wip_http::{Endpoint, Limits, encode_observe_request};
|
||||||
//! use wip_protocol::ObserveRequest;
|
//! use wip_protocol::ObserveRequest;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use wip_http::http::StatusCode;
|
|||||||
use wip_http::wip_protocol::{
|
use wip_http::wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation,
|
||||||
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
|
ObserveRequest, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||||
TypeExpr, UnionCase, Value,
|
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
use wip_http::{
|
use wip_http::{
|
||||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||||
@@ -25,11 +25,18 @@ fn endpoint() -> Endpoint {
|
|||||||
Endpoint::parse("https://example.test/wip").unwrap()
|
Endpoint::parse("https://example.test/wip").unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn object(name: &str, validator: &[u8]) -> Object {
|
fn object(name: &str, validator: &[u8]) -> Object {
|
||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some("description".into()),
|
description: Some("description".into()),
|
||||||
interfaces: vec!["interface-v1".into()],
|
interfaces: vec![reference("/", "interface-v1")],
|
||||||
r#ref: Some(format!("object:{name}")),
|
r#ref: Some(format!("object:{name}")),
|
||||||
validator: Some(validator.to_vec()),
|
validator: Some(validator.to_vec()),
|
||||||
}
|
}
|
||||||
@@ -152,7 +159,8 @@ fn call_request() -> CallOperationRequest {
|
|||||||
validator: Some(vec![0, 1, 2, 255]),
|
validator: Some(vec![0, 1, 2, 255]),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: "interface-v1".into(),
|
reference: reference("/", "interface-v1"),
|
||||||
|
scope_ref: None,
|
||||||
validator: Some(vec![9, 8, 7]),
|
validator: Some(vec![9, 8, 7]),
|
||||||
},
|
},
|
||||||
operation: "everything".into(),
|
operation: "everything".into(),
|
||||||
@@ -206,7 +214,7 @@ fn every_single_interaction_request_accepts_json() {
|
|||||||
let fetch_interface = encode_fetch_interface_request(
|
let fetch_interface = encode_fetch_interface_request(
|
||||||
&endpoint(),
|
&endpoint(),
|
||||||
&FetchInterfaceRequest {
|
&FetchInterfaceRequest {
|
||||||
interface: "interface-v1".into(),
|
interface: reference("/", "interface-v1"),
|
||||||
},
|
},
|
||||||
limits(),
|
limits(),
|
||||||
)
|
)
|
||||||
@@ -286,7 +294,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let interface_request = FetchInterfaceRequest {
|
let interface_request = FetchInterfaceRequest {
|
||||||
interface: "interface-v1".into(),
|
interface: reference("/", "interface-v1"),
|
||||||
};
|
};
|
||||||
let encoded_request =
|
let encoded_request =
|
||||||
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
||||||
@@ -296,6 +304,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
|||||||
);
|
);
|
||||||
let interface = FetchInterfaceResponse {
|
let interface = FetchInterfaceResponse {
|
||||||
interface: interface_request.interface.clone(),
|
interface: interface_request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: descriptor(),
|
descriptor: descriptor(),
|
||||||
validator: Some(vec![4, 5, 6, 255]),
|
validator: Some(vec![4, 5, 6, 255]),
|
||||||
};
|
};
|
||||||
@@ -383,7 +392,7 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
|||||||
let descriptor = descriptor();
|
let descriptor = descriptor();
|
||||||
let body = serde_json::to_vec(&serde_json::json!({
|
let body = serde_json::to_vec(&serde_json::json!({
|
||||||
"target": {"path": "/items/123", "validator": null},
|
"target": {"path": "/items/123", "validator": null},
|
||||||
"interface": {"reference": "interface-v1"},
|
"interface": {"reference": {"scope": "/", "name": "interface-v1"}},
|
||||||
"operation": "everything",
|
"operation": "everything",
|
||||||
"arguments": {}
|
"arguments": {}
|
||||||
}))
|
}))
|
||||||
@@ -443,10 +452,11 @@ fn caller_limits_remain_local_codec_failures() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unsupported_descriptor_format_is_client_local() {
|
fn unsupported_descriptor_format_is_client_local() {
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: "interface-v1".into(),
|
interface: reference("/", "interface-v1"),
|
||||||
};
|
};
|
||||||
let mut interface = FetchInterfaceResponse {
|
let mut interface = FetchInterfaceResponse {
|
||||||
interface: request.interface.clone(),
|
interface: request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: descriptor(),
|
descriptor: descriptor(),
|
||||||
validator: None,
|
validator: None,
|
||||||
};
|
};
|
||||||
@@ -462,7 +472,7 @@ fn unsupported_descriptor_format_is_client_local() {
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("content-type", "application/json; charset=utf-8")
|
.header("content-type", "application/json; charset=utf-8")
|
||||||
.body(
|
.body(
|
||||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
||||||
.to_vec(),
|
.to_vec(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -473,8 +483,8 @@ fn unsupported_descriptor_format_is_client_local() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
for malformed in [
|
for malformed in [
|
||||||
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
br#"{"interface":{"scope":"/","name":"wrong"},"descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||||
.as_slice(),
|
.as_slice(),
|
||||||
] {
|
] {
|
||||||
let response = wip_http::http::Response::builder()
|
let response = wip_http::http::Response::builder()
|
||||||
|
|||||||
@@ -0,0 +1,740 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
use wip_http::http::{Response, StatusCode};
|
||||||
|
use wip_http::wip_protocol::{
|
||||||
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||||
|
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||||
|
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||||
|
ReturnDeclaration, Target, TypeExpr,
|
||||||
|
};
|
||||||
|
use wip_http::{
|
||||||
|
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||||
|
TransportFailureKind, decode_call_operation_metadata, decode_call_operation_request,
|
||||||
|
decode_call_operation_response, decode_fetch_interface_request,
|
||||||
|
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
|
||||||
|
encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_response,
|
||||||
|
encode_protocol_error_response,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn limits() -> Limits {
|
||||||
|
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint() -> Endpoint {
|
||||||
|
Endpoint::parse("https://example.test/deployment/prefix").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.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::Unit,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(reference: InterfaceReference, scope_ref: Option<String>) -> CallOperationRequest {
|
||||||
|
CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/世界::#/item".into(),
|
||||||
|
validator: Some(vec![0, 255]),
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref,
|
||||||
|
validator: Some(vec![1, 255]),
|
||||||
|
},
|
||||||
|
operation: "run#::操作".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observation(references: Vec<InterfaceReference>) -> ObjectObservation {
|
||||||
|
ObjectObservation {
|
||||||
|
object: Object {
|
||||||
|
name: "item".into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: references,
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_request() -> ObserveRequest {
|
||||||
|
ObserveRequest {
|
||||||
|
path: "/世界::#/item".into(),
|
||||||
|
depth: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(status: StatusCode, value: Json) -> Response<Vec<u8>> {
|
||||||
|
raw_response(status, &serde_json::to_vec(&value).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body.to_vec())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_json(interface: Json) -> Json {
|
||||||
|
json!({
|
||||||
|
"interface": interface,
|
||||||
|
"descriptor": {"format": INTERFACE_FORMAT_V1, "types": [], "operations": []}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_json(reference: Json) -> Json {
|
||||||
|
json!({
|
||||||
|
"target": {"path": "/世界::#/item"},
|
||||||
|
"interface": {"reference": reference},
|
||||||
|
"operation": "run#::操作",
|
||||||
|
"arguments": {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_success<T: std::fmt::Debug>(result: Result<T, ClientResponseError>) {
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::InvalidSuccessBody,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_structured_reference_is_encoded_and_round_tripped_in_every_position() {
|
||||||
|
let interface_ref = reference("/世界::#", "é::接口#read/with\"quote\\slash");
|
||||||
|
let expected = json!({"scope": interface_ref.scope, "name": interface_ref.name});
|
||||||
|
let fetch_request = FetchInterfaceRequest {
|
||||||
|
interface: interface_ref.clone(),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_request(&endpoint(), &fetch_request, limits()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
encoded.uri().path(),
|
||||||
|
"/deployment/prefix/v1/fetch_interface"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<Json>(encoded.body()).unwrap(),
|
||||||
|
json!({"interface": expected})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||||
|
fetch_request
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetch_response = FetchInterfaceResponse {
|
||||||
|
interface: interface_ref.clone(),
|
||||||
|
scope_ref: Some("opaque:世界::# not base64!".into()),
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: Some(vec![1, 255]),
|
||||||
|
};
|
||||||
|
let encoded =
|
||||||
|
encode_fetch_interface_response(&fetch_request, &fetch_response, limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(body["interface"], expected);
|
||||||
|
assert_eq!(body["scope_ref"], "opaque:世界::# not base64!");
|
||||||
|
assert_eq!(body["validator"], "Af8=");
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_response(&fetch_request, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(fetch_response)
|
||||||
|
);
|
||||||
|
|
||||||
|
let observation = observation(vec![interface_ref.clone(), reference("/", "root")]);
|
||||||
|
let encoded = encode_observe_response(&observe_request(), &observation, limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body["object"]["interfaces"],
|
||||||
|
json!([expected, {"scope": "/", "name": "root"}])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_observe_response(&observe_request(), &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(observation)
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = call(interface_ref, Some("opaque:世界::# not base64!".into()));
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(body["interface"]["reference"], expected);
|
||||||
|
assert_eq!(body["interface"]["scope_ref"], "opaque:世界::# not base64!");
|
||||||
|
assert_eq!(body["interface"]["validator"], "Af8=");
|
||||||
|
assert_eq!(body["target"]["validator"], "AP8=");
|
||||||
|
assert_eq!(body["operation"], "run#::操作");
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_metadata(encoded.body(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface,
|
||||||
|
request.interface
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_reference_corpus_is_rejected_in_all_positions() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "interface-v1"),
|
||||||
|
};
|
||||||
|
for bad in [
|
||||||
|
json!("interface-v1"),
|
||||||
|
json!("/::interface-v1"),
|
||||||
|
json!("interface-v1#run"),
|
||||||
|
Json::Null,
|
||||||
|
json!(false),
|
||||||
|
json!(1),
|
||||||
|
json!([]),
|
||||||
|
json!({}),
|
||||||
|
json!({"name": "interface-v1"}),
|
||||||
|
json!({"scope": "/"}),
|
||||||
|
json!({"scope": null, "name": "interface-v1"}),
|
||||||
|
json!({"scope": 1, "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/", "name": null}),
|
||||||
|
json!({"scope": "/", "name": []}),
|
||||||
|
json!({"scope": "/", "name": ""}),
|
||||||
|
json!({"scope": "/", "name": "interface-v1", "extra": true}),
|
||||||
|
json!({"scope": "/", "name": "interface-v1", "scope_ref": "identity"}),
|
||||||
|
json!({"Scope": "/", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "relative", "name": "interface-v1"}),
|
||||||
|
json!({"scope": ".", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "..", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a/", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "//a", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a//b", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a/./b", "name": "interface-v1"}),
|
||||||
|
json!({"scope": "/a/../b", "name": "interface-v1"}),
|
||||||
|
] {
|
||||||
|
let body = serde_json::to_vec(&json!({"interface": bad})).unwrap();
|
||||||
|
assert!(
|
||||||
|
decode_fetch_interface_request(&body, limits()).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
let body = serde_json::to_vec(&call_json(bad.clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
decode_call_operation_metadata(&body, limits()).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decode_call_operation_request(&body, &descriptor(), limits()).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, fetch_json(bad.clone())),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let mut future = fetch_json(bad.clone());
|
||||||
|
future["descriptor"] = json!({"format": "future-format", "future": true});
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, future),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&observe_request(),
|
||||||
|
&response(
|
||||||
|
StatusCode::OK,
|
||||||
|
json!({"object": {"name": "item", "interfaces": [bad]}}),
|
||||||
|
),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_reference_members_are_rejected_even_when_escaped_or_equal() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
for bad in [
|
||||||
|
r#"{"scope":"/","scope":"/","name":"x"}"#,
|
||||||
|
r#"{"scope":"/","name":"x","name":"x"}"#,
|
||||||
|
r#"{"scope":"/","name":"x","na\u006de":"x"}"#,
|
||||||
|
] {
|
||||||
|
let fetch = format!(r#"{{"interface":{bad}}}"#);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
let call = format!(
|
||||||
|
r#"{{"target":{{"path":"/"}},"interface":{{"reference":{bad}}},"operation":"run","arguments":{{}}}}"#
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_metadata(call.as_bytes(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_request(call.as_bytes(), &descriptor(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
let fetch_response = format!(
|
||||||
|
r#"{{"interface":{bad},"descriptor":{{"format":"wip-interface/1","types":[],"operations":[]}}}}"#
|
||||||
|
);
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&raw_response(StatusCode::OK, fetch_response.as_bytes()),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let observe = format!(r#"{{"object":{{"name":"item","interfaces":[{bad}]}}}}"#);
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&observe_request(),
|
||||||
|
&raw_response(StatusCode::OK, observe.as_bytes()),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_order_and_json_escapes_do_not_change_reference_identity() {
|
||||||
|
let expected = reference("/世界::#", "é::接口#");
|
||||||
|
let raw_reference = r#"{"name":"\u00e9::\u63a5\u53e3#","scope":"\/\u4e16\u754c::#"}"#;
|
||||||
|
let fetch = format!(r#"{{"interface":{raw_reference}}}"#);
|
||||||
|
let request = decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap();
|
||||||
|
assert_eq!(request.interface, expected);
|
||||||
|
let fetch_response = format!(
|
||||||
|
r#"{{"descriptor":{{"operations":[],"types":[],"format":"wip-interface/1"}},"interface":{raw_reference}}}"#
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &raw_response(StatusCode::OK, fetch_response.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.interface == expected)
|
||||||
|
);
|
||||||
|
let call = format!(
|
||||||
|
r#"{{"arguments":{{}},"operation":"run#::操作","interface":{{"reference":{raw_reference}}},"target":{{"path":"/世界::#/item"}}}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_metadata(call.as_bytes(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface
|
||||||
|
.reference,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(call.as_bytes(), &descriptor(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface
|
||||||
|
.reference,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let observe = format!(r#"{{"object":{{"interfaces":[{raw_reference}],"name":"item"}}}}"#);
|
||||||
|
assert!(
|
||||||
|
matches!(decode_observe_response(&observe_request(), &raw_response(StatusCode::OK, observe.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.object.interfaces == vec![expected])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_comparison_is_exact_without_normalization_case_or_url_decoding() {
|
||||||
|
for different in [
|
||||||
|
reference("/", "e\u{301}"),
|
||||||
|
reference("/", "É"),
|
||||||
|
reference("/", "%C3%A9"),
|
||||||
|
reference("/other", "é"),
|
||||||
|
] {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "é"),
|
||||||
|
};
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(
|
||||||
|
StatusCode::OK,
|
||||||
|
fetch_json(json!({"scope": different.scope, "name": different.name})),
|
||||||
|
),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/%2E", "é"),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "."),
|
||||||
|
};
|
||||||
|
assert!(encode_fetch_interface_request(&endpoint(), &request, limits()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_encoding_rejects_invalid_reference_shape_without_needing_a_fetch_target() {
|
||||||
|
for bad in [
|
||||||
|
reference("relative", "x"),
|
||||||
|
reference("", "x"),
|
||||||
|
reference("/a/../b", "x"),
|
||||||
|
reference("/", ""),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
encode_fetch_interface_request(
|
||||||
|
&endpoint(),
|
||||||
|
&FetchInterfaceRequest {
|
||||||
|
interface: bad.clone()
|
||||||
|
},
|
||||||
|
limits()
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
encode_call_operation_request(&endpoint(), &call(bad, None), &descriptor(), limits())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/unrelated/scope", "x"),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_metadata_leaves_valid_out_of_scope_references_and_identity_to_host_resolution() {
|
||||||
|
let request = call(
|
||||||
|
reference("/elsewhere", "x"),
|
||||||
|
Some("not-current-identity".into()),
|
||||||
|
);
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||||
|
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||||
|
assert_eq!(metadata.interface, request.interface);
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.decode_request(encoded.body(), &descriptor(), limits())
|
||||||
|
.unwrap(),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neither an unknown operation nor descriptor-bound argument values are
|
||||||
|
// resolved by the metadata boundary before Host precondition checks.
|
||||||
|
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
body["operation"] = json!("missing");
|
||||||
|
body["arguments"] = json!({"not_declared": [null, {"anything": true}]});
|
||||||
|
let metadata =
|
||||||
|
decode_call_operation_metadata(&serde_json::to_vec(&body).unwrap(), limits()).unwrap();
|
||||||
|
assert_eq!(metadata.operation, "missing");
|
||||||
|
assert_eq!(metadata.interface, request.interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observation_scope_validation_uses_each_nodes_path_and_structural_duplicates() {
|
||||||
|
let request = ObserveRequest {
|
||||||
|
path: "/a".into(),
|
||||||
|
depth: 1,
|
||||||
|
};
|
||||||
|
let valid = json!({
|
||||||
|
"object": {"name": "a", "interfaces": [{"scope": "/", "name": "x"}, {"scope": "/a", "name": "x"}]},
|
||||||
|
"children": [{"object": {"name": "b", "interfaces": [{"scope": "/a/b", "name": "x"}]}}]
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
decode_observe_response(&request, &response(StatusCode::OK, valid.clone()), limits())
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
for scope in ["/ab", "/elsewhere", "/a/b/c"] {
|
||||||
|
let mut bad = valid.clone();
|
||||||
|
bad["children"][0]["object"]["interfaces"][0]["scope"] = json!(scope);
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, bad),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut duplicate = valid;
|
||||||
|
duplicate["object"]["interfaces"] =
|
||||||
|
json!([{"scope": "/a", "name": "x"}, {"name": "x", "scope": "/a"}]);
|
||||||
|
invalid_success(decode_observe_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, duplicate),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let root_request = ObserveRequest {
|
||||||
|
path: "/".into(),
|
||||||
|
depth: 0,
|
||||||
|
};
|
||||||
|
for (scope, valid) in [("/", true), ("/a", false)] {
|
||||||
|
let result = decode_observe_response(
|
||||||
|
&root_request,
|
||||||
|
&response(
|
||||||
|
StatusCode::OK,
|
||||||
|
json!({"object": {"name": "", "interfaces": [{"scope": scope, "name": "x"}]}}),
|
||||||
|
),
|
||||||
|
limits(),
|
||||||
|
);
|
||||||
|
if valid {
|
||||||
|
assert!(result.is_ok());
|
||||||
|
} else {
|
||||||
|
invalid_success(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ref_omission_and_opaque_strings_round_trip_in_both_positions() {
|
||||||
|
for scope_ref in [
|
||||||
|
None,
|
||||||
|
Some(String::new()),
|
||||||
|
Some("opaque::# 世界 ! not base64 /+=".into()),
|
||||||
|
Some("\"\\\n\u{0}".into()),
|
||||||
|
] {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
let expected = FetchInterfaceResponse {
|
||||||
|
interface: request.interface.clone(),
|
||||||
|
scope_ref: scope_ref.clone(),
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: None,
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_response(&request, &expected, limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body.get("scope_ref"),
|
||||||
|
scope_ref
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| Json::String(value.clone()))
|
||||||
|
.as_ref()
|
||||||
|
);
|
||||||
|
assert!(body.get("validator").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(expected)
|
||||||
|
);
|
||||||
|
let call = call(request.interface, scope_ref);
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &call, &descriptor(), limits()).unwrap();
|
||||||
|
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body["interface"].get("scope_ref"),
|
||||||
|
call.interface
|
||||||
|
.scope_ref
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| Json::String(value.clone()))
|
||||||
|
.as_ref()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_metadata(encoded.body(), limits())
|
||||||
|
.unwrap()
|
||||||
|
.interface,
|
||||||
|
call.interface
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||||
|
call
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ref_null_and_every_nonstring_type_are_rejected_before_descriptor_resolution() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
for bad in [Json::Null, json!(true), json!(0), json!([]), json!({})] {
|
||||||
|
let mut fetch = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||||
|
fetch["scope_ref"] = bad.clone();
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, fetch.clone()),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
// Unknown format does not mask malformed common response fields.
|
||||||
|
fetch["descriptor"] = json!({"format": "future-format", "future": true});
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&response(StatusCode::OK, fetch),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let mut call = call_json(json!({"scope": "/", "name": "x"}));
|
||||||
|
call["interface"]["scope_ref"] = bad.clone();
|
||||||
|
let body = serde_json::to_vec(&call).unwrap();
|
||||||
|
let error = decode_call_operation_metadata(&body, limits()).unwrap_err();
|
||||||
|
if bad.is_null() {
|
||||||
|
assert!(matches!(error, CodecError::NullOptionalField { .. }));
|
||||||
|
} else {
|
||||||
|
assert!(matches!(error, CodecError::InvalidField { .. }));
|
||||||
|
}
|
||||||
|
assert!(decode_call_operation_request(&body, &descriptor(), limits()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ref_is_only_allowed_on_fetch_responses_and_call_interface_targets() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
let reference = json!({"scope": "/", "name": "x"});
|
||||||
|
let fetch_request = json!({"interface": reference, "scope_ref": "identity"});
|
||||||
|
assert!(
|
||||||
|
decode_fetch_interface_request(&serde_json::to_vec(&fetch_request).unwrap(), limits())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut call = call_json(reference.clone());
|
||||||
|
call["scope_ref"] = json!("identity");
|
||||||
|
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||||
|
let mut call = call_json(reference);
|
||||||
|
call["target"]["scope_ref"] = json!("identity");
|
||||||
|
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||||
|
|
||||||
|
let duplicate = br#"{"interface":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity","descriptor":{"format":"wip-interface/1","types":[],"operations":[]}}"#;
|
||||||
|
invalid_success(decode_fetch_interface_response(
|
||||||
|
&request,
|
||||||
|
&raw_response(StatusCode::OK, duplicate),
|
||||||
|
limits(),
|
||||||
|
));
|
||||||
|
let duplicate = br#"{"target":{"path":"/"},"interface":{"reference":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity"},"operation":"run#::\u64cd\u4f5c","arguments":{}}"#;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_metadata(duplicate, limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decode_call_operation_request(duplicate, &descriptor(), limits()).unwrap_err(),
|
||||||
|
CodecError::InvalidJson(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_correspondence_includes_reference_and_scope_ref_but_not_wire_order() {
|
||||||
|
let request = call(reference("/", "x"), Some("identity".into()));
|
||||||
|
let encoded =
|
||||||
|
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||||
|
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||||
|
for (field, changed) in [
|
||||||
|
("scope_ref", json!("other")),
|
||||||
|
("reference", json!({"scope": "/", "name": "other"})),
|
||||||
|
] {
|
||||||
|
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
body["interface"][field] = changed;
|
||||||
|
assert!(
|
||||||
|
matches!(metadata.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits()).unwrap_err(), CodecError::InvalidField { field, .. } if field == "request")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||||
|
body["interface"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("scope_ref");
|
||||||
|
assert!(
|
||||||
|
metadata
|
||||||
|
.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_classification_preserves_transport_status_and_error_envelope_semantics() {
|
||||||
|
let request = FetchInterfaceRequest {
|
||||||
|
interface: reference("/", "x"),
|
||||||
|
};
|
||||||
|
let valid = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||||
|
for (status, kind) in [
|
||||||
|
(
|
||||||
|
StatusCode::CREATED,
|
||||||
|
InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
InvalidResponseKind::SuccessBodyOnErrorStatus,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &response(status, valid.clone()), limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: actual, .. } if actual == kind)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for status in [
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
] {
|
||||||
|
let malformed = response(status, fetch_json(json!("legacy")));
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &malformed, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.status == status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
|
message: "unpublished".into(),
|
||||||
|
};
|
||||||
|
let mut encoded =
|
||||||
|
encode_protocol_error_response(ProtocolInteraction::FetchInterface, &error, limits())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error: error.clone(),
|
||||||
|
status_mismatch: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
*encoded.status_mut() = StatusCode::BAD_GATEWAY;
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: actual, status_mismatch: Some(_) } if actual == error)
|
||||||
|
);
|
||||||
|
*encoded.status_mut() = StatusCode::OK;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_response(&request, &encoded, limits()).unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::ErrorEnvelopeOnSuccessStatus,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let disallowed = response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({"error": {"code": "interface_mismatch", "message": "wrong interaction"}}),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_interface_response(&request, &disallowed, limits()).unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::DisallowedProtocolCode,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let mut coded = response(StatusCode::OK, valid);
|
||||||
|
coded
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-encoding", "gzip".parse().unwrap());
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fetch_interface_response(&request, &coded, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.kind == TransportFailureKind::UnsupportedContentCoding)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_interface_mismatch_envelope_remains_protocol_failure_with_structured_metadata() {
|
||||||
|
let request = call(reference("/unrelated", "x"), Some("stale identity".into()));
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceMismatch,
|
||||||
|
message: "interface precondition".into(),
|
||||||
|
};
|
||||||
|
let encoded =
|
||||||
|
encode_protocol_error_response(ProtocolInteraction::CallOperation, &error, limits())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_call_operation_response(&request, &descriptor(), &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error,
|
||||||
|
status_mismatch: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,15 @@ This crate defines the canonical Object and Interface Descriptor models, the thr
|
|||||||
wip-protocol = "0.1.0"
|
wip-protocol = "0.1.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
||||||
|
`scope` is a canonical absolute Worldspace path and `name` is nonempty Unicode.
|
||||||
|
Use `validate()` for shape, `validate_for_path()` for ancestor-or-self placement,
|
||||||
|
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.
|
||||||
|
|
||||||
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
||||||
|
|
||||||
Licensed under either of Apache-2.0 or MIT at your option.
|
Licensed under either of Apache-2.0 or MIT at your option.
|
||||||
|
|||||||
@@ -143,8 +143,17 @@ pub enum ValidationErrorKind {
|
|||||||
},
|
},
|
||||||
/// An object's ordered interface set contains a duplicate.
|
/// An object's ordered interface set contains a duplicate.
|
||||||
DuplicateInterface {
|
DuplicateInterface {
|
||||||
/// Duplicate opaque interface reference.
|
/// Duplicate structured interface reference.
|
||||||
reference: String,
|
reference: crate::InterfaceReference,
|
||||||
|
},
|
||||||
|
/// The local interface name is empty.
|
||||||
|
EmptyInterfaceName,
|
||||||
|
/// A valid scope is not an ancestor-or-self of the target path.
|
||||||
|
InterfaceScopeMismatch {
|
||||||
|
/// Selected scope path.
|
||||||
|
scope: String,
|
||||||
|
/// Target Object path.
|
||||||
|
path: String,
|
||||||
},
|
},
|
||||||
/// A string is not a canonical absolute Worldspace path.
|
/// A string is not a canonical absolute Worldspace path.
|
||||||
InvalidPath {
|
InvalidPath {
|
||||||
@@ -175,10 +184,10 @@ pub enum ValidationErrorKind {
|
|||||||
},
|
},
|
||||||
/// A response refers to a different interface than its request.
|
/// A response refers to a different interface than its request.
|
||||||
InterfaceResponseMismatch {
|
InterfaceResponseMismatch {
|
||||||
/// Requested opaque reference.
|
/// Requested structured reference (boxed to keep validation errors small).
|
||||||
expected: String,
|
expected: Box<crate::InterfaceReference>,
|
||||||
/// Returned opaque reference.
|
/// Returned structured reference (boxed to keep validation errors small).
|
||||||
actual: String,
|
actual: Box<crate::InterfaceReference>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +235,7 @@ impl Error for ValidationError {}
|
|||||||
pub enum ProtocolInteraction {
|
pub enum ProtocolInteraction {
|
||||||
/// Observe one object and its indexable descendants to a requested depth.
|
/// Observe one object and its indexable descendants to a requested depth.
|
||||||
Observe,
|
Observe,
|
||||||
/// Fetch one interface descriptor by its opaque reference.
|
/// Fetch one interface descriptor by its structured reference.
|
||||||
FetchInterface,
|
FetchInterface,
|
||||||
/// Invoke one operation on a path-targeted object.
|
/// Invoke one operation on a path-targeted object.
|
||||||
CallOperation,
|
CallOperation,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
//! guarantee; Clients must not automatically retry it.
|
//! guarantee; Clients must not automatically retry it.
|
||||||
//!
|
//!
|
||||||
//! Paths, object refs, interface refs, and validators remain separate concepts.
|
//! Paths, object refs, interface refs, and validators remain separate concepts.
|
||||||
//! They are represented by ordinary protocol strings/bytes in the corresponding
|
//! Interface refs use [`InterfaceReference`]; other metadata use strings/bytes in their
|
||||||
//! fields. In particular, object refs are optional, operation targeting uses a
|
//! fields. In particular, object refs are optional, operation targeting uses a
|
||||||
//! path, and this crate defines no interaction that fetches an object by ref.
|
//! path, and this crate defines no interaction that fetches an object by ref.
|
||||||
//!
|
//!
|
||||||
@@ -52,9 +52,10 @@ pub use error::{
|
|||||||
};
|
};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object,
|
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceReference,
|
||||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
InterfaceTarget, Object, ObjectObservation, ObserveRequest, ObserveResponse,
|
||||||
ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target,
|
||||||
|
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Validates a canonical absolute Worldspace path.
|
/// Validates a canonical absolute Worldspace path.
|
||||||
|
|||||||
@@ -11,9 +11,49 @@ pub struct Documentation {
|
|||||||
pub details: Option<String>,
|
pub details: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An interface identified by an exact `(scope, name)` pair in one Worldspace.
|
||||||
|
///
|
||||||
|
/// Neither field is normalized, case-folded, URL-decoded, or split on display
|
||||||
|
/// delimiters. Descriptor identity is external to the descriptor itself.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::{FetchInterfaceRequest, InterfaceReference};
|
||||||
|
/// let reference = InterfaceReference {
|
||||||
|
/// scope: "/世::界/#".into(),
|
||||||
|
/// name: "read::書く#λ".into(),
|
||||||
|
/// };
|
||||||
|
/// reference.validate_for_path("/世::界/#/item")?;
|
||||||
|
/// // Fetching is independent of an Object target path.
|
||||||
|
/// FetchInterfaceRequest { interface: reference }.validate()?;
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct InterfaceReference {
|
||||||
|
/// Canonical absolute Worldspace path of the interface's scope Object.
|
||||||
|
pub scope: String,
|
||||||
|
/// Nonempty Unicode local name, with no delimiter restrictions.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterfaceReference {
|
||||||
|
/// Validates reference shape without requiring a target Object path.
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
crate::validation::validate_interface_reference(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates shape and that scope is an ancestor-or-self of `path`.
|
||||||
|
///
|
||||||
|
/// This is independent of indexability. A Host maps a valid reference's
|
||||||
|
/// scope mismatch to `InterfaceMismatch` at the interface-selection stage,
|
||||||
|
/// not to the earlier shape-validation `InvalidRequest` stage.
|
||||||
|
pub fn validate_for_path(&self, path: &str) -> Result<(), ValidationError> {
|
||||||
|
crate::validation::validate_interface_scope(self, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The protocol projection currently published at one Worldspace path.
|
/// The protocol projection currently published at one Worldspace path.
|
||||||
///
|
///
|
||||||
/// `name` is a path segment, `interfaces` is an ordered set of opaque interface
|
/// `name` is a path segment, `interfaces` is an ordered set of structured interface
|
||||||
/// references, `r#ref` is optional object identity, and `validator` is an
|
/// references, `r#ref` is optional object identity, and `validator` is an
|
||||||
/// independent optional observation precondition. In particular, neither the
|
/// independent optional observation precondition. In particular, neither the
|
||||||
/// path nor `r#ref` is a mandatory object identifier.
|
/// path nor `r#ref` is a mandatory object identifier.
|
||||||
@@ -23,8 +63,8 @@ pub struct Object {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// Short public description of the object.
|
/// Short public description of the object.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Opaque interface references in host publication order.
|
/// Structured interface references in host publication order.
|
||||||
pub interfaces: Vec<String>,
|
pub interfaces: Vec<InterfaceReference>,
|
||||||
/// Optional opaque identity within one client-facing Worldspace.
|
/// Optional opaque identity within one client-facing Worldspace.
|
||||||
pub r#ref: Option<String>,
|
pub r#ref: Option<String>,
|
||||||
/// Optional opaque validator for the object's observed state.
|
/// Optional opaque validator for the object's observed state.
|
||||||
@@ -239,18 +279,20 @@ pub struct ObjectObservation {
|
|||||||
/// Successful `observe` response.
|
/// Successful `observe` response.
|
||||||
pub type ObserveResponse = ObjectObservation;
|
pub type ObserveResponse = ObjectObservation;
|
||||||
|
|
||||||
/// Request for the current descriptor of one opaque interface reference.
|
/// Request for the current descriptor of one structured interface reference.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FetchInterfaceRequest {
|
pub struct FetchInterfaceRequest {
|
||||||
/// Opaque interface reference obtained from an object.
|
/// Structured interface reference obtained from an object.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Successful `fetch_interface` response.
|
/// Successful `fetch_interface` response.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FetchInterfaceResponse {
|
pub struct FetchInterfaceResponse {
|
||||||
/// Opaque interface reference corresponding to the request.
|
/// Structured interface reference corresponding to the request.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
|
/// Optional opaque ref of the scope Object, not part of interface identity.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Current validated descriptor.
|
/// Current validated descriptor.
|
||||||
pub descriptor: InterfaceDescriptor,
|
pub descriptor: InterfaceDescriptor,
|
||||||
/// Optional opaque validator for this descriptor observation.
|
/// Optional opaque validator for this descriptor observation.
|
||||||
@@ -269,8 +311,10 @@ pub struct Target {
|
|||||||
/// Interface portion of an operation target.
|
/// Interface portion of an operation target.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceTarget {
|
pub struct InterfaceTarget {
|
||||||
/// Opaque interface reference selected from the object's interface set.
|
/// Structured reference selected from the object's interface set.
|
||||||
pub reference: String,
|
pub reference: InterfaceReference,
|
||||||
|
/// Optional scope Object ref copied from the same interface observation.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Optional validator copied from the interface observation.
|
/// Optional validator copied from the interface observation.
|
||||||
pub validator: Option<Vec<u8>>,
|
pub validator: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
@@ -369,6 +413,13 @@ impl ObserveRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FetchInterfaceRequest {
|
||||||
|
/// Validates reference shape; fetching an interface needs no target path.
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
self.interface.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FetchInterfaceResponse {
|
impl FetchInterfaceResponse {
|
||||||
/// Validates request correspondence and descriptor semantics.
|
/// Validates request correspondence and descriptor semantics.
|
||||||
///
|
///
|
||||||
@@ -382,7 +433,16 @@ impl FetchInterfaceResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CallOperationRequest {
|
impl CallOperationRequest {
|
||||||
/// Validates this call against the descriptor selected by `interface`.
|
/// Validates descriptor-independent request shape only.
|
||||||
|
///
|
||||||
|
/// Scope compatibility is deliberately deferred to interface selection so
|
||||||
|
/// that `NotFound` precedes `InterfaceMismatch` at the Host boundary.
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
crate::validation::validate_call_shape(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates this call's shape and descriptor-bound operation/arguments.
|
||||||
|
/// Scope compatibility and membership require Host publication context.
|
||||||
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||||
descriptor.validate_call(self)
|
descriptor.validate_call(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||||
MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object,
|
||||||
PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, ValidationError,
|
ObjectObservation, ObserveRequest, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD,
|
||||||
ValidationErrorKind, Value, ValueKind,
|
UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
|
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
|
||||||
validate_object_name(&object.name, true)?;
|
validate_object_name(&object.name, true)?;
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for interface in &object.interfaces {
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
if !seen.insert(interface.as_str()) {
|
interface.validate().map_err(|error| {
|
||||||
|
error
|
||||||
|
.prepend(PathSegment::Index(index))
|
||||||
|
.prepend(PathSegment::Field("interfaces".into()))
|
||||||
|
})?;
|
||||||
|
if !seen.insert(interface) {
|
||||||
return Err(
|
return Err(
|
||||||
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
||||||
reference: interface.clone(),
|
reference: interface.clone(),
|
||||||
@@ -51,6 +56,14 @@ pub(crate) fn validate_object_at_path(object: &Object, path: &str) -> Result<(),
|
|||||||
.next()
|
.next()
|
||||||
.expect("non-root path has a segment")
|
.expect("non-root path has a segment")
|
||||||
};
|
};
|
||||||
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
|
interface.validate_for_path(path).map_err(|error| {
|
||||||
|
error
|
||||||
|
.prepend(PathSegment::Index(index))
|
||||||
|
.prepend(PathSegment::Field("interfaces".into()))
|
||||||
|
.prepend(PathSegment::Object(path.to_owned()))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
if object.name == expected {
|
if object.name == expected {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -106,6 +119,54 @@ pub(crate) fn validate_path(path: &str) -> Result<(), ValidationError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_interface_reference(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<(), ValidationError> {
|
||||||
|
validate_path(&reference.scope)
|
||||||
|
.map_err(|error| error.prepend(PathSegment::Field("scope".into())))?;
|
||||||
|
if reference.name.is_empty() {
|
||||||
|
return Err(
|
||||||
|
ValidationError::new(ValidationErrorKind::EmptyInterfaceName)
|
||||||
|
.prepend(PathSegment::Field("name".into())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_interface_scope(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<(), ValidationError> {
|
||||||
|
reference.validate()?;
|
||||||
|
validate_path(path)?;
|
||||||
|
let scope = reference.scope.as_str();
|
||||||
|
if scope == "/"
|
||||||
|
|| path == scope
|
||||||
|
|| path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ValidationError::new(
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch {
|
||||||
|
scope: reference.scope.clone(),
|
||||||
|
path: path.to_owned(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_call_shape(request: &CallOperationRequest) -> Result<(), ValidationError> {
|
||||||
|
validate_path(&request.target.path)
|
||||||
|
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||||
|
request.interface.reference.validate().map_err(|error| {
|
||||||
|
error
|
||||||
|
.prepend(PathSegment::Field("reference".into()))
|
||||||
|
.prepend(PathSegment::Field("interface".into()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||||
let declarations = declaration_map(descriptor)?;
|
let declarations = declaration_map(descriptor)?;
|
||||||
|
|
||||||
@@ -582,8 +643,7 @@ pub(crate) fn validate_call(
|
|||||||
descriptor: &InterfaceDescriptor,
|
descriptor: &InterfaceDescriptor,
|
||||||
request: &CallOperationRequest,
|
request: &CallOperationRequest,
|
||||||
) -> Result<(), ValidationError> {
|
) -> Result<(), ValidationError> {
|
||||||
validate_path(&request.target.path)
|
request.validate()?;
|
||||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
|
||||||
validate_arguments(descriptor, &request.operation, &request.arguments)
|
validate_arguments(descriptor, &request.operation, &request.arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,11 +720,13 @@ pub(crate) fn validate_fetch_interface_response(
|
|||||||
response: &FetchInterfaceResponse,
|
response: &FetchInterfaceResponse,
|
||||||
request: &FetchInterfaceRequest,
|
request: &FetchInterfaceRequest,
|
||||||
) -> Result<(), ValidationError> {
|
) -> Result<(), ValidationError> {
|
||||||
|
request.validate()?;
|
||||||
|
response.interface.validate()?;
|
||||||
if response.interface != request.interface {
|
if response.interface != request.interface {
|
||||||
return Err(
|
return Err(
|
||||||
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
||||||
expected: request.interface.clone(),
|
expected: Box::new(request.interface.clone()),
|
||||||
actual: response.interface.clone(),
|
actual: Box::new(response.interface.clone()),
|
||||||
})
|
})
|
||||||
.prepend(PathSegment::Field("interface".into())),
|
.prepend(PathSegment::Field("interface".into())),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use wip_protocol::{
|
||||||
|
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||||
|
InterfaceReference, InterfaceTarget, Object, ObjectObservation, ObserveRequest,
|
||||||
|
OperationDeclaration, ReturnDeclaration, Target, TypeExpr, ValidationErrorKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(path: &str, interfaces: Vec<InterfaceReference>) -> Object {
|
||||||
|
Object {
|
||||||
|
name: path.rsplit('/').next().unwrap().into(),
|
||||||
|
description: None,
|
||||||
|
interfaces,
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor() -> InterfaceDescriptor {
|
||||||
|
InterfaceDescriptor {
|
||||||
|
format: wip_protocol::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::Unit,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_is_segment_based_ancestor_or_self_not_indexability() {
|
||||||
|
for (scope, path) in [
|
||||||
|
("/", "/"),
|
||||||
|
("/", "/a/b"),
|
||||||
|
("/a", "/a"),
|
||||||
|
("/a", "/a/b"),
|
||||||
|
("/a/b", "/a/b/c"),
|
||||||
|
] {
|
||||||
|
reference(scope, "read").validate_for_path(path).unwrap();
|
||||||
|
object(path, vec![reference(scope, "read")])
|
||||||
|
.validate_at_path(path)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for (scope, path) in [
|
||||||
|
("/a", "/ab"),
|
||||||
|
("/a", "/ab/c"),
|
||||||
|
("/a/b", "/a/c"),
|
||||||
|
("/a/b", "/a"),
|
||||||
|
("/a", "/"),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
reference(scope, "read")
|
||||||
|
.validate_for_path(path)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
// Without the placement context, the Object/reference shape is valid.
|
||||||
|
let object = object(path, vec![reference(scope, "read")]);
|
||||||
|
object.validate().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
object.validate_at_path(path).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_validation_does_not_normalize_or_parse_fields() {
|
||||||
|
for scope in ["", "relative", "//a", "/a/", "/a//b", "/a/./b", "/a/../b"] {
|
||||||
|
let reference = reference(scope, "read");
|
||||||
|
assert!(matches!(
|
||||||
|
reference.validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InvalidPath { .. }
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
FetchInterfaceRequest {
|
||||||
|
interface: reference.clone()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(object("/a", vec![reference]).validate().is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
reference("/", "").validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
for name in [".", "..", "a/b", "::", "#", "読み::書き#λ", "e\u{301}"] {
|
||||||
|
let reference = reference("/世::界/#/%2F", name);
|
||||||
|
reference.validate().unwrap();
|
||||||
|
assert_eq!(reference.name, name);
|
||||||
|
assert_eq!(reference.scope, "/世::界/#/%2F");
|
||||||
|
}
|
||||||
|
assert_ne!(reference("/", "é"), reference("/", "e\u{301}"));
|
||||||
|
assert_ne!(reference("/A", "read"), reference("/a", "read"));
|
||||||
|
assert_ne!(reference("/%61", "read"), reference("/a", "read"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_membership_and_correlation_use_complete_pair() {
|
||||||
|
let root = reference("/", "read");
|
||||||
|
let scoped = reference("/a", "read");
|
||||||
|
let mut object = object("/a", vec![root.clone(), scoped.clone()]);
|
||||||
|
object.validate_at_path("/a").unwrap();
|
||||||
|
assert!(object.interfaces.contains(&scoped));
|
||||||
|
assert!(!object.interfaces.contains(&reference("/b", "read")));
|
||||||
|
object.interfaces.push(root.clone());
|
||||||
|
assert_eq!(
|
||||||
|
object.validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::DuplicateInterface {
|
||||||
|
reference: root.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let request = FetchInterfaceRequest { interface: root };
|
||||||
|
let mut response = FetchInterfaceResponse {
|
||||||
|
interface: scoped,
|
||||||
|
scope_ref: None,
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
response.validate_for(&request).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InterfaceResponseMismatch { .. }
|
||||||
|
));
|
||||||
|
response.interface = request.interface.clone();
|
||||||
|
for scope_ref in [None, Some(String::new()), Some("opaque::世界#/%".into())] {
|
||||||
|
response.scope_ref = scope_ref;
|
||||||
|
response.validate_for(&request).unwrap();
|
||||||
|
}
|
||||||
|
response.interface.name.clear();
|
||||||
|
assert_eq!(
|
||||||
|
response.validate_for(&request).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observe_checks_scope_at_every_node_including_depth_boundary() {
|
||||||
|
let request = ObserveRequest {
|
||||||
|
path: "/a".into(),
|
||||||
|
depth: 1,
|
||||||
|
};
|
||||||
|
let mut response = ObjectObservation {
|
||||||
|
object: object("/a", vec![reference("/a", "read")]),
|
||||||
|
children: Some(vec![ObjectObservation {
|
||||||
|
object: object("/a/b", vec![reference("/a/b", "read")]),
|
||||||
|
children: None,
|
||||||
|
}]),
|
||||||
|
};
|
||||||
|
request.validate_response(&response).unwrap();
|
||||||
|
response.children.as_mut().unwrap()[0].object.interfaces[0].scope = "/a/c".into();
|
||||||
|
assert!(matches!(
|
||||||
|
request.validate_response(&response).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_needs_no_target_path_and_call_separates_shape_from_scope_selection() {
|
||||||
|
let reference = reference("/some/unrelated/scope", "read");
|
||||||
|
let fetch = FetchInterfaceRequest {
|
||||||
|
interface: reference.clone(),
|
||||||
|
};
|
||||||
|
fetch.validate().unwrap();
|
||||||
|
FetchInterfaceResponse {
|
||||||
|
interface: reference.clone(),
|
||||||
|
scope_ref: None,
|
||||||
|
descriptor: descriptor(),
|
||||||
|
validator: None,
|
||||||
|
}
|
||||||
|
.validate_for(&fetch)
|
||||||
|
.unwrap();
|
||||||
|
let mut call = CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/a".into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref: Some("opaque".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "run".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
call.validate().unwrap();
|
||||||
|
call.validate_with(&descriptor()).unwrap();
|
||||||
|
// This stage is Host InterfaceMismatch, after NotFound, not shape InvalidRequest.
|
||||||
|
assert!(matches!(
|
||||||
|
call.interface
|
||||||
|
.reference
|
||||||
|
.validate_for_path(&call.target.path)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||||
|
));
|
||||||
|
call.interface.reference.name.clear();
|
||||||
|
assert_eq!(
|
||||||
|
call.validate().unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call.validate_with(&descriptor()).unwrap_err().kind,
|
||||||
|
ValidationErrorKind::EmptyInterfaceName
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,19 @@ use std::collections::BTreeMap;
|
|||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
||||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||||
InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
|
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object,
|
||||||
OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, ProtocolErrorCode,
|
ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, PathSegment,
|
||||||
ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr,
|
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration,
|
||||||
UnionCase, ValidationErrorKind, Value, ValueKind,
|
Target, TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
||||||
Value::Record(
|
Value::Record(
|
||||||
entries
|
entries
|
||||||
@@ -135,7 +142,7 @@ fn object(name: &str) -> Object {
|
|||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some(format!("Object {name}")),
|
description: Some(format!("Object {name}")),
|
||||||
interfaces: vec!["catalog-v1".into(), "search-v2".into()],
|
interfaces: vec![reference("/", "catalog-v1"), reference("/", "search-v2")],
|
||||||
r#ref: Some("opaque-object-ref".into()),
|
r#ref: Some("opaque-object-ref".into()),
|
||||||
validator: Some(vec![1, 2, 3]),
|
validator: Some(vec![1, 2, 3]),
|
||||||
}
|
}
|
||||||
@@ -146,16 +153,19 @@ fn object_fields_have_canonical_identity_and_ordered_set_semantics() {
|
|||||||
let object = object("article");
|
let object = object("article");
|
||||||
object.validate().unwrap();
|
object.validate().unwrap();
|
||||||
object.validate_at_path("/articles/article").unwrap();
|
object.validate_at_path("/articles/article").unwrap();
|
||||||
assert_eq!(object.interfaces, ["catalog-v1", "search-v2"]);
|
assert_eq!(
|
||||||
|
object.interfaces,
|
||||||
|
[reference("/", "catalog-v1"), reference("/", "search-v2")]
|
||||||
|
);
|
||||||
|
|
||||||
let duplicate = Object {
|
let duplicate = Object {
|
||||||
interfaces: vec!["catalog-v1".into(), "catalog-v1".into()],
|
interfaces: vec![reference("/", "catalog-v1"), reference("/", "catalog-v1")],
|
||||||
..object.clone()
|
..object.clone()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
duplicate.validate().unwrap_err().kind,
|
duplicate.validate().unwrap_err().kind,
|
||||||
ValidationErrorKind::DuplicateInterface {
|
ValidationErrorKind::DuplicateInterface {
|
||||||
reference: "catalog-v1".into()
|
reference: reference("/", "catalog-v1")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -574,7 +584,8 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
|
|||||||
validator: Some(vec![1]),
|
validator: Some(vec![1]),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: "catalog-v1".into(),
|
scope_ref: None,
|
||||||
|
reference: reference("/", "catalog-v1"),
|
||||||
validator: Some(vec![2]),
|
validator: Some(vec![2]),
|
||||||
},
|
},
|
||||||
operation: "publish".into(),
|
operation: "publish".into(),
|
||||||
@@ -659,17 +670,18 @@ fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let request = FetchInterfaceRequest {
|
let request = FetchInterfaceRequest {
|
||||||
interface: "catalog-v1".into(),
|
interface: reference("/", "catalog-v1"),
|
||||||
};
|
};
|
||||||
let response = FetchInterfaceResponse {
|
let response = FetchInterfaceResponse {
|
||||||
interface: "catalog-v1".into(),
|
scope_ref: None,
|
||||||
|
interface: reference("/", "catalog-v1"),
|
||||||
descriptor: descriptor(),
|
descriptor: descriptor(),
|
||||||
validator: Some(vec![9]),
|
validator: Some(vec![9]),
|
||||||
};
|
};
|
||||||
response.validate_for(&request).unwrap();
|
response.validate_for(&request).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: "other".into(),
|
interface: reference("/", "other"),
|
||||||
..response
|
..response
|
||||||
}
|
}
|
||||||
.validate_for(&request)
|
.validate_for(&request)
|
||||||
@@ -847,7 +859,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let interface_request = FetchInterfaceRequest {
|
let interface_request = FetchInterfaceRequest {
|
||||||
interface: "catalog-v1".into(),
|
interface: reference("/", "catalog-v1"),
|
||||||
};
|
};
|
||||||
let mut invalid_descriptor = descriptor();
|
let mut invalid_descriptor = descriptor();
|
||||||
invalid_descriptor
|
invalid_descriptor
|
||||||
@@ -856,6 +868,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
FetchInterfaceResponse {
|
FetchInterfaceResponse {
|
||||||
interface: interface_request.interface.clone(),
|
interface: interface_request.interface.clone(),
|
||||||
|
scope_ref: None,
|
||||||
descriptor: invalid_descriptor,
|
descriptor: invalid_descriptor,
|
||||||
validator: None,
|
validator: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@
|
|||||||
//!
|
//!
|
||||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||||
//! optional observation fields. Interface references are opaque values observed
|
//! optional observation fields. This demo registers explicit `catalog`, `audit`
|
||||||
|
//! and `diagnostics` names at the service Object self-scope. A private [`Scope`]
|
||||||
|
//! lifetime and coherent [`ScopeObservation`] bind fetch and call to that Object;
|
||||||
|
//! successful calls carry its optional `scope_ref` identity precondition.
|
||||||
|
//! Interface references are structured scope/name values observed
|
||||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||||
@@ -41,12 +45,12 @@ use std::io;
|
|||||||
|
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
||||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
|
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
|
||||||
WipObject,
|
Target, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
use wip_interface::WipType;
|
use wip_interface::WipType;
|
||||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
|
use wip_protocol::{InterfaceReference, ProtocolError, ProtocolErrorCode, Value};
|
||||||
|
|
||||||
const OBJECT_PATH: &str = "/article-service";
|
const OBJECT_PATH: &str = "/article-service";
|
||||||
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
||||||
@@ -134,16 +138,22 @@ pub struct DemoReport {
|
|||||||
pub object_reference: Option<String>,
|
pub object_reference: Option<String>,
|
||||||
/// Optional object-state validator.
|
/// Optional object-state validator.
|
||||||
pub object_validator: Option<Vec<u8>>,
|
pub object_validator: Option<Vec<u8>>,
|
||||||
/// Opaque interface references published by the object, in exposure order.
|
/// Structured interface references published by the object, in exposure order.
|
||||||
pub published_interfaces: Vec<String>,
|
pub published_interfaces: Vec<InterfaceReference>,
|
||||||
/// Opaque reference assigned to the catalog descriptor.
|
/// Self-scoped reference assigned to the catalog descriptor.
|
||||||
pub catalog_interface_reference: String,
|
pub catalog_interface_reference: InterfaceReference,
|
||||||
/// Opaque reference assigned to the audit descriptor.
|
/// Self-scoped reference assigned to the audit descriptor.
|
||||||
pub audit_interface_reference: String,
|
pub audit_interface_reference: InterfaceReference,
|
||||||
/// Descriptor format observed separately for the catalog reference.
|
/// Descriptor format observed separately for the catalog reference.
|
||||||
pub catalog_descriptor_format: String,
|
pub catalog_descriptor_format: String,
|
||||||
/// Descriptor format observed separately for the audit reference.
|
/// Descriptor format observed separately for the audit reference.
|
||||||
pub audit_descriptor_format: String,
|
pub audit_descriptor_format: String,
|
||||||
|
/// Scope Object ref observed together with the catalog Descriptor.
|
||||||
|
pub catalog_scope_ref: Option<String>,
|
||||||
|
/// Scope Object ref observed together with the audit Descriptor.
|
||||||
|
pub audit_scope_ref: Option<String>,
|
||||||
|
/// Membership rejection when the supplied scope Object ref is stale.
|
||||||
|
pub scope_mismatch: ProtocolErrorCode,
|
||||||
/// Typed successful application result decoded from a validated [`Value`].
|
/// Typed successful application result decoded from a validated [`Value`].
|
||||||
pub published: PublishOutcome,
|
pub published: PublishOutcome,
|
||||||
/// Typed application-domain rejection decoded from a validated [`Value`].
|
/// Typed application-domain rejection decoded from a validated [`Value`].
|
||||||
@@ -182,25 +192,34 @@ pub struct DemoReport {
|
|||||||
/// application-value decoding fails unexpectedly.
|
/// application-value decoding fails unexpectedly.
|
||||||
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
|
let scope = Scope::new(OBJECT_PATH)?;
|
||||||
let audit = registry.register(AuditDefinition, None)?;
|
let catalog = registry.register(
|
||||||
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
|
&scope,
|
||||||
|
"catalog",
|
||||||
|
CatalogDefinition,
|
||||||
|
Some(Validator::new(CATALOG_VALIDATOR)),
|
||||||
|
)?;
|
||||||
|
let audit = registry.register(&scope, "audit", AuditDefinition, None)?;
|
||||||
|
let diagnostics = registry.register(&scope, "diagnostics", DiagnosticsDefinition, None)?;
|
||||||
let provider = ApplicationProvider {
|
let provider = ApplicationProvider {
|
||||||
|
scope,
|
||||||
catalog: catalog.clone(),
|
catalog: catalog.clone(),
|
||||||
audit: audit.clone(),
|
audit: audit.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let object = provider.observe_object(OBJECT_PATH)?;
|
let object = provider.observe_object(OBJECT_PATH)?;
|
||||||
let catalog_response = registry
|
let catalog_response = provider
|
||||||
.fetch_interface(
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.first()
|
.first()
|
||||||
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
||||||
)?
|
)?
|
||||||
.protocol_response()?;
|
.protocol_response()?;
|
||||||
let audit_response = registry
|
let audit_response = provider
|
||||||
.fetch_interface(
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.get(1)
|
.get(1)
|
||||||
@@ -296,7 +315,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
validator: Some(b"stale-object".to_vec()),
|
validator: Some(b"stale-object".to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: diagnostics.reference().as_str().to_owned(),
|
scope_ref: None,
|
||||||
|
reference: diagnostics.reference().protocol_reference(),
|
||||||
validator: Some(b"stale-interface".to_vec()),
|
validator: Some(b"stale-interface".to_vec()),
|
||||||
},
|
},
|
||||||
operation: "missing".into(),
|
operation: "missing".into(),
|
||||||
@@ -318,11 +338,31 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
.protocol_error()
|
.protocol_error()
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
let interface_not_found = registry
|
let interface_not_found = provider
|
||||||
.fetch_interface("missing-interface")
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
|
&InterfaceReference {
|
||||||
|
scope: OBJECT_PATH.into(),
|
||||||
|
name: "missing-interface".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.expect_err("unknown interface references must not resolve")
|
.expect_err("unknown interface references must not resolve")
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
|
let mut stale_scope = request(
|
||||||
|
OBJECT_PATH,
|
||||||
|
catalog.reference(),
|
||||||
|
Some(CATALOG_VALIDATOR),
|
||||||
|
"status",
|
||||||
|
BTreeMap::new(),
|
||||||
|
);
|
||||||
|
stale_scope.interface.scope_ref = Some("replaced-service-instance".into());
|
||||||
|
let scope_mismatch = provider
|
||||||
|
.call_operation(stale_scope)
|
||||||
|
.expect_err("stale scope identity must reject before operation dispatch")
|
||||||
|
.protocol_error()
|
||||||
|
.code;
|
||||||
|
|
||||||
let precedence_failure = provider
|
let precedence_failure = provider
|
||||||
.call_operation(CallRequest {
|
.call_operation(CallRequest {
|
||||||
target: Target {
|
target: Target {
|
||||||
@@ -330,7 +370,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
validator: Some(b"stale-object".to_vec()),
|
validator: Some(b"stale-object".to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: diagnostics.reference().as_str().to_owned(),
|
scope_ref: None,
|
||||||
|
reference: diagnostics.reference().protocol_reference(),
|
||||||
validator: Some(b"stale-interface".to_vec()),
|
validator: Some(b"stale-interface".to_vec()),
|
||||||
},
|
},
|
||||||
operation: "missing".into(),
|
operation: "missing".into(),
|
||||||
@@ -345,10 +386,13 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
object_reference: object.r#ref,
|
object_reference: object.r#ref,
|
||||||
object_validator: object.validator,
|
object_validator: object.validator,
|
||||||
published_interfaces: object.interfaces,
|
published_interfaces: object.interfaces,
|
||||||
catalog_interface_reference: catalog.reference().as_str().to_owned(),
|
catalog_interface_reference: catalog.reference().protocol_reference(),
|
||||||
audit_interface_reference: audit.reference().as_str().to_owned(),
|
audit_interface_reference: audit.reference().protocol_reference(),
|
||||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||||
audit_descriptor_format: audit_response.descriptor.format,
|
audit_descriptor_format: audit_response.descriptor.format,
|
||||||
|
catalog_scope_ref: catalog_response.scope_ref,
|
||||||
|
audit_scope_ref: audit_response.scope_ref,
|
||||||
|
scope_mismatch,
|
||||||
published,
|
published,
|
||||||
rejected,
|
rejected,
|
||||||
catalog_status,
|
catalog_status,
|
||||||
@@ -412,7 +456,8 @@ fn request(
|
|||||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
reference: interface.as_str().to_owned(),
|
scope_ref: Some("article-service-instance".into()),
|
||||||
|
reference: interface.protocol_reference(),
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
@@ -421,6 +466,7 @@ fn request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ApplicationProvider {
|
struct ApplicationProvider {
|
||||||
|
scope: Scope,
|
||||||
catalog: RegisteredInterface<CatalogDefinition>,
|
catalog: RegisteredInterface<CatalogDefinition>,
|
||||||
audit: RegisteredInterface<AuditDefinition>,
|
audit: RegisteredInterface<AuditDefinition>,
|
||||||
}
|
}
|
||||||
@@ -437,7 +483,12 @@ impl ObjectProvider for ApplicationProvider {
|
|||||||
if path != OBJECT_PATH {
|
if path != OBJECT_PATH {
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
}
|
}
|
||||||
let publication = PublishedObject::new(ApplicationService::new())
|
// The immutable demo has no mutable backend actors. A real backend
|
||||||
|
// must retain its transaction/lock across association and operation.
|
||||||
|
let service = ApplicationService::new();
|
||||||
|
let current = ScopeObservation::new(&self.scope, &service);
|
||||||
|
let publication = PublishedObject::new(service)
|
||||||
|
.bind_scope(current)
|
||||||
.expose(&self.catalog)
|
.expose(&self.catalog)
|
||||||
.expect("ApplicationService implements Catalog")
|
.expect("ApplicationService implements Catalog")
|
||||||
.expose(&self.audit)
|
.expose(&self.audit)
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
|
|||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
report.catalog_interface_reference, report.audit_interface_reference,
|
report.catalog_interface_reference, report.audit_interface_reference,
|
||||||
"interfaces form an ordered set of distinct opaque references"
|
"interfaces form an ordered set of distinct scope/name references"
|
||||||
);
|
);
|
||||||
|
assert_eq!(report.catalog_interface_reference.scope, "/article-service");
|
||||||
|
assert_eq!(report.catalog_interface_reference.name, "catalog");
|
||||||
|
assert_eq!(report.audit_interface_reference.scope, "/article-service");
|
||||||
|
assert_eq!(report.audit_interface_reference.name, "audit");
|
||||||
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
||||||
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
|
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
|
||||||
|
assert_eq!(
|
||||||
|
report.catalog_scope_ref.as_deref(),
|
||||||
|
Some("article-service-instance")
|
||||||
|
);
|
||||||
|
assert_eq!(report.audit_scope_ref, report.catalog_scope_ref);
|
||||||
|
assert_eq!(report.scope_mismatch, ProtocolErrorCode::InterfaceMismatch);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.published,
|
report.published,
|
||||||
PublishOutcome::Published("article-1".into())
|
PublishOutcome::Published("article-1".into())
|
||||||
@@ -97,7 +107,7 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
|||||||
let object = Object {
|
let object = Object {
|
||||||
name: "article".into(),
|
name: "article".into(),
|
||||||
description: None,
|
description: None,
|
||||||
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
|
interfaces: vec![root_reference("catalog-ref"), root_reference("audit-ref")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
};
|
};
|
||||||
@@ -105,7 +115,10 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
|||||||
object.validate_at_path("/library/article").unwrap();
|
object.validate_at_path("/library/article").unwrap();
|
||||||
assert_eq!(object.name, "article");
|
assert_eq!(object.name, "article");
|
||||||
assert_eq!(object.r#ref, None);
|
assert_eq!(object.r#ref, None);
|
||||||
assert_eq!(object.interfaces, ["catalog-ref", "audit-ref"]);
|
assert_eq!(
|
||||||
|
object.interfaces,
|
||||||
|
[root_reference("catalog-ref"), root_reference("audit-ref")]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -244,3 +257,10 @@ fn assert_schema_neutral(value: &Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
|||||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||||
presented as current.
|
presented as current.
|
||||||
- **Interfaces / Operations** identifies every operation by `(interface reference,
|
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||||
operation name)`. Descriptor lookahead follows fresh visible Objects, shares the
|
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
||||||
|
Structured references remain intact in selection, acquisition, and call state;
|
||||||
|
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the
|
||||||
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
||||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||||
status, and result types.
|
status, and result types.
|
||||||
|
|||||||
+71
-16
@@ -3,7 +3,7 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
||||||
use wip_protocol::{InterfaceDescriptor, Object, Value};
|
use wip_protocol::{InterfaceDescriptor, InterfaceReference, Object, Value};
|
||||||
|
|
||||||
use crate::editor::ArgumentEditor;
|
use crate::editor::ArgumentEditor;
|
||||||
|
|
||||||
@@ -139,8 +139,8 @@ impl EntryView {
|
|||||||
/// Cached interface descriptor state used by presentation.
|
/// Cached interface descriptor state used by presentation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InterfaceView {
|
pub struct InterfaceView {
|
||||||
/// Opaque interface reference.
|
/// Structured interface reference.
|
||||||
pub reference: String,
|
pub reference: InterfaceReference,
|
||||||
/// Descriptor when accepted by the client runtime.
|
/// Descriptor when accepted by the client runtime.
|
||||||
pub descriptor: Option<InterfaceDescriptor>,
|
pub descriptor: Option<InterfaceDescriptor>,
|
||||||
/// Descriptor observation state.
|
/// Descriptor observation state.
|
||||||
@@ -187,13 +187,13 @@ pub enum InFlightKind {
|
|||||||
/// Tree expansion or refresh.
|
/// Tree expansion or refresh.
|
||||||
Tree(String),
|
Tree(String),
|
||||||
/// Descriptor fetch.
|
/// Descriptor fetch.
|
||||||
Interface(String),
|
Interface(InterfaceReference),
|
||||||
/// Explicit operation call.
|
/// Explicit operation call.
|
||||||
Operation {
|
Operation {
|
||||||
/// Target path.
|
/// Target path.
|
||||||
path: String,
|
path: String,
|
||||||
/// Interface reference.
|
/// Interface reference.
|
||||||
interface: String,
|
interface: InterfaceReference,
|
||||||
/// Operation name.
|
/// Operation name.
|
||||||
operation: String,
|
operation: String,
|
||||||
/// Whether the transport dispatch boundary was crossed.
|
/// Whether the transport dispatch boundary was crossed.
|
||||||
@@ -241,13 +241,13 @@ pub enum Action {
|
|||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
/// Retrieve one interface descriptor.
|
/// Retrieve one interface descriptor.
|
||||||
FetchInterface(String),
|
FetchInterface(InterfaceReference),
|
||||||
/// Execute only after explicit confirmation.
|
/// Execute only after explicit confirmation.
|
||||||
Execute {
|
Execute {
|
||||||
/// Entry target.
|
/// Entry target.
|
||||||
path: String,
|
path: String,
|
||||||
/// Interface reference.
|
/// Interface reference.
|
||||||
interface: String,
|
interface: InterfaceReference,
|
||||||
/// Operation name.
|
/// Operation name.
|
||||||
operation: String,
|
operation: String,
|
||||||
/// Descriptor-validated arguments.
|
/// Descriptor-validated arguments.
|
||||||
@@ -276,8 +276,8 @@ pub struct App {
|
|||||||
pub selected_path: String,
|
pub selected_path: String,
|
||||||
/// Focused pane.
|
/// Focused pane.
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
/// Interface descriptor views by opaque reference.
|
/// Interface descriptor views by structured reference.
|
||||||
pub interfaces: BTreeMap<String, InterfaceView>,
|
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||||
/// Selected row in the flattened interface/operation view.
|
/// Selected row in the flattened interface/operation view.
|
||||||
pub operation_cursor: usize,
|
pub operation_cursor: usize,
|
||||||
/// Selected recent-history row.
|
/// Selected recent-history row.
|
||||||
@@ -615,7 +615,7 @@ impl App {
|
|||||||
/// Returns interface references advertised by fresh visible Objects in display
|
/// Returns interface references advertised by fresh visible Objects in display
|
||||||
/// and per-Object declaration order, with duplicates removed.
|
/// and per-Object declaration order, with duplicates removed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn visible_interface_references(&self) -> Vec<String> {
|
pub fn visible_interface_references(&self) -> Vec<InterfaceReference> {
|
||||||
let mut references = Vec::new();
|
let mut references = Vec::new();
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
for path in self.visible_paths() {
|
for path in self.visible_paths() {
|
||||||
@@ -716,7 +716,7 @@ impl App {
|
|||||||
.or_insert_with(|| EntryView::placeholder(path))
|
.or_insert_with(|| EntryView::placeholder(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
|
fn ensure_interface(&mut self, reference: &InterfaceReference) -> &mut InterfaceView {
|
||||||
if !self.interfaces.contains_key(reference)
|
if !self.interfaces.contains_key(reference)
|
||||||
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
||||||
{
|
{
|
||||||
@@ -757,7 +757,7 @@ impl App {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct OperationRow {
|
pub struct OperationRow {
|
||||||
/// Interface reference, which participates in operation identity.
|
/// Interface reference, which participates in operation identity.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
/// Operation name, or `None` for the interface heading.
|
/// Operation name, or `None` for the interface heading.
|
||||||
pub operation: Option<String>,
|
pub operation: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -773,6 +773,12 @@ pub fn parent_path(path: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
||||||
|
|
||||||
@@ -780,7 +786,10 @@ mod tests {
|
|||||||
Object {
|
Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: Some(format!("{name} description")),
|
description: Some(format!("{name} description")),
|
||||||
interfaces: interfaces.iter().map(|value| (*value).into()).collect(),
|
interfaces: interfaces
|
||||||
|
.iter()
|
||||||
|
.map(|value| root_reference(value))
|
||||||
|
.collect(),
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
}
|
}
|
||||||
@@ -846,7 +855,51 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.visible_interface_references(),
|
app.visible_interface_references(),
|
||||||
["root", "shared", "child"]
|
[
|
||||||
|
root_reference("root"),
|
||||||
|
root_reference("shared"),
|
||||||
|
root_reference("child")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presentation_retains_equal_names_in_distinct_scopes() {
|
||||||
|
let mut app = App::new("https://example.test/wip");
|
||||||
|
let root = root_reference("shared#::操作");
|
||||||
|
let local = InterfaceReference {
|
||||||
|
scope: "/a".into(),
|
||||||
|
name: root.name.clone(),
|
||||||
|
};
|
||||||
|
let mut value = object("a", &[]);
|
||||||
|
value.interfaces = vec![root.clone(), local.clone()];
|
||||||
|
app.navigate_entry("/a");
|
||||||
|
app.apply_object(ObjectObservation {
|
||||||
|
path: "/a".into(),
|
||||||
|
object: Some(value),
|
||||||
|
validator: None,
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
});
|
||||||
|
for reference in [&root, &local] {
|
||||||
|
app.apply_interface(InterfaceObservation {
|
||||||
|
reference: reference.clone(),
|
||||||
|
descriptor: None,
|
||||||
|
validator: None,
|
||||||
|
scope_ref: None,
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(app.interfaces.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
app.visible_interface_references(),
|
||||||
|
vec![root.clone(), local.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.operation_rows()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.interface.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![root, local]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,7 +927,8 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: "iface".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("iface"),
|
||||||
descriptor: Some(descriptor),
|
descriptor: Some(descriptor),
|
||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
@@ -903,7 +957,8 @@ mod tests {
|
|||||||
|
|
||||||
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: format!("interface-{index}"),
|
scope_ref: None,
|
||||||
|
reference: root_reference(&format!("interface-{index}")),
|
||||||
descriptor: None,
|
descriptor: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use wip_protocol::{InterfaceDescriptor, OperationDeclaration, TypeExpr, Value};
|
use wip_protocol::{
|
||||||
|
InterfaceDescriptor, InterfaceReference, OperationDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
|
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
@@ -30,8 +32,8 @@ pub struct InvalidArguments;
|
|||||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ArgumentEditor {
|
pub struct ArgumentEditor {
|
||||||
/// Opaque interface reference selected for the call.
|
/// Structured interface reference selected for the call.
|
||||||
pub interface: String,
|
pub interface: InterfaceReference,
|
||||||
/// Selected operation declaration.
|
/// Selected operation declaration.
|
||||||
pub operation: OperationDeclaration,
|
pub operation: OperationDeclaration,
|
||||||
/// Complete descriptor used to resolve named types.
|
/// Complete descriptor used to resolve named types.
|
||||||
@@ -46,7 +48,7 @@ impl ArgumentEditor {
|
|||||||
/// Builds an editor whose fields preserve descriptor order.
|
/// Builds an editor whose fields preserve descriptor order.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
interface: impl Into<String>,
|
interface: InterfaceReference,
|
||||||
descriptor: InterfaceDescriptor,
|
descriptor: InterfaceDescriptor,
|
||||||
operation: OperationDeclaration,
|
operation: OperationDeclaration,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -66,7 +68,7 @@ impl ArgumentEditor {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
interface: interface.into(),
|
interface,
|
||||||
operation,
|
operation,
|
||||||
descriptor,
|
descriptor,
|
||||||
fields,
|
fields,
|
||||||
@@ -466,6 +468,12 @@ fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result<Value, Strin
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
||||||
@@ -560,7 +568,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let operation = descriptor.operations[0].clone();
|
let operation = descriptor.operations[0].clone();
|
||||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||||
let inputs = [
|
let inputs = [
|
||||||
"null",
|
"null",
|
||||||
"true",
|
"true",
|
||||||
@@ -595,7 +603,7 @@ mod tests {
|
|||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
let operation = descriptor.operations[0].clone();
|
let operation = descriptor.operations[0].clone();
|
||||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||||
editor.fields[1].input = "not-a-bool".into();
|
editor.fields[1].input = "not-a-bool".into();
|
||||||
assert_eq!(editor.validate(), Err(InvalidArguments));
|
assert_eq!(editor.validate(), Err(InvalidArguments));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ use crate::editor::type_name;
|
|||||||
|
|
||||||
const MAX_RENDER_TEXT: usize = 512;
|
const MAX_RENDER_TEXT: usize = 512;
|
||||||
|
|
||||||
|
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||||
|
format!(
|
||||||
|
"scope={}, name={}",
|
||||||
|
sanitize(&reference.scope),
|
||||||
|
sanitize(&reference.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws the complete application without mutating it.
|
/// Draws the complete application without mutating it.
|
||||||
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
@@ -162,7 +170,7 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
|||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| sanitize(value))
|
.map(interface_label)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
@@ -224,7 +232,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
.find(|operation| operation.name == *name)
|
.find(|operation| operation.name == *name)
|
||||||
});
|
});
|
||||||
let signature = declaration.map_or_else(
|
let signature = declaration.map_or_else(
|
||||||
|| format!(" ({}, {})", row.interface, name),
|
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||||
|operation| {
|
|operation| {
|
||||||
let parameters = operation
|
let parameters = operation
|
||||||
.parameters
|
.parameters
|
||||||
@@ -246,7 +254,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
" ({}, {}) ({parameters}) -> {}{}",
|
" ({}, {}) ({parameters}) -> {}{}",
|
||||||
row.interface,
|
interface_label(&row.interface),
|
||||||
name,
|
name,
|
||||||
type_name(&operation.returns.r#type),
|
type_name(&operation.returns.r#type),
|
||||||
documentation
|
documentation
|
||||||
@@ -266,7 +274,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
(
|
(
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"Interface {} [{}]{}",
|
"Interface {} [{}]{}",
|
||||||
sanitize(&row.interface),
|
interface_label(&row.interface),
|
||||||
state,
|
state,
|
||||||
documentation
|
documentation
|
||||||
)),
|
)),
|
||||||
@@ -355,7 +363,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
let mut lines = vec![Line::from(format!(
|
let mut lines = vec![Line::from(format!(
|
||||||
"Target: {} Operation: ({}, {})",
|
"Target: {} Operation: ({}, {})",
|
||||||
sanitize(&app.selected_path),
|
sanitize(&app.selected_path),
|
||||||
sanitize(&editor.interface),
|
interface_label(&editor.interface),
|
||||||
sanitize(&editor.operation.name)
|
sanitize(&editor.operation.name)
|
||||||
))];
|
))];
|
||||||
if editor.fields.is_empty() {
|
if editor.fields.is_empty() {
|
||||||
@@ -405,7 +413,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||||
Line::from(format!("Interface: {}", sanitize(&editor.interface))),
|
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||||
Line::from("Input values:"),
|
Line::from("Input values:"),
|
||||||
];
|
];
|
||||||
@@ -579,6 +587,12 @@ fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
@@ -664,7 +678,7 @@ mod tests {
|
|||||||
object: Some(Object {
|
object: Some(Object {
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
description: Some("public root description".into()),
|
description: Some("public root description".into()),
|
||||||
interfaces: vec!["demo-interface".into()],
|
interfaces: vec![root_reference("demo-interface")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: Some(b"must-not-render".to_vec()),
|
validator: Some(b"must-not-render".to_vec()),
|
||||||
}),
|
}),
|
||||||
@@ -691,7 +705,8 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: "demo-interface".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("demo-interface"),
|
||||||
descriptor: Some(descriptor.clone()),
|
descriptor: Some(descriptor.clone()),
|
||||||
validator: Some(b"must-not-render".to_vec()),
|
validator: Some(b"must-not-render".to_vec()),
|
||||||
state: ObservationState::Fresh,
|
state: ObservationState::Fresh,
|
||||||
@@ -711,7 +726,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let snapshot = rendered(&app, 140, 40);
|
let snapshot = rendered(&app, 140, 40);
|
||||||
assert!(snapshot.contains("public root description"));
|
assert!(snapshot.contains("public root description"));
|
||||||
assert!(snapshot.contains("(demo-interface, run)"));
|
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||||
assert!(snapshot.contains("SUCCESS"));
|
assert!(snapshot.contains("SUCCESS"));
|
||||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||||
@@ -719,7 +734,7 @@ mod tests {
|
|||||||
assert!(!snapshot.contains("must-not-render"));
|
assert!(!snapshot.contains("must-not-render"));
|
||||||
|
|
||||||
let mut editor = ArgumentEditor::new(
|
let mut editor = ArgumentEditor::new(
|
||||||
"demo-interface",
|
root_reference("demo-interface"),
|
||||||
descriptor.clone(),
|
descriptor.clone(),
|
||||||
descriptor.operations[0].clone(),
|
descriptor.operations[0].clone(),
|
||||||
);
|
);
|
||||||
@@ -774,7 +789,7 @@ mod tests {
|
|||||||
object: Some(Object {
|
object: Some(Object {
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
description: None,
|
description: None,
|
||||||
interfaces: vec!["many".into()],
|
interfaces: vec![root_reference("many")],
|
||||||
r#ref: None,
|
r#ref: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
}),
|
}),
|
||||||
@@ -793,7 +808,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
app.apply_interface(InterfaceObservation {
|
app.apply_interface(InterfaceObservation {
|
||||||
reference: "many".into(),
|
scope_ref: None,
|
||||||
|
reference: root_reference("many"),
|
||||||
descriptor: Some(InterfaceDescriptor {
|
descriptor: Some(InterfaceDescriptor {
|
||||||
format: INTERFACE_FORMAT_V1.into(),
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
documentation: None,
|
documentation: None,
|
||||||
@@ -806,7 +822,7 @@ mod tests {
|
|||||||
app.focus = Focus::Operations;
|
app.focus = Focus::Operations;
|
||||||
app.operation_cursor = app.operation_rows().len() - 1;
|
app.operation_cursor = app.operation_rows().len() - 1;
|
||||||
let snapshot = rendered(&app, 100, 30);
|
let snapshot = rendered(&app, 100, 30);
|
||||||
assert!(snapshot.contains("(many, operation-19)"));
|
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||||
|
|
||||||
for index in 0..20 {
|
for index in 0..20 {
|
||||||
app.push_history(HistoryItem {
|
app.push_history(HistoryItem {
|
||||||
@@ -828,6 +844,19 @@ mod tests {
|
|||||||
assert!(snapshot.contains("too small"));
|
assert!(snapshot.contains("too small"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_labels_show_scope_and_name_without_parsing() {
|
||||||
|
let reference = wip_protocol::InterfaceReference {
|
||||||
|
scope: "/nested".into(),
|
||||||
|
name: "#::操作".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||||
|
assert_ne!(
|
||||||
|
interface_label(&reference),
|
||||||
|
interface_label(&root_reference(&reference.name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn control_characters_and_large_bytes_are_bounded() {
|
fn control_characters_and_large_bytes_are_bounded() {
|
||||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||||
|
|||||||
@@ -303,7 +303,10 @@ impl<T: Transport> Runtime<T> {
|
|||||||
Err(error) => self.local_error("subtree refresh", error),
|
Err(error) => self.local_error("subtree refresh", error),
|
||||||
},
|
},
|
||||||
Action::FetchInterface(reference) => {
|
Action::FetchInterface(reference) => {
|
||||||
match self.client.prepare_interface(&self.session, &reference) {
|
match self
|
||||||
|
.client
|
||||||
|
.prepare_interface(&self.session, reference.clone())
|
||||||
|
{
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
self.dispatch(request, InFlightKind::Interface(reference), false)
|
self.dispatch(request, InFlightKind::Interface(reference), false)
|
||||||
}
|
}
|
||||||
@@ -367,7 +370,10 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for reference in references {
|
for reference in references {
|
||||||
match self.client.ensure_interface(&self.session, &reference) {
|
match self
|
||||||
|
.client
|
||||||
|
.ensure_interface(&self.session, reference.clone())
|
||||||
|
{
|
||||||
Ok(Some(request)) => {
|
Ok(Some(request)) => {
|
||||||
self.dispatch(request, InFlightKind::Interface(reference), false);
|
self.dispatch(request, InFlightKind::Interface(reference), false);
|
||||||
}
|
}
|
||||||
@@ -599,8 +605,9 @@ impl<T: Transport> Runtime<T> {
|
|||||||
|
|
||||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||||
let key = format!(
|
let key = format!(
|
||||||
"({}, {}) on {}",
|
"(scope={}, name={}, {}) on {}",
|
||||||
record.context.interface().reference,
|
sanitize(&record.context.interface().reference.scope),
|
||||||
|
sanitize(&record.context.interface().reference.name),
|
||||||
record.context.operation().name,
|
record.context.operation().name,
|
||||||
record.context.object().path
|
record.context.object().path
|
||||||
);
|
);
|
||||||
@@ -683,7 +690,11 @@ impl<T: Transport> Runtime<T> {
|
|||||||
let title = match kind {
|
let title = match kind {
|
||||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||||
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
|
Some(InFlightKind::Interface(reference)) => format!(
|
||||||
|
"interface scope={}, name={}",
|
||||||
|
sanitize(&reference.scope),
|
||||||
|
sanitize(&reference.name)
|
||||||
|
),
|
||||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||||
None => "request".into(),
|
None => "request".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user