feat: migrate protocol and HTTP to structured interface references
T-20: identify interfaces by exact scope/name pairs; add optional opaque scope_ref metadata and separate shape validation from path-dependent scope compatibility. Preserve call error precedence, strict JSON, validators and transport boundaries. Mechanically migrate consumers and document API/wire compatibility and deferred lifecycle semantics. Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test -p wip-protocol -p wip-http; cargo test --workspace (221 passing tests including doctests); cargo test --workspace --all-targets; nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps. All pass. cargo/rustc 1.95.0; development-shell Clippy/rustc 1.97.1.
This commit is contained in:
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
||||||
|
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
Initial public release of:
|
Initial public release of:
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# Structured Interface reference 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.
|
||||||
|
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
||||||
|
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
||||||
|
and codec, not a completed lifecycle implementation.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Downstream status
|
||||||
|
|
||||||
|
The workspace Client, Host, filesystem server, TUI and public-API example
|
||||||
|
are mechanically migrated to structured protocol values and updated fixtures.
|
||||||
|
The existing Host's generated opaque registration handle is bridged explicitly
|
||||||
|
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
||||||
|
registration/lifetime model is introduced here. The existing Client keys use
|
||||||
|
structured values; this change does not implement scope deletion/replacement
|
||||||
|
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
||||||
|
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
||||||
|
remains T-23. Interface authoring/macros are descriptor-only and require 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,13 +195,15 @@ 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-in legacy registrations use scope `/` and copy the opaque
|
||||||
|
registration string verbatim into `name`; they do not emit `scope_ref`. 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":"<read-interface-name-from-observation>"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then call a bounded range read:
|
Then call a bounded range read:
|
||||||
@@ -211,7 +213,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":"<read-interface-name>"}},
|
||||||
"operation":"read",
|
"operation":"read",
|
||||||
"arguments":{"offset":0,"length":4096}
|
"arguments":{"offset":0,"length":4096}
|
||||||
}'
|
}'
|
||||||
@@ -225,7 +227,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":"<write-interface-name>"}},
|
||||||
"operation":"replace",
|
"operation":"replace",
|
||||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
|||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
||||||
let reference = custom.reference().as_str().to_owned();
|
let reference = custom.reference().protocol_reference();
|
||||||
let engine = Projection::new(
|
let engine = Projection::new(
|
||||||
MemoryBackend(fixture.shared.clone()),
|
MemoryBackend(fixture.shared.clone()),
|
||||||
registry,
|
registry,
|
||||||
@@ -616,8 +616,8 @@ 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,
|
||||||
}
|
}
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
@@ -696,8 +696,8 @@ impl Fixture {
|
|||||||
limits,
|
limits,
|
||||||
),
|
),
|
||||||
shared,
|
shared,
|
||||||
custom: custom_ref.as_str().into(),
|
custom: custom_ref.protocol_reference(),
|
||||||
read: read_ref.as_str().into(),
|
read: read_ref.protocol_reference(),
|
||||||
limits,
|
limits,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,7 +707,11 @@ 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,
|
||||||
|
principal: &Principal,
|
||||||
|
reference: &wip_protocol::InterfaceReference,
|
||||||
|
) -> InterfaceDescriptor {
|
||||||
self.engine
|
self.engine
|
||||||
.view(principal)
|
.view(principal)
|
||||||
.fetch_interface(&fetch_request(reference))
|
.fetch_interface(&fetch_request(reference))
|
||||||
@@ -717,7 +721,7 @@ impl Fixture {
|
|||||||
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 +731,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 +871,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 {
|
||||||
@@ -1804,8 +1809,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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+57
-17
@@ -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.
|
||||||
@@ -192,11 +192,22 @@ impl ExecutionContext {
|
|||||||
pub struct InterfaceRef(String);
|
pub struct InterfaceRef(String);
|
||||||
|
|
||||||
impl InterfaceRef {
|
impl InterfaceRef {
|
||||||
/// Returns the opaque reference for a protocol transport.
|
/// Returns the legacy opaque registration reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
||||||
|
///
|
||||||
|
/// The opaque registration string is copied as the name without parsing it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: self.0.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||||
@@ -229,13 +240,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: None,
|
||||||
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,
|
||||||
@@ -354,10 +366,16 @@ impl InterfaceRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a transport-provided opaque reference string in this registry.
|
/// Resolves a structured protocol reference to a legacy registration token.
|
||||||
|
///
|
||||||
|
/// Legacy artifacts are published only at root scope. The name is looked up
|
||||||
|
/// verbatim; no scope or name parsing is performed.
|
||||||
#[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());
|
if reference.scope != "/" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let reference = InterfaceRef(reference.name.clone());
|
||||||
self.artifacts.contains_key(&reference).then_some(reference)
|
self.artifacts.contains_key(&reference).then_some(reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,14 +385,20 @@ impl InterfaceRegistry {
|
|||||||
self.artifacts.get(reference).map(Self::observation)
|
self.artifacts.get(reference).map(Self::observation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implements canonical `fetch_interface` semantics for an opaque reference.
|
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
||||||
///
|
///
|
||||||
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
|
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
||||||
/// not object membership errors or authorization failures.
|
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
||||||
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
|
pub fn fetch_interface(
|
||||||
let reference = InterfaceRef(reference.to_owned());
|
&self,
|
||||||
self.artifacts
|
reference: &InterfaceReference,
|
||||||
.get(&reference)
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
self.resolve(reference)
|
||||||
|
.and_then(|reference| self.artifacts.get(&reference))
|
||||||
.map(Self::observation)
|
.map(Self::observation)
|
||||||
.ok_or_else(|| ProtocolError {
|
.ok_or_else(|| ProtocolError {
|
||||||
code: ProtocolErrorCode::InterfaceNotFound,
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
@@ -625,7 +649,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
|
||||||
@@ -678,10 +702,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,
|
||||||
@@ -1039,6 +1070,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 +1124,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| {
|
||||||
|
|||||||
+133
-14
@@ -35,7 +35,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 +61,18 @@ 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 full = registry
|
||||||
|
.fetch_interface(&read.reference().protocol_reference())
|
||||||
|
.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())
|
||||||
|
.unwrap(),
|
||||||
full
|
full
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -478,7 +485,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 +505,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 = registry
|
||||||
|
.fetch_interface(&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 = registry
|
||||||
|
.fetch_interface(&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 +529,12 @@ 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 = registry
|
||||||
|
.fetch_interface(&wip_protocol::InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "unknown".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,8 +550,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 +832,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 +851,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 +870,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 +889,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 +1144,100 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
|||||||
);
|
);
|
||||||
assert_eq!(provider.calls.get(), calls);
|
assert_eq!(provider.calls.get(), calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_registry_boundaries_copy_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());
|
||||||
|
// The registry's colon-delimited name remains opaque, not parsed.
|
||||||
|
assert!(reference.name.starts_with("wip-interface:"));
|
||||||
|
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!(
|
||||||
|
registry.fetch_interface(&nested).unwrap_err().code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
let malformed = wip_protocol::InterfaceReference {
|
||||||
|
scope: "relative".into(),
|
||||||
|
name: reference.name,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
registry.fetch_interface(&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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 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. 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`]
|
||||||
@@ -46,7 +46,7 @@ use wip_host::{
|
|||||||
};
|
};
|
||||||
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,12 +134,12 @@ 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.
|
/// Root-scoped reference assigned to the catalog descriptor.
|
||||||
pub catalog_interface_reference: String,
|
pub catalog_interface_reference: InterfaceReference,
|
||||||
/// Opaque reference assigned to the audit descriptor.
|
/// Root-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.
|
||||||
@@ -296,7 +296,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(),
|
||||||
@@ -319,7 +320,10 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
.code;
|
.code;
|
||||||
|
|
||||||
let interface_not_found = registry
|
let interface_not_found = registry
|
||||||
.fetch_interface("missing-interface")
|
.fetch_interface(&InterfaceReference {
|
||||||
|
scope: "/".into(),
|
||||||
|
name: "missing-interface".into(),
|
||||||
|
})
|
||||||
.expect_err("unknown interface references must not resolve")
|
.expect_err("unknown interface references must not resolve")
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
@@ -330,7 +334,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,8 +350,8 @@ 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,
|
||||||
published,
|
published,
|
||||||
@@ -412,7 +417,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: None,
|
||||||
|
reference: interface.protocol_reference(),
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
|
|||||||
@@ -97,7 +97,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 +105,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 +247,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