feat: unify object observation API

This commit is contained in:
2026-09-30 18:19:12 +09:00
parent 2e4196a1ec
commit ea7b0f00ea
32 changed files with 1035 additions and 1251 deletions
+4 -3
View File
@@ -47,7 +47,7 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. | | `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-fs-server` | Linux directory-handle-confined request-time projection, bounded BFS trees and file operations, standard `wip-https` dispatch/batch handling, and a loopback HTTP binary for TLS-proxy or explicit development use. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, or filesystem-wide preregistration. | | `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-https` dispatch/batch handling, and a loopback HTTP binary for TLS-proxy or explicit development use. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. | | `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a The boundary is intentional: protocol types describe what can cross a
@@ -274,8 +274,9 @@ layout, validator, and filesystem-operation examples are in the
binary and its full security model, configuration, curl walkthrough, and binary and its full security model, configuration, curl walkthrough, and
resource/failure semantics. It resolves every object from current filesystem resource/failure semantics. It resolves every object from current filesystem
state, uses pinned directory-relative handles without following symlinks, omits state, uses pinned directory-relative handles without following symlinks, omits
non-UTF-8 backend names, performs bounded breadth-first tree discovery, and non-UTF-8 backend names, returns complete recursive observations through the
publishes validator-protected replacement only in explicit read-write mode. requested depth or a resource-limit failure, and publishes validator-protected
replacement only in explicit read-write mode.
The built-in cleartext listener is loopback-only: production HTTPS is explicitly The built-in cleartext listener is loopback-only: production HTTPS is explicitly
delegated to a reverse proxy, while local plain HTTP requires an opt-in flag. delegated to a reverse proxy, while local plain HTTP requires an opt-in flag.
+89 -208
View File
@@ -15,14 +15,13 @@ use std::fmt::{self, Display, Formatter};
use wip_https::http::{Request, Response}; use wip_https::http::{Request, Response};
use wip_https::{ use wip_https::{
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response, ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
decode_fetch_interface_response, decode_fetch_response, decode_fetch_tree_response, decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
encode_call_operation_request, encode_fetch_interface_request, encode_fetch_request, encode_fetch_interface_request, encode_observe_request,
encode_fetch_tree_request,
}; };
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchRequest, CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
FetchTreeRequest, FetchTreeResponse, InterfaceDescriptor, InterfaceTarget, Object, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
ObjectTreeNode, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
}; };
/// Monotonic identity assigned to one logical request. /// Monotonic identity assigned to one logical request.
@@ -84,7 +83,7 @@ impl SessionId {
pub struct ClientLimits { pub struct ClientLimits {
/// Maximum simultaneously retained endpoint/security-context sessions. /// Maximum simultaneously retained endpoint/security-context sessions.
pub sessions: usize, pub sessions: usize,
/// Maximum object, path, and lazy-tree observations retained by each session. /// Maximum object, path, and observed-children state retained by each session.
pub objects_per_session: usize, pub objects_per_session: usize,
/// Maximum interface observations retained by each session. /// Maximum interface observations retained by each session.
pub interfaces_per_session: usize, pub interfaces_per_session: usize,
@@ -217,8 +216,6 @@ pub struct TreeObservation {
pub path: String, pub path: String,
/// Materialized direct child paths in Host order. /// Materialized direct child paths in Host order.
pub children: Vec<String>, pub children: Vec<String>,
/// Whether the latest expansion rooted here omitted indexable entries.
pub truncated: bool,
/// Request revision that most recently owned this tree subject. /// Request revision that most recently owned this tree subject.
pub revision: RequestId, pub revision: RequestId,
/// Current expansion lifecycle. /// Current expansion lifecycle.
@@ -375,8 +372,6 @@ pub struct PreparedRequest {
pub enum Completion { pub enum Completion {
/// An object observation was updated. /// An object observation was updated.
Object(ObjectObservation), Object(ObjectObservation),
/// A lazy tree expansion was updated.
Tree(TreeObservation),
/// An interface observation was updated. /// An interface observation was updated.
Interface(InterfaceObservation), Interface(InterfaceObservation),
/// An operation reached a terminal outcome. /// An operation reached a terminal outcome.
@@ -501,13 +496,9 @@ impl Session {
#[derive(Debug)] #[derive(Debug)]
enum Pending { enum Pending {
Fetch { Observe {
session: SessionId, session: SessionId,
request: FetchRequest, request: ObserveRequest,
},
Tree {
session: SessionId,
request: FetchTreeRequest,
}, },
Interface { Interface {
session: SessionId, session: SessionId,
@@ -522,9 +513,7 @@ enum Pending {
impl Pending { impl Pending {
fn session(&self) -> &SessionId { fn session(&self) -> &SessionId {
match self { match self {
Self::Fetch { session, .. } Self::Observe { session, .. } | Self::Interface { session, .. } => session,
| Self::Tree { session, .. }
| Self::Interface { session, .. } => session,
Self::Call { context, .. } => context.session(), Self::Call { context, .. } => context.session(),
} }
} }
@@ -641,70 +630,30 @@ impl Client {
} }
} }
/// Starts or refreshes an object observation. /// Starts or refreshes an object observation through the requested depth.
pub fn prepare_fetch( pub fn prepare_observe(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.ensure_capacity()?;
let request = FetchRequest { path: path.into() };
let endpoint = session_endpoint(session)?;
let encoded =
encode_fetch_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
let id = self.allocate_request_id();
let maximum = self.limits.objects_per_session;
let state = self.session_mut(session)?;
ensure_entry_slot(state, &request.path, maximum)?;
let entry = state
.entries
.entry(request.path.clone())
.or_insert(EntryRecord {
object_key: None,
revision: id,
state: ObservationState::Loading(id),
});
entry.revision = id;
entry.state = ObservationState::Loading(id);
self.pending.insert(
id,
Pending::Fetch {
session: session.clone(),
request,
},
);
Ok(PreparedRequest {
id,
request: encoded,
})
}
/// Starts or refreshes a bounded lazy tree expansion.
pub fn prepare_tree(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
path: impl Into<String>, path: impl Into<String>,
depth: u32, depth: u32,
limit: u32,
) -> Result<PreparedRequest, ClientError> { ) -> Result<PreparedRequest, ClientError> {
self.ensure_capacity()?; self.ensure_capacity()?;
let request = FetchTreeRequest { let request = ObserveRequest {
path: path.into(), path: path.into(),
depth, depth,
limit,
}; };
let endpoint = session_endpoint(session)?; let endpoint = session_endpoint(session)?;
let encoded = let encoded =
encode_fetch_tree_request(&endpoint, &request, self.wire_limits).map_err(|error| { encode_observe_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()) ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?; })?;
let id = self.allocate_request_id(); let id = self.allocate_request_id();
let maximum = self.limits.objects_per_session; let maximum = self.limits.objects_per_session;
let state = self.session_mut(session)?; let state = self.session_mut(session)?;
ensure_entry_slot(state, &request.path, maximum)?; ensure_entry_slot(state, &request.path, maximum)?;
if request.depth > 0 {
ensure_tree_slot(state, &request.path, maximum)?; ensure_tree_slot(state, &request.path, maximum)?;
}
let entry = state let entry = state
.entries .entries
.entry(request.path.clone()) .entry(request.path.clone())
@@ -715,21 +664,26 @@ impl Client {
}); });
entry.revision = id; entry.revision = id;
entry.state = ObservationState::Loading(id); entry.state = ObservationState::Loading(id);
if request.depth > 0 {
let tree = state let tree = state
.trees .trees
.entry(request.path.clone()) .entry(request.path.clone())
.or_insert(TreeObservation { .or_insert(TreeObservation {
path: request.path.clone(), path: request.path.clone(),
children: Vec::new(), children: Vec::new(),
truncated: false,
revision: id, revision: id,
state: ObservationState::Loading(id), state: ObservationState::Loading(id),
}); });
tree.revision = id; tree.revision = id;
tree.state = ObservationState::Loading(id); tree.state = ObservationState::Loading(id);
} else if let Some(tree) = state.trees.get_mut(&request.path)
&& matches!(tree.state, ObservationState::Loading(_))
{
tree.state = ObservationState::Stale;
}
self.pending.insert( self.pending.insert(
id, id,
Pending::Tree { Pending::Observe {
session: session.clone(), session: session.clone(),
request, request,
}, },
@@ -928,11 +882,8 @@ impl Client {
) -> Result<Completion, ClientError> { ) -> Result<Completion, ClientError> {
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?; let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
match pending { match pending {
Pending::Fetch { session, request } => { Pending::Observe { session, request } => {
self.complete_fetch(id, &session, request, response) self.complete_observe(id, &session, request, response)
}
Pending::Tree { session, request } => {
self.complete_tree(id, &session, request, response)
} }
Pending::Interface { session, request } => { Pending::Interface { session, request } => {
self.complete_interface(id, &session, request, response) self.complete_interface(id, &session, request, response)
@@ -994,12 +945,9 @@ impl Client {
other => { other => {
let session = other.session().clone(); let session = other.session().clone();
let is_current = match &other { let is_current = match &other {
Pending::Fetch { request, .. } => { Pending::Observe { request, .. } => {
self.entry_request_is_current(&session, &request.path, id) self.entry_request_is_current(&session, &request.path, id)
} }
Pending::Tree { request, .. } => {
self.tree_request_is_current(&session, &request.path, id)
}
Pending::Interface { request, .. } => { Pending::Interface { request, .. } => {
self.interface_request_is_current(&session, &request.interface, id) self.interface_request_is_current(&session, &request.interface, id)
} }
@@ -1054,28 +1002,29 @@ impl Client {
result result
} }
fn complete_fetch( fn complete_observe(
&mut self, &mut self,
id: RequestId, id: RequestId,
session: &SessionId, session: &SessionId,
request: FetchRequest, request: ObserveRequest,
response: Response<Vec<u8>>, response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> { ) -> Result<Completion, ClientError> {
if !self.entry_request_is_current(session, &request.path, id) { if !self.entry_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id); return self.reject_stale(session, id);
} }
match decode_fetch_response(&request, &response, self.wire_limits) { match decode_observe_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(object)) => { Ok(DecodedResponse::Success(value)) => {
let maximum = self.limits.objects_per_session; let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone(); let mut candidate = self.session(session)?.clone();
if let Err(error) = if let Err(error) =
insert_object(&mut candidate, &request.path, object, maximum, id) apply_observation(&mut candidate, &request.path, value, maximum, id)
{ {
self.session_mut(session)? set_observe_subject_state(
.entries self.session_mut(session)?,
.get_mut(&request.path) &request,
.expect("loading entry") id,
.state = ObservationState::Error(error.clone()); ObservationState::Error(error.clone()),
);
self.push_failure_diagnostic(session, id, &error)?; self.push_failure_diagnostic(session, id, &error)?;
return Err(error); return Err(error);
} }
@@ -1085,62 +1034,6 @@ impl Client {
self.sessions.insert(session.clone(), candidate); self.sessions.insert(session.clone(), candidate);
Ok(Completion::Object(observation)) Ok(Completion::Object(observation))
} }
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
}) => {
self.record_status_mismatch(session, id, status_mismatch)?;
self.session_mut(session)?
.entries
.get_mut(&request.path)
.expect("loading entry")
.state = if error.code == ProtocolErrorCode::NotFound {
ObservationState::Stale
} else {
ObservationState::Error(ClientError::new(
ClientErrorKind::ProtocolFailure,
error.to_string(),
))
};
Ok(Completion::ProtocolFailure(error))
}
Err(error) => self.retrieval_decode_error(
id,
session,
RetrievalSubject::Object(request.path),
error,
),
}
}
fn complete_tree(
&mut self,
id: RequestId,
session: &SessionId,
request: FetchTreeRequest,
response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> {
if !self.tree_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id);
}
match decode_fetch_tree_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(tree)) => {
let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone();
if let Err(error) = apply_tree(&mut candidate, &request.path, tree, maximum, id) {
set_tree_subject_state(
self.session_mut(session)?,
&request.path,
id,
ObservationState::Error(error.clone()),
);
self.push_failure_diagnostic(session, id, &error)?;
return Err(error);
}
let observation = candidate.trees[&request.path].clone();
self.sessions.insert(session.clone(), candidate);
Ok(Completion::Tree(observation))
}
Ok(DecodedResponse::ProtocolFailure { Ok(DecodedResponse::ProtocolFailure {
error, error,
status_mismatch, status_mismatch,
@@ -1154,20 +1047,17 @@ impl Client {
error.to_string(), error.to_string(),
)) ))
}; };
set_tree_subject_state( set_observe_subject_state(
self.session_mut(session)?, self.session_mut(session)?,
&request.path, &request,
id, id,
observation_state, observation_state,
); );
Ok(Completion::ProtocolFailure(error)) Ok(Completion::ProtocolFailure(error))
} }
Err(error) => self.retrieval_decode_error( Err(error) => {
id, self.retrieval_decode_error(id, session, RetrievalSubject::Observe(request), error)
session, }
RetrievalSubject::Tree(request.path),
error,
),
} }
} }
@@ -1372,17 +1262,10 @@ impl Client {
) -> Result<Completion, ClientError> { ) -> Result<Completion, ClientError> {
let error = classify_response_error(error); let error = classify_response_error(error);
match subject { match subject {
RetrievalSubject::Object(path) => { RetrievalSubject::Observe(request) => {
self.session_mut(session)? set_observe_subject_state(
.entries
.get_mut(&path)
.expect("entry exists")
.state = ObservationState::Error(error.clone());
}
RetrievalSubject::Tree(path) => {
set_tree_subject_state(
self.session_mut(session)?, self.session_mut(session)?,
&path, &request,
id, id,
ObservationState::Error(error.clone()), ObservationState::Error(error.clone()),
); );
@@ -1407,17 +1290,10 @@ impl Client {
error: ClientError, error: ClientError,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
match pending { match pending {
Pending::Fetch { session, request } => { Pending::Observe { session, request } => {
self.session_mut(&session)? set_observe_subject_state(
.entries
.get_mut(&request.path)
.expect("entry exists")
.state = ObservationState::Error(error);
}
Pending::Tree { session, request } => {
set_tree_subject_state(
self.session_mut(&session)?, self.session_mut(&session)?,
&request.path, &request,
id, id,
ObservationState::Error(error), ObservationState::Error(error),
); );
@@ -1503,16 +1379,6 @@ impl Client {
) )
} }
fn tree_request_is_current(&self, session: &SessionId, path: &str, id: RequestId) -> bool {
matches!(
self.sessions
.get(session)
.and_then(|state| state.trees.get(path))
.map(|tree| &tree.state),
Some(ObservationState::Loading(current)) if *current == id
)
}
fn interface_request_is_current( fn interface_request_is_current(
&self, &self,
session: &SessionId, session: &SessionId,
@@ -1560,8 +1426,7 @@ impl Client {
#[derive(Debug)] #[derive(Debug)]
enum RetrievalSubject { enum RetrievalSubject {
Object(String), Observe(ObserveRequest),
Tree(String),
Interface(String), Interface(String),
} }
@@ -1589,18 +1454,19 @@ fn classify_response_error(error: ClientResponseError) -> ClientError {
} }
} }
fn set_tree_subject_state( fn set_observe_subject_state(
state: &mut Session, state: &mut Session,
path: &str, request: &ObserveRequest,
revision: RequestId, revision: RequestId,
observation_state: ObservationState, observation_state: ObservationState,
) { ) {
if let Some(tree) = state.trees.get_mut(path) if request.depth > 0
&& let Some(tree) = state.trees.get_mut(&request.path)
&& tree.revision == revision && tree.revision == revision
{ {
tree.state = observation_state.clone(); tree.state = observation_state.clone();
} }
if let Some(entry) = state.entries.get_mut(path) if let Some(entry) = state.entries.get_mut(&request.path)
&& entry.revision == revision && entry.revision == revision
{ {
entry.state = observation_state; entry.state = observation_state;
@@ -1916,9 +1782,10 @@ fn remove_object(state: &mut Session, key: u64) {
} }
} }
fn reserve_tree_metadata( fn reserve_observation_metadata(
state: &mut Session, state: &mut Session,
paths: &[String], paths: &[String],
observed_children_paths: &[String],
maximum: usize, maximum: usize,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
while state.entries.len() while state.entries.len()
@@ -1955,7 +1822,7 @@ fn reserve_tree_metadata(
remove_entry(state, &candidate); remove_entry(state, &candidate);
} }
while state.trees.len() while state.trees.len()
+ paths + observed_children_paths
.iter() .iter()
.filter(|path| !state.trees.contains_key(*path)) .filter(|path| !state.trees.contains_key(*path))
.count() .count()
@@ -1965,7 +1832,8 @@ fn reserve_tree_metadata(
.trees .trees
.iter() .iter()
.find(|(path, tree)| { .find(|(path, tree)| {
!paths.contains(path) && !matches!(tree.state, ObservationState::Loading(_)) !observed_children_paths.contains(path)
&& !matches!(tree.state, ObservationState::Loading(_))
}) })
.map(|(path, _)| path.clone()) .map(|(path, _)| path.clone())
.ok_or_else(|| { .ok_or_else(|| {
@@ -1993,27 +1861,33 @@ fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
} }
} }
fn apply_tree( fn apply_observation(
state: &mut Session, state: &mut Session,
root_path: &str, root_path: &str,
response: FetchTreeResponse, response: ProtocolObjectObservation,
maximum: usize, maximum: usize,
request_id: RequestId, request_id: RequestId,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
let mut nodes = Vec::new(); let mut nodes = Vec::new();
flatten_tree(root_path, &response.root, &mut nodes); flatten_observation(root_path, &response, &mut nodes);
if nodes.len() > maximum { if nodes.len() > maximum {
return Err(ClientError::new( return Err(ClientError::new(
ClientErrorKind::Capacity, ClientErrorKind::Capacity,
"tree response exceeds the local observation bound", "observation response exceeds the local observation bound",
)); ));
} }
let paths: Vec<String> = nodes.iter().map(|(path, _, _)| path.clone()).collect(); let paths: Vec<String> = nodes.iter().map(|(path, _, _)| path.clone()).collect();
reserve_tree_metadata(state, &paths, maximum)?; let observed_children_paths: Vec<String> = nodes
.iter()
.filter_map(|(path, _, children)| children.as_ref().map(|_| path.clone()))
.collect();
reserve_observation_metadata(state, &paths, &observed_children_paths, maximum)?;
if !response.truncated {
let mut omitted = Vec::new(); let mut omitted = Vec::new();
for (path, _, children) in &nodes { for (path, _, children) in &nodes {
let Some(children) = children else {
continue;
};
if let Some(previous) = state if let Some(previous) = state
.trees .trees
.get(path) .get(path)
@@ -2031,7 +1905,6 @@ fn apply_tree(
for path in omitted { for path in omitted {
mark_subtree_stale(state, &path, request_id); mark_subtree_stale(state, &path, request_id);
} }
}
for (path, object, _) in &nodes { for (path, object, _) in &nodes {
validate_object_consistency(state, object)?; validate_object_consistency(state, object)?;
@@ -2044,6 +1917,9 @@ fn apply_tree(
} }
} }
for (path, _, children) in nodes { for (path, _, children) in nodes {
let Some(children) = children else {
continue;
};
let newer_owner = state let newer_owner = state
.trees .trees
.get(&path) .get(&path)
@@ -2051,13 +1927,11 @@ fn apply_tree(
if newer_owner { if newer_owner {
continue; continue;
} }
let is_root = path == root_path;
state.trees.insert( state.trees.insert(
path.clone(), path.clone(),
TreeObservation { TreeObservation {
path, path,
children, children,
truncated: is_root && response.truncated,
revision: request_id, revision: request_id,
state: ObservationState::Fresh, state: ObservationState::Fresh,
}, },
@@ -2066,19 +1940,26 @@ fn apply_tree(
Ok(()) Ok(())
} }
fn flatten_tree( fn flatten_observation(
path: &str, path: &str,
node: &ObjectTreeNode, observation: &ProtocolObjectObservation,
output: &mut Vec<(String, Object, Vec<String>)>, output: &mut Vec<(String, Object, Option<Vec<String>>)>,
) { ) {
let children: Vec<String> = node let children = observation.children.as_ref().map(|children| {
.children children
.iter() .iter()
.map(|child| child_path(path, &child.object.name)) .map(|child| child_path(path, &child.object.name))
.collect(); .collect::<Vec<_>>()
output.push((path.to_owned(), node.object.clone(), children.clone())); });
for (child, child_path) in node.children.iter().zip(children) { output.push((
flatten_tree(&child_path, child, output); path.to_owned(),
observation.object.clone(),
children.clone(),
));
if let (Some(observations), Some(paths)) = (&observation.children, children) {
for (child, child_path) in observations.iter().zip(paths) {
flatten_observation(&child_path, child, output);
}
} }
} }
+19 -14
View File
@@ -6,12 +6,13 @@ use wip_client::{
}; };
use wip_https::http::{Response, StatusCode}; use wip_https::http::{Response, StatusCode};
use wip_https::{ use wip_https::{
Limits, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response, Limits, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError, Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
}; };
fn limits() -> Limits { fn limits() -> Limits {
@@ -76,7 +77,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
]; ];
for body in cases { for body in cases {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.prepare_observe(&session, "/item", 0).unwrap();
let error = client let error = client
.complete(request.id, response(StatusCode::OK, body)) .complete(request.id, response(StatusCode::OK, body))
.unwrap_err(); .unwrap_err();
@@ -106,7 +107,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
]; ];
for (status, body) in cases { for (status, body) in cases {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.prepare_observe(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
.complete(request.id, response(status, body)) .complete(request.id, response(status, body))
@@ -120,13 +121,13 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
#[test] #[test]
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() { fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.prepare_observe(&session, "/item", 0).unwrap();
let error = ProtocolError { let error = ProtocolError {
code: ProtocolErrorCode::NotFound, code: ProtocolErrorCode::NotFound,
message: "missing".into(), message: "missing".into(),
}; };
let mut encoded = let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT; *encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!( assert_eq!(
@@ -146,7 +147,7 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
#[test] #[test]
fn non_success_without_valid_envelope_remains_transport_failure() { fn non_success_without_valid_envelope_remains_transport_failure() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.prepare_observe(&session, "/item", 0).unwrap();
let error = client let error = client
.complete( .complete(
request.id, request.id,
@@ -176,15 +177,19 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
#[test] #[test]
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() { fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let fetch = client.prepare_fetch(&session, "/item").unwrap(); let observation = client.prepare_observe(&session, "/item", 0).unwrap();
client client
.complete( .complete(
fetch.id, observation.id,
encode_fetch_response( encode_observe_response(
&FetchRequest { &ObserveRequest {
path: "/item".into(), path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object(),
children: None,
}, },
&object(),
limits(), limits(),
) )
.unwrap(), .unwrap(),
+271 -140
View File
@@ -6,20 +6,54 @@ use wip_client::{
}; };
use wip_https::http::Response; use wip_https::http::Response;
use wip_https::{ use wip_https::{
Limits, encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response, EncodeHttpError, Limits, encode_call_operation_response, encode_fetch_interface_response,
encode_fetch_tree_response, encode_protocol_error_response, encode_observe_response, encode_protocol_error_response,
}; };
use wip_protocol::{ use wip_protocol::{
CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, InterfaceDescriptor, Object, ObjectObservation, ObserveRequest, OperationDeclaration,
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, ParameterDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration,
ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, TypeExpr, Value,
}; };
fn wire_limits() -> Limits { fn wire_limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap() Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap()
} }
#[derive(Clone)]
struct TestObservation {
object: Object,
children: Vec<TestObservation>,
}
struct TestObservationResponse {
root: TestObservation,
}
fn protocol_observation(node: &TestObservation, depth: u32) -> ObjectObservation {
ObjectObservation {
object: node.object.clone(),
children: (depth > 0).then(|| {
node.children
.iter()
.map(|child| protocol_observation(child, depth - 1))
.collect()
}),
}
}
fn encode_test_observation_response(
request: &ObserveRequest,
value: &TestObservationResponse,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
encode_observe_response(
request,
&protocol_observation(&value.root, request.depth),
limits,
)
}
fn client(objects: usize, interfaces: usize, in_flight: usize, history: usize) -> Client { fn client(objects: usize, interfaces: usize, in_flight: usize, history: usize) -> Client {
Client::new( Client::new(
ClientLimits::new(8, objects, interfaces, in_flight, history).unwrap(), ClientLimits::new(8, objects, interfaces, in_flight, history).unwrap(),
@@ -59,14 +93,25 @@ fn descriptor(return_type: TypeExpr) -> InterfaceDescriptor {
} }
} }
fn fetch_response(path: &str, value: &Object) -> Response<Vec<u8>> { fn observation_response(path: &str, value: &Object) -> Response<Vec<u8>> {
encode_fetch_response(&FetchRequest { path: path.into() }, value, wire_limits()).unwrap() encode_observe_response(
&ObserveRequest {
path: path.into(),
depth: 0,
},
&ObjectObservation {
object: value.clone(),
children: None,
},
wire_limits(),
)
.unwrap()
} }
fn observe_object(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) { fn observe_object(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
let request = client.prepare_fetch(session, path).unwrap(); let request = client.prepare_observe(session, path, 0).unwrap();
client client
.complete(request.id, fetch_response(path, &value)) .complete(request.id, observation_response(path, &value))
.unwrap(); .unwrap();
} }
@@ -134,14 +179,14 @@ fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let old = client.prepare_fetch(&session, "/item").unwrap(); let old = client.prepare_observe(&session, "/item", 0).unwrap();
let new = client.prepare_fetch(&session, "/item").unwrap(); let new = client.prepare_observe(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
.complete( .complete(
old.id, old.id,
fetch_response("/item", &object("item", None, b"old")) observation_response("/item", &object("item", None, b"old"))
) )
.unwrap(), .unwrap(),
Completion::StaleResponseRejected(old.id) Completion::StaleResponseRejected(old.id)
@@ -154,7 +199,7 @@ fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
client client
.complete( .complete(
new.id, new.id,
fetch_response("/item", &object("item", None, b"new")), observation_response("/item", &object("item", None, b"new")),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -182,11 +227,11 @@ fn normalized_object_cache_rejects_conflicts_and_evicts_to_its_bound() {
object("shared", Some("shared-ref"), b"v1"), object("shared", Some("shared-ref"), b"v1"),
); );
let conflict = client.prepare_fetch(&session, "/b/shared").unwrap(); let conflict = client.prepare_observe(&session, "/b/shared", 0).unwrap();
let mut changed = object("shared", Some("shared-ref"), b"v1"); let mut changed = object("shared", Some("shared-ref"), b"v1");
changed.description = Some("contradiction".into()); changed.description = Some("contradiction".into());
let error = client let error = client
.complete(conflict.id, fetch_response("/b/shared", &changed)) .complete(conflict.id, observation_response("/b/shared", &changed))
.unwrap_err(); .unwrap_err();
assert_eq!(error.kind, ClientErrorKind::InvalidResponse); assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
@@ -220,34 +265,33 @@ fn normalized_aliases_preserve_newer_request_ownership() {
"/b/shared", "/b/shared",
object("shared", Some("shared-ref"), b"initial"), object("shared", Some("shared-ref"), b"initial"),
); );
let request = FetchTreeRequest { let request = ObserveRequest {
path: "/a".into(), path: "/a".into(),
depth: 1, depth: 1,
limit: 4,
}; };
let older_tree_response = || FetchTreeResponse { let older_tree_response = || TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("a", None, b"root"), object: object("a", None, b"root"),
children: vec![ObjectTreeNode { children: vec![TestObservation {
object: object("shared", Some("shared-ref"), b"older"), object: object("shared", Some("shared-ref"), b"older"),
children: vec![], children: vec![],
}], }],
}, },
truncated: false,
}; };
let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap(); let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap(); let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
client client
.complete( .complete(
newer_alias.id, newer_alias.id,
fetch_response("/b/shared", &object("shared", Some("shared-ref"), b"newer")), observation_response("/b/shared", &object("shared", Some("shared-ref"), b"newer")),
) )
.unwrap(); .unwrap();
client client
.complete( .complete(
older_tree.id, older_tree.id,
encode_fetch_tree_response(&request, &older_tree_response(), wire_limits()).unwrap(), encode_test_observation_response(&request, &older_tree_response(), wire_limits())
.unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -259,12 +303,13 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newer".to_vec()) Some(b"newer".to_vec())
); );
let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap(); let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap(); let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
client client
.complete( .complete(
older_tree.id, older_tree.id,
encode_fetch_tree_response(&request, &older_tree_response(), wire_limits()).unwrap(), encode_test_observation_response(&request, &older_tree_response(), wire_limits())
.unwrap(),
) )
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
@@ -278,7 +323,7 @@ fn normalized_aliases_preserve_newer_request_ownership() {
client client
.complete( .complete(
newer_alias.id, newer_alias.id,
fetch_response( observation_response(
"/b/shared", "/b/shared",
&object("shared", Some("shared-ref"), b"newest"), &object("shared", Some("shared-ref"), b"newest"),
), ),
@@ -289,12 +334,12 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newest".to_vec()) Some(b"newest".to_vec())
); );
let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap(); let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap(); let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
client client
.complete( .complete(
newer_alias.id, newer_alias.id,
fetch_response( observation_response(
"/b/shared", "/b/shared",
&object("shared", Some("shared-ref"), b"newest"), &object("shared", Some("shared-ref"), b"newest"),
), ),
@@ -305,17 +350,16 @@ fn normalized_aliases_preserve_newer_request_ownership() {
let error = client let error = client
.complete( .complete(
older_tree.id, older_tree.id,
encode_fetch_tree_response( encode_test_observation_response(
&request, &request,
&FetchTreeResponse { &TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("a", None, b"root"), object: object("a", None, b"root"),
children: vec![ObjectTreeNode { children: vec![TestObservation {
object: contradictory, object: contradictory,
children: vec![], children: vec![],
}], }],
}, },
truncated: false,
}, },
wire_limits(), wire_limits(),
) )
@@ -334,35 +378,33 @@ fn normalized_aliases_preserve_newer_request_ownership() {
"/child", "/child",
object("child", Some("child-ref"), b"stable"), object("child", Some("child-ref"), b"stable"),
); );
let older_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); let older_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child = client.prepare_fetch(&session, "/child").unwrap(); let newer_child = client.prepare_observe(&session, "/child", 0).unwrap();
client client
.complete( .complete(
newer_child.id, newer_child.id,
fetch_response("/child", &object("child", Some("child-ref"), b"stable")), observation_response("/child", &object("child", Some("child-ref"), b"stable")),
) )
.unwrap(); .unwrap();
let mut contradictory = object("child", Some("child-ref"), b"stable"); let mut contradictory = object("child", Some("child-ref"), b"stable");
contradictory.description = Some("same-path contradiction".into()); contradictory.description = Some("same-path contradiction".into());
let root_request = FetchTreeRequest { let root_request = ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 4,
}; };
let error = client let error = client
.complete( .complete(
older_tree.id, older_tree.id,
encode_fetch_tree_response( encode_test_observation_response(
&root_request, &root_request,
&FetchTreeResponse { &TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"root"), object: object("", None, b"root"),
children: vec![ObjectTreeNode { children: vec![TestObservation {
object: contradictory, object: contradictory,
children: vec![], children: vec![],
}], }],
}, },
truncated: false,
}, },
wire_limits(), wire_limits(),
) )
@@ -377,61 +419,151 @@ fn normalized_aliases_preserve_newer_request_ownership() {
} }
#[test] #[test]
fn tree_expansion_is_lazy_truncated_and_refreshes_edges() { fn observation_expansion_is_complete_and_refreshes_edges() {
let mut client = client(16, 8, 8, 8); let mut client = client(16, 8, 8, 8);
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
assert!(client.tree(&session, "/").is_none()); assert!(client.tree(&session, "/").is_none());
let request = client.prepare_tree(&session, "/", 1, 4).unwrap(); let request = client.prepare_observe(&session, "/", 1).unwrap();
let logical_request = FetchTreeRequest { let logical_request = ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 4,
}; };
let tree = FetchTreeResponse { let tree = TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"root"), object: object("", None, b"root"),
children: vec![ObjectTreeNode { children: vec![TestObservation {
object: object("items", None, b"items"), object: object("items", None, b"items"),
children: vec![], children: vec![],
}], }],
}, },
truncated: true,
}; };
client client
.complete( .complete(
request.id, request.id,
encode_fetch_tree_response(&logical_request, &tree, wire_limits()).unwrap(), encode_test_observation_response(&logical_request, &tree, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]); assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]);
assert!(client.tree(&session, "/").unwrap().truncated);
assert!(client.object(&session, "/items").is_some()); assert!(client.object(&session, "/items").is_some());
let refresh = client.prepare_tree(&session, "/", 1, 4).unwrap(); let refresh = client.prepare_observe(&session, "/", 1).unwrap();
let refreshed = FetchTreeResponse { let refreshed = TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"root-2"), object: object("", None, b"root-2"),
children: vec![], children: vec![],
}, },
truncated: false,
}; };
client client
.complete( .complete(
refresh.id, refresh.id,
encode_fetch_tree_response(&logical_request, &refreshed, wire_limits()).unwrap(), encode_test_observation_response(&logical_request, &refreshed, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert!(client.tree(&session, "/").unwrap().children.is_empty()); assert!(client.tree(&session, "/").unwrap().children.is_empty());
assert!(!client.tree(&session, "/").unwrap().truncated);
assert_eq!( assert_eq!(
client.object(&session, "/items").unwrap().state, client.object(&session, "/items").unwrap().state,
ObservationState::Stale ObservationState::Stale
); );
} }
#[test]
fn depth_zero_observation_preserves_previously_observed_children() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let deep_request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let deep = client.prepare_observe(&session, "/", 1).unwrap();
client
.complete(
deep.id,
encode_test_observation_response(
&deep_request,
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"root-v1"),
children: vec![TestObservation {
object: object("child", None, b"child-v1"),
children: vec![],
}],
},
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap();
client
.complete(
shallow.id,
observation_response("/", &object("", None, b"root-v2")),
)
.unwrap();
assert_eq!(client.tree(&session, "/").unwrap().children, ["/child"]);
assert_eq!(
client.object(&session, "/child").unwrap().state,
ObservationState::Fresh
);
assert_eq!(
client.object(&session, "/").unwrap().validator,
Some(b"root-v2".to_vec())
);
}
#[test]
fn shallow_observation_supersedes_loading_deep_observation_without_leaking_loading_state() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let deep_request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let deep_value = TestObservationResponse {
root: TestObservation {
object: object("", None, b"old-root"),
children: vec![],
},
};
let deep = client.prepare_observe(&session, "/", 1).unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap();
assert_eq!(
client.tree(&session, "/").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client
.complete(
deep.id,
encode_test_observation_response(&deep_request, &deep_value, wire_limits())
.unwrap(),
)
.unwrap(),
Completion::StaleResponseRejected(deep.id)
);
client
.complete(
shallow.id,
observation_response("/", &object("", None, b"new-root")),
)
.unwrap();
assert_eq!(
client.tree(&session, "/").unwrap().state,
ObservationState::Stale
);
}
#[test] #[test]
fn call_context_fixes_membership_validators_descriptor_and_result_validation() { fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
let mut client = client(8, 8, 8, 8); let mut client = client(8, 8, 8, 8);
@@ -686,7 +818,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
}, },
); );
let older_alias = client.prepare_fetch(&session, "/alias/item").unwrap(); let older_alias = client.prepare_observe(&session, "/alias/item", 0).unwrap();
let call = client let call = client
.prepare_call(&session, "/item", "math", "calculate", args(7)) .prepare_call(&session, "/item", "math", "calculate", args(7))
.unwrap(); .unwrap();
@@ -723,7 +855,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
client client
.complete( .complete(
older_alias.id, older_alias.id,
fetch_response( observation_response(
"/alias/item", "/alias/item",
&object("item", Some("shared-ref"), b"object-v1"), &object("item", Some("shared-ref"), b"object-v1"),
), ),
@@ -746,7 +878,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
"/item", "/item",
object("item", Some("shared-ref"), b"object-v2"), object("item", Some("shared-ref"), b"object-v2"),
); );
let older_alias = client.prepare_fetch(&session, "/second/item").unwrap(); let older_alias = client.prepare_observe(&session, "/second/item", 0).unwrap();
let call = client let call = client
.prepare_call(&session, "/item", "math", "calculate", args(8)) .prepare_call(&session, "/item", "math", "calculate", args(8))
.unwrap(); .unwrap();
@@ -769,7 +901,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
client client
.complete( .complete(
older_alias.id, older_alias.id,
fetch_response( observation_response(
"/second/item", "/second/item",
&object("item", Some("shared-ref"), b"object-v2"), &object("item", Some("shared-ref"), b"object-v2"),
), ),
@@ -808,7 +940,10 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
client.mark_dispatched(call.id).unwrap(); client.mark_dispatched(call.id).unwrap();
assert_eq!( assert_eq!(
client.prepare_fetch(&session, "/other").unwrap_err().kind, client
.prepare_observe(&session, "/other", 0)
.unwrap_err()
.kind,
ClientErrorKind::Capacity ClientErrorKind::Capacity
); );
let unknown = ProtocolError { let unknown = ProtocolError {
@@ -980,57 +1115,55 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let tree_request = FetchTreeRequest { let tree_request = ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 4,
}; };
let tree_value = FetchTreeResponse { let tree_value = TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"root"), object: object("", None, b"root"),
children: vec![ObjectTreeNode { children: vec![TestObservation {
object: object("child", None, b"tree-child"), object: object("child", None, b"tree-child"),
children: vec![], children: vec![],
}], }],
}, },
truncated: false,
}; };
let old_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); let old_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_fetch = client.prepare_fetch(&session, "/child").unwrap(); let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
client client
.complete( .complete(
old_tree.id, old_tree.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(), encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
client.object(&session, "/child").unwrap().state, client.object(&session, "/child").unwrap().state,
ObservationState::Loading(id) if id == newer_fetch.id ObservationState::Loading(id) if id == newer_observation.id
)); ));
client client
.complete( .complete(
newer_fetch.id, newer_observation.id,
fetch_response("/child", &object("child", None, b"newer-fetch")), observation_response("/child", &object("child", None, b"newer-observation")),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
client.object(&session, "/child").unwrap().validator, client.object(&session, "/child").unwrap().validator,
Some(b"newer-fetch".to_vec()) Some(b"newer-observation".to_vec())
); );
let older_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); let older_tree = client.prepare_observe(&session, "/", 1).unwrap();
let completed_newer_fetch = client.prepare_fetch(&session, "/child").unwrap(); let completed_newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
client client
.complete( .complete(
completed_newer_fetch.id, completed_newer_observation.id,
fetch_response("/child", &object("child", None, b"completed-newer")), observation_response("/child", &object("child", None, b"completed-newer")),
) )
.unwrap(); .unwrap();
client client
.complete( .complete(
older_tree.id, older_tree.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(), encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1038,25 +1171,24 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
Some(b"completed-newer".to_vec()) Some(b"completed-newer".to_vec())
); );
let older_omitting_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); let older_omitting_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_fetch = client.prepare_fetch(&session, "/child").unwrap(); let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
client client
.complete( .complete(
newer_fetch.id, newer_observation.id,
fetch_response("/child", &object("child", None, b"newer-than-omission")), observation_response("/child", &object("child", None, b"newer-than-omission")),
) )
.unwrap(); .unwrap();
client client
.complete( .complete(
older_omitting_tree.id, older_omitting_tree.id,
encode_fetch_tree_response( encode_test_observation_response(
&tree_request, &tree_request,
&FetchTreeResponse { &TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"older-root"), object: object("", None, b"older-root"),
children: vec![], children: vec![],
}, },
truncated: false,
}, },
wire_limits(), wire_limits(),
) )
@@ -1067,12 +1199,12 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
assert_eq!(child.state, ObservationState::Fresh); assert_eq!(child.state, ObservationState::Fresh);
assert_eq!(child.validator, Some(b"newer-than-omission".to_vec())); assert_eq!(child.validator, Some(b"newer-than-omission".to_vec()));
let old_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); let old_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child_tree = client.prepare_tree(&session, "/child", 0, 1).unwrap(); let newer_child_tree = client.prepare_observe(&session, "/child", 1).unwrap();
client client
.complete( .complete(
old_tree.id, old_tree.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(), encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
@@ -1088,22 +1220,20 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
child_observation.validator, child_observation.validator,
Some(b"newer-than-omission".to_vec()) Some(b"newer-than-omission".to_vec())
); );
let child_request = FetchTreeRequest { let child_request = ObserveRequest {
path: "/child".into(), path: "/child".into(),
depth: 0, depth: 1,
limit: 1,
}; };
let child_value = FetchTreeResponse { let child_value = TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("child", None, b"newer-tree"), object: object("child", None, b"newer-tree"),
children: vec![], children: vec![],
}, },
truncated: false,
}; };
client client
.complete( .complete(
newer_child_tree.id, newer_child_tree.id,
encode_fetch_tree_response(&child_request, &child_value, wire_limits()).unwrap(), encode_test_observation_response(&child_request, &child_value, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1111,19 +1241,18 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
ObservationState::Fresh ObservationState::Fresh
); );
let older_parent = client.prepare_tree(&session, "/", 1, 4).unwrap(); let older_parent = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child = client.prepare_tree(&session, "/child", 0, 1).unwrap(); let newer_child = client.prepare_observe(&session, "/child", 1).unwrap();
client client
.complete( .complete(
newer_child.id, newer_child.id,
encode_fetch_tree_response( encode_test_observation_response(
&child_request, &child_request,
&FetchTreeResponse { &TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("child", None, b"completed-newer-tree"), object: object("child", None, b"completed-newer-tree"),
children: vec![], children: vec![],
}, },
truncated: false,
}, },
wire_limits(), wire_limits(),
) )
@@ -1133,7 +1262,7 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
client client
.complete( .complete(
older_parent.id, older_parent.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(), encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1152,8 +1281,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let old = client.prepare_fetch(&session, "/item").unwrap(); let old = client.prepare_observe(&session, "/item", 0).unwrap();
let newer = client.prepare_fetch(&session, "/item").unwrap(); let newer = client.prepare_observe(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(old.id, "old connection failed") .fail_transport(old.id, "old connection failed")
@@ -1167,12 +1296,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
client client
.complete( .complete(
newer.id, newer.id,
fetch_response("/item", &object("item", None, b"new")), observation_response("/item", &object("item", None, b"new")),
) )
.unwrap(); .unwrap();
let old = client.prepare_tree(&session, "/", 0, 1).unwrap(); let old = client.prepare_observe(&session, "/", 1).unwrap();
let newer_tree = client.prepare_tree(&session, "/", 0, 1).unwrap(); let newer_tree = client.prepare_observe(&session, "/", 1).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(old.id, "old tree connection failed") .fail_transport(old.id, "old tree connection failed")
@@ -1186,18 +1315,16 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
client client
.complete( .complete(
newer_tree.id, newer_tree.id,
encode_fetch_tree_response( encode_test_observation_response(
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 0, depth: 1,
limit: 1,
}, },
&FetchTreeResponse { &TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"root"), object: object("", None, b"root"),
children: vec![], children: vec![],
}, },
truncated: false,
}, },
wire_limits(), wire_limits(),
) )
@@ -1283,9 +1410,12 @@ fn entry_eviction_never_discards_a_loading_tree_subject() {
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
observe_object(&mut client, &session, "/", object("", None, b"root")); observe_object(&mut client, &session, "/", object("", None, b"root"));
let tree = client.prepare_tree(&session, "/", 0, 1).unwrap(); let tree = client.prepare_observe(&session, "/", 1).unwrap();
assert_eq!( assert_eq!(
client.prepare_fetch(&session, "/other").unwrap_err().kind, client
.prepare_observe(&session, "/other", 0)
.unwrap_err()
.kind,
ClientErrorKind::Capacity ClientErrorKind::Capacity
); );
assert!(matches!( assert!(matches!(
@@ -1295,18 +1425,16 @@ fn entry_eviction_never_discards_a_loading_tree_subject() {
client client
.complete( .complete(
tree.id, tree.id,
encode_fetch_tree_response( encode_test_observation_response(
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 0, depth: 1,
limit: 1,
}, },
&FetchTreeResponse { &TestObservationResponse {
root: ObjectTreeNode { root: TestObservation {
object: object("", None, b"tree"), object: object("", None, b"tree"),
children: vec![], children: vec![],
}, },
truncated: false,
}, },
wire_limits(), wire_limits(),
) )
@@ -1326,7 +1454,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
for path in ["/one", "/two", "/three"] { for path in ["/one", "/two", "/three"] {
let request = client.prepare_fetch(&session, path).unwrap(); let request = client.prepare_observe(&session, path, 0).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(request.id, "retrieval failed") .fail_transport(request.id, "retrieval failed")
@@ -1340,7 +1468,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
assert!(client.object(&session, "/three").is_some()); assert!(client.object(&session, "/three").is_some());
for path in ["/alpha", "/beta", "/gamma"] { for path in ["/alpha", "/beta", "/gamma"] {
let request = client.prepare_tree(&session, path, 0, 1).unwrap(); let request = client.prepare_observe(&session, path, 1).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(request.id, "tree failed") .fail_transport(request.id, "tree failed")
@@ -1359,16 +1487,19 @@ fn in_flight_and_history_are_bounded() {
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let first = client.prepare_fetch(&session, "/one").unwrap(); let first = client.prepare_observe(&session, "/one", 0).unwrap();
let error = client.prepare_fetch(&session, "/two").unwrap_err(); let error = client.prepare_observe(&session, "/two", 0).unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Capacity); assert_eq!(error.kind, ClientErrorKind::Capacity);
client client
.complete(first.id, fetch_response("/one", &object("one", None, b"1"))) .complete(
first.id,
observation_response("/one", &object("one", None, b"1")),
)
.unwrap(); .unwrap();
// Diagnostic history evicts oldest entries at the configured bound. // Diagnostic history evicts oldest entries at the configured bound.
let stale_old = client.prepare_fetch(&session, "/one").unwrap(); let stale_old = client.prepare_observe(&session, "/one", 0).unwrap();
let stale_new = client.prepare_fetch(&session, "/one").unwrap_err(); let stale_new = client.prepare_observe(&session, "/one", 0).unwrap_err();
assert_eq!(stale_new.kind, ClientErrorKind::Capacity); assert_eq!(stale_new.kind, ClientErrorKind::Capacity);
client client
.complete( .complete(
+15 -16
View File
@@ -68,29 +68,29 @@ The following examples use development HTTP for readability. The JSON is the
standard `wip-https` profile; validators and operation `Bytes` are base64 body standard `wip-https` profile; validators and operation `Bytes` are base64 body
fields, never `ETag` or `If-Match` headers. fields, never `ETag` or `If-Match` headers.
Fetch root: Observe only the root Object:
```console ```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch \ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"path":"/"}' --data '{"path":"/","depth":0}'
``` ```
Fetch a bounded tree (the root counts toward `limit`): Observe the root and two levels of indexable children:
```console ```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_tree \ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"path":"/","depth":2,"limit":100}' --data '{"path":"/","depth":2}'
``` ```
A regular-file fetch returns the opaque read interface reference and an object A regular-file observation returns the opaque read interface reference and an object
validator. Retrieve that descriptor with: validator. Retrieve that descriptor with:
```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-fetch>"}' --data '{"interface":"<read-interface-from-observation>"}'
``` ```
Then call a bounded range read: Then call a bounded range read:
@@ -120,8 +120,8 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
}' }'
``` ```
`fetch`, `fetch_tree`, and `fetch_interface` can also be combined through the `observe` and `fetch_interface` can also be combined through the standard
standard `POST /wip/v1/batch` route. `call_operation` is never batchable. `POST /wip/v1/batch` route. `call_operation` is never batchable.
## Resource and failure behavior ## Resource and failure behavior
@@ -133,12 +133,11 @@ stalled clients are closed and cannot occupy a worker beyond that bound. Workers
accept connections directly, so the listener never moves more than the configured accept connections directly, so the listener never moves more than the configured
concurrency into active userspace service. Advisory file-lock acquisition uses a concurrency into active userspace service. Advisory file-lock acquisition uses a
nonblocking loop and checks the same deadline/cancellation rather than allowing a nonblocking loop and checks the same deadline/cancellation rather than allowing a
contended lock to stall a worker. Tree traversal is breadth-first and opens only contended lock to stall a worker. Object observation recursively opens only the
requested entries. Once a limit or depth boundary is reached it reads only far requested depth. A successful response is complete through that depth; if the
enough to configured projected-node budget would be exceeded, the interaction fails with
prove whether an additional visible child exists, so `truncated` is true only `ResourceLimitExceeded` instead of returning a partial observation. I/O failure
when something was actually omitted. I/O failure and cancellation remain errors, and cancellation remain errors.
not clean exhaustion.
Whole-file replacement compares the request validator against the identity and Whole-file replacement compares the request validator against the identity and
state of the same pinned file handle used for the write. Failures before mutation state of the same pinned file handle used for the write. Failures before mutation
+9 -9
View File
@@ -51,10 +51,10 @@ pub struct ResourceLimits {
pub max_file_read_bytes: usize, pub max_file_read_bytes: usize,
/// Maximum bytes accepted by one file replacement. /// Maximum bytes accepted by one file replacement.
pub max_file_write_bytes: usize, pub max_file_write_bytes: usize,
/// Maximum accepted `fetch_tree` depth. /// Maximum accepted `observe` depth.
pub max_tree_depth: u32, pub max_observation_depth: u32,
/// Maximum accepted `fetch_tree` node count, including its root. /// Maximum accepted `observe` node count, including its root.
pub max_tree_nodes: u32, pub max_observation_nodes: u32,
/// Maximum simultaneously pinned projected filesystem handles. /// Maximum simultaneously pinned projected filesystem handles.
pub max_open_handles: usize, pub max_open_handles: usize,
/// Maximum bytes accepted for one HTTP request head, including its terminator. /// Maximum bytes accepted for one HTTP request head, including its terminator.
@@ -72,8 +72,8 @@ impl Default for ResourceLimits {
.expect("default wire limits are nonzero"), .expect("default wire limits are nonzero"),
max_file_read_bytes: 1024 * 1024, max_file_read_bytes: 1024 * 1024,
max_file_write_bytes: 1024 * 1024, max_file_write_bytes: 1024 * 1024,
max_tree_depth: 32, max_observation_depth: 32,
max_tree_nodes: 1024, max_observation_nodes: 1024,
max_open_handles: 2048, max_open_handles: 2048,
max_http_header_bytes: 16 * 1024, max_http_header_bytes: 16 * 1024,
max_concurrent_requests: 32, max_concurrent_requests: 32,
@@ -125,7 +125,7 @@ impl ServerConfig {
} }
if self.limits.max_file_read_bytes == 0 if self.limits.max_file_read_bytes == 0
|| self.limits.max_file_write_bytes == 0 || self.limits.max_file_write_bytes == 0
|| self.limits.max_tree_nodes == 0 || self.limits.max_observation_nodes == 0
|| self.limits.max_open_handles == 0 || self.limits.max_open_handles == 0
|| self.limits.max_http_header_bytes == 0 || self.limits.max_http_header_bytes == 0
|| self.limits.max_concurrent_requests == 0 || self.limits.max_concurrent_requests == 0
@@ -133,7 +133,7 @@ impl ServerConfig {
{ {
return Err(ConfigError::ZeroLimit); return Err(ConfigError::ZeroLimit);
} }
if self.limits.max_open_handles < self.limits.max_tree_nodes as usize { if self.limits.max_open_handles < self.limits.max_observation_nodes as usize {
return Err(ConfigError::InsufficientHandles); return Err(ConfigError::InsufficientHandles);
} }
if self.limits.wire.max_response_bytes() < 512 { if self.limits.wire.max_response_bytes() < 512 {
@@ -171,7 +171,7 @@ impl std::fmt::Display for ConfigError {
"endpoint base path must be absolute, canonical, and query-free" "endpoint base path must be absolute, canonical, and query-free"
} }
Self::ZeroLimit => "all resource limits and the request timeout must be nonzero", Self::ZeroLimit => "all resource limits and the request timeout must be nonzero",
Self::InsufficientHandles => "max_open_handles must cover max_tree_nodes", Self::InsufficientHandles => "max_open_handles must cover max_observation_nodes",
Self::ResponseLimitTooSmall => "max_response_bytes must be at least 512", Self::ResponseLimitTooSmall => "max_response_bytes must be at least 512",
}) })
} }
+102 -110
View File
@@ -1,4 +1,3 @@
use std::collections::VecDeque;
use std::ffi::CStr; use std::ffi::CStr;
use std::mem::MaybeUninit; use std::mem::MaybeUninit;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
@@ -17,8 +16,8 @@ use wip_host::{
}; };
use wip_interface as wip; use wip_interface as wip;
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, FetchTreeResponse, Object, FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
ObjectTreeNode, ProtocolError, ProtocolErrorCode, ObserveResponse, ProtocolError, ProtocolErrorCode,
}; };
use crate::interfaces::{ use crate::interfaces::{
@@ -223,37 +222,60 @@ impl Projection {
.protocol_response() .protocol_response()
} }
pub(crate) fn fetch_tree( pub(crate) fn observe(
&self, &self,
context: &ExecutionContext, context: &ExecutionContext,
request: &FetchTreeRequest, request: &ObserveRequest,
) -> Result<FetchTreeResponse, ProtocolError> { ) -> Result<ObserveResponse, ProtocolError> {
request.validate().map_err(|_| ProtocolError { request.validate().map_err(|_| ProtocolError {
code: ProtocolErrorCode::InvalidRequest, code: ProtocolErrorCode::InvalidRequest,
message: "invalid fetch_tree request".into(), message: "invalid observe request".into(),
})?; })?;
if request.depth > self.limits.max_tree_depth || request.limit > self.limits.max_tree_nodes if request.depth > self.limits.max_observation_depth {
{
return Err(ProjectionError::Resource.protocol()); return Err(ProjectionError::Resource.protocol());
} }
let root = self let root = self
.resolve(context, &request.path) .resolve(context, &request.path)
.map_err(ProjectionError::protocol)?; .map_err(ProjectionError::protocol)?;
let root_object = self let mut node_count = 0_u32;
.publication(root.clone(), context.clone()) let response = self.observe_entry(context, root, 0, request.depth, &mut node_count)?;
.protocol_object(); request
let mut objects = vec![root_object]; .validate_response(&response)
let mut children: Vec<Vec<usize>> = vec![Vec::new()]; .map_err(|_| ProtocolError {
let mut queue = VecDeque::from([(0_usize, root, 0_u32)]); code: ProtocolErrorCode::Internal,
let mut truncated = false; message: "Host produced an invalid observation response".into(),
})?;
while let Some((parent_index, parent, depth)) = queue.pop_front() { Ok(response)
check_context(context).map_err(ProjectionError::protocol)?;
if parent.kind != EntryKind::Directory {
continue;
} }
let directory_fd = reopen(parent.fd(), OFlags::RDONLY | OFlags::DIRECTORY)
fn observe_entry(
&self,
context: &ExecutionContext,
entry: Entry,
depth: u32,
maximum_depth: u32,
node_count: &mut u32,
) -> Result<ObjectObservation, ProtocolError> {
check_context(context).map_err(ProjectionError::protocol)?;
*node_count = node_count.saturating_add(1);
if *node_count > self.limits.max_observation_nodes {
return Err(ProjectionError::Resource.protocol());
}
let object = self
.publication(entry.clone(), context.clone())
.protocol_object();
if depth == maximum_depth {
return Ok(ObjectObservation {
object,
children: None,
});
}
let mut children = Vec::new();
if entry.kind == EntryKind::Directory {
let directory_fd = reopen(entry.fd(), OFlags::RDONLY | OFlags::DIRECTORY)
.map_err(ProjectionError::protocol)?; .map_err(ProjectionError::protocol)?;
let mut buffer = [MaybeUninit::<u8>::uninit(); 8192]; let mut buffer = [MaybeUninit::<u8>::uninit(); 8192];
let mut directory = RawDir::new(directory_fd, &mut buffer); let mut directory = RawDir::new(directory_fd, &mut buffer);
@@ -271,50 +293,25 @@ impl Projection {
if name.contains('/') || name.is_empty() || name == "." || name == ".." { if name.contains('/') || name.is_empty() || name == "." || name == ".." {
continue; continue;
} }
let child = match self.open_child(context, parent.fd(), name) { let child = match self.open_child(context, entry.fd(), name) {
Ok(child) => child, Ok(child) => child,
Err(ProjectionError::NotFound) => continue, Err(ProjectionError::NotFound) => continue,
Err(error) => return Err(error.protocol()), Err(error) => return Err(error.protocol()),
}; };
if depth == request.depth || objects.len() == request.limit as usize { children.push(self.observe_entry(
truncated = true; context,
break; child,
} depth + 1,
let child_index = objects.len(); maximum_depth,
objects.push( node_count,
self.publication(child.clone(), context.clone()) )?);
.protocol_object(),
);
children.push(Vec::new());
children[parent_index].push(child_index);
if child.kind == EntryKind::Directory {
queue.push_back((child_index, child, depth + 1));
}
}
if truncated && objects.len() == request.limit as usize {
break;
} }
} }
fn build(index: usize, objects: &[Object], children: &[Vec<usize>]) -> ObjectTreeNode { Ok(ObjectObservation {
ObjectTreeNode { object,
object: objects[index].clone(), children: Some(children),
children: children[index] })
.iter()
.map(|child| build(*child, objects, children))
.collect(),
}
}
let response = FetchTreeResponse {
root: build(0, &objects, &children),
truncated,
};
response.validate_for(request).map_err(|_| ProtocolError {
code: ProtocolErrorCode::Internal,
message: "Host produced an invalid tree response".into(),
})?;
Ok(response)
} }
fn publication( fn publication(
@@ -809,7 +806,7 @@ mod tests {
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, ExecutionContext, InterfaceTarget, ObjectProvider, Target, CallErrorKind, CallRequest, ExecutionContext, InterfaceTarget, ObjectProvider, Target,
}; };
use wip_protocol::{FetchTreeRequest, ProtocolErrorCode, Value}; use wip_protocol::{ObserveRequest, ProtocolErrorCode, Value};
use super::{FailureInjection, FlockOperation, Projection}; use super::{FailureInjection, FlockOperation, Projection};
use crate::{AccessMode, ResourceLimits}; use crate::{AccessMode, ResourceLimits};
@@ -850,23 +847,29 @@ mod tests {
.expect("create symlink"); .expect("create symlink");
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let root = projection.fetch("/").expect("fetch root"); let root = projection.observe_object("/").expect("observe root");
assert_eq!(root.name, ""); assert_eq!(root.name, "");
assert_eq!(root.description.as_deref(), Some("Filesystem directory")); assert_eq!(root.description.as_deref(), Some("Filesystem directory"));
assert!(root.interfaces.is_empty()); assert!(root.interfaces.is_empty());
assert!(root.validator.is_none()); assert!(root.validator.is_none());
let file = projection.fetch("/file.txt").expect("fetch file"); let file = projection
.observe_object("/file.txt")
.expect("observe file");
assert_eq!(file.interfaces.len(), 1); assert_eq!(file.interfaces.len(), 1);
assert!(file.validator.is_some()); assert!(file.validator.is_some());
assert!(file.r#ref.is_none()); assert!(file.r#ref.is_none());
let directory = projection.fetch("/dir").expect("fetch directory"); let directory = projection
.observe_object("/dir")
.expect("observe directory");
assert!(directory.interfaces.is_empty()); assert!(directory.interfaces.is_empty());
#[cfg(unix)] #[cfg(unix)]
{ {
let link = projection.fetch("/link").expect("fetch link itself"); let link = projection
.observe_object("/link")
.expect("observe link itself");
assert_eq!( assert_eq!(
link.description.as_deref(), link.description.as_deref(),
Some("Symbolic link (not followed)") Some("Symbolic link (not followed)")
@@ -874,7 +877,7 @@ mod tests {
assert_eq!(link.interfaces.len(), 1); assert_eq!(link.interfaces.len(), 1);
assert_eq!( assert_eq!(
projection projection
.fetch("/link/shadow") .observe_object("/link/shadow")
.expect_err("must not follow") .expect_err("must not follow")
.kind(), .kind(),
CallErrorKind::NotFound CallErrorKind::NotFound
@@ -892,18 +895,21 @@ mod tests {
} }
assert_eq!( assert_eq!(
projection.fetch("/missing").expect_err("missing").kind(), projection
.observe_object("/missing")
.expect_err("missing")
.kind(),
CallErrorKind::NotFound CallErrorKind::NotFound
); );
} }
#[test] #[test]
fn reads_are_bounded_and_tree_truncation_is_exact() { fn reads_are_bounded_and_observation_is_complete() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
fs::write(temporary.path().join("a"), b"abcdef").expect("write file"); fs::write(temporary.path().join("a"), b"abcdef").expect("write file");
fs::write(temporary.path().join("b"), b"b").expect("write file"); fs::write(temporary.path().join("b"), b"b").expect("write file");
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let file = projection.fetch("/a").expect("fetch file"); let file = projection.observe_object("/a").expect("observe file");
let result = call( let result = call(
&projection, &projection,
"/a", "/a",
@@ -919,30 +925,17 @@ mod tests {
assert_eq!(result.result, Value::Bytes(b"cde".to_vec())); assert_eq!(result.result, Value::Bytes(b"cde".to_vec()));
let complete = projection let complete = projection
.fetch_tree( .observe(
&ExecutionContext::new(), &ExecutionContext::new(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 3,
}, },
) )
.expect("complete tree"); .expect("complete observation");
assert!(!complete.truncated); let children = complete.children.as_ref().expect("root children observed");
assert_eq!(complete.root.children.len(), 2); assert_eq!(children.len(), 2);
assert!(children.iter().all(|child| child.children.is_none()));
let limited = projection
.fetch_tree(
&ExecutionContext::new(),
&FetchTreeRequest {
path: "/".into(),
depth: 1,
limit: 2,
},
)
.expect("limited tree");
assert!(limited.truncated);
assert_eq!(limited.root.children.len(), 1);
} }
#[test] #[test]
@@ -951,7 +944,7 @@ mod tests {
let path = temporary.path().join("file"); let path = temporary.path().join("file");
fs::write(&path, b"old").expect("write file"); fs::write(&path, b"old").expect("write file");
let projection = projection(temporary.path(), AccessMode::ReadWrite); let projection = projection(temporary.path(), AccessMode::ReadWrite);
let observed = projection.fetch("/file").expect("fetch file"); let observed = projection.observe_object("/file").expect("observe file");
assert_eq!(observed.interfaces.len(), 2); assert_eq!(observed.interfaces.len(), 2);
let write_interface = observed.interfaces[1].clone(); let write_interface = observed.interfaces[1].clone();
@@ -981,7 +974,9 @@ mod tests {
assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch); assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch);
assert_eq!(fs::read(&path).expect("read replacement"), b"replacement"); assert_eq!(fs::read(&path).expect("read replacement"), b"replacement");
let current = projection.fetch("/file").expect("fetch replacement"); let current = projection
.observe_object("/file")
.expect("observe replacement");
let result = call( let result = call(
&projection, &projection,
"/file", "/file",
@@ -1001,7 +996,7 @@ mod tests {
let path = temporary.path().join("file"); let path = temporary.path().join("file");
fs::write(&path, b"old").expect("write file"); fs::write(&path, b"old").expect("write file");
let projection = projection(temporary.path(), AccessMode::ReadWrite); let projection = projection(temporary.path(), AccessMode::ReadWrite);
let observed = projection.fetch("/file").expect("fetch file"); let observed = projection.observe_object("/file").expect("observe file");
let request = || { let request = || {
call( call(
&projection, &projection,
@@ -1029,25 +1024,23 @@ mod tests {
} }
#[test] #[test]
fn large_directory_stops_after_proving_truncation() { fn large_directory_fails_without_partial_observation() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
for index in 0..2_000 { for index in 0..2_000 {
fs::write(temporary.path().join(format!("entry-{index}")), b"x") fs::write(temporary.path().join(format!("entry-{index}")), b"x")
.expect("write directory entry"); .expect("write directory entry");
} }
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let tree = projection let error = projection
.fetch_tree( .observe(
&ExecutionContext::new(), &ExecutionContext::new(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 2,
}, },
) )
.expect("bounded tree"); .expect_err("complete observation exceeds node budget");
assert!(tree.truncated); assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
assert_eq!(tree.root.children.len(), 1);
} }
#[cfg(unix)] #[cfg(unix)]
@@ -1065,22 +1058,22 @@ mod tests {
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let tree = projection let tree = projection
.fetch_tree( .observe(
&ExecutionContext::new(), &ExecutionContext::new(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 10,
}, },
) )
.expect("list root"); .expect("list root");
assert_eq!(tree.root.children.len(), 1); let children = tree.children.as_ref().expect("root children observed");
assert_eq!(tree.root.children[0].object.name, "socket"); assert_eq!(children.len(), 1);
assert_eq!(children[0].object.name, "socket");
assert_eq!( assert_eq!(
tree.root.children[0].object.description.as_deref(), children[0].object.description.as_deref(),
Some("Unsupported filesystem entry") Some("Unsupported filesystem entry")
); );
assert!(tree.root.children[0].object.interfaces.is_empty()); assert!(children[0].object.interfaces.is_empty());
} }
#[test] #[test]
@@ -1091,12 +1084,11 @@ mod tests {
let cancellation = wip_host::CancellationToken::new(); let cancellation = wip_host::CancellationToken::new();
cancellation.cancel(); cancellation.cancel();
let error = projection let error = projection
.fetch_tree( .observe(
&ExecutionContext::new().with_cancellation(cancellation), &ExecutionContext::new().with_cancellation(cancellation),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 2,
}, },
) )
.expect_err("cancelled traversal"); .expect_err("cancelled traversal");
@@ -1108,7 +1100,7 @@ mod tests {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let error = projection let error = projection
.fetch("/bad\0name") .observe_object("/bad\0name")
.expect_err("NUL cannot name a backend entry"); .expect_err("NUL cannot name a backend entry");
assert_eq!(error.kind(), CallErrorKind::NotFound); assert_eq!(error.kind(), CallErrorKind::NotFound);
} }
@@ -1144,7 +1136,7 @@ mod tests {
}; };
let projection = Projection::open(temporary.path(), AccessMode::ReadOnly, limits) let projection = Projection::open(temporary.path(), AccessMode::ReadOnly, limits)
.expect("open projection"); .expect("open projection");
let observed = projection.fetch("/file").expect("fetch file"); let observed = projection.observe_object("/file").expect("observe file");
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let error = call( let error = call(
&projection, &projection,
+19 -45
View File
@@ -10,12 +10,10 @@ use wip_host::{ExecutionContext, ObjectProvider};
use wip_https::{ use wip_https::{
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError, BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError,
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request, Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request,
decode_call_operation_metadata, decode_fetch_interface_request, decode_fetch_request, decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
decode_fetch_tree_request, encode_batch_error_response, encode_batch_error_response, encode_batch_resource_limit_error_response, encode_batch_response,
encode_batch_resource_limit_error_response, encode_batch_response, encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response, encode_resource_limit_error_response, validate_http_request,
encode_fetch_tree_response, encode_protocol_error_response,
encode_resource_limit_error_response, validate_http_request,
}; };
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
@@ -78,10 +76,9 @@ impl WipFsServer {
} }
let context = ExecutionContext::new() let context = ExecutionContext::new()
.with_deadline(deadline) .with_deadline(deadline)
.with_resource_budget(u64::from(self.config.limits.max_tree_nodes)); .with_resource_budget(u64::from(self.config.limits.max_observation_nodes));
match route { match route {
Route::Fetch => self.fetch(request.body(), &context), Route::Observe => self.observe(request.body(), &context),
Route::FetchTree => self.fetch_tree(request.body(), &context),
Route::FetchInterface => self.fetch_interface(request.body()), Route::FetchInterface => self.fetch_interface(request.body()),
Route::CallOperation => self.call_operation(request.body(), &context), Route::CallOperation => self.call_operation(request.body(), &context),
Route::Batch => self.batch(request.body(), &context), Route::Batch => self.batch(request.body(), &context),
@@ -110,8 +107,7 @@ impl WipFsServer {
fn route(&self, path: &str) -> Option<Route> { fn route(&self, path: &str) -> Option<Route> {
[ [
Route::Fetch, Route::Observe,
Route::FetchTree,
Route::FetchInterface, Route::FetchInterface,
Route::CallOperation, Route::CallOperation,
Route::Batch, Route::Batch,
@@ -120,33 +116,17 @@ impl WipFsServer {
.find(|route| self.endpoint.route(*route).path() == path) .find(|route| self.endpoint.route(*route).path() == path)
} }
fn fetch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> { fn observe(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
let request = match decode_fetch_request(body, self.config.limits.wire) { let request = match decode_observe_request(body, self.config.limits.wire) {
Ok(request) => request, Ok(request) => request,
Err(error) => return self.codec_error(ProtocolInteraction::Fetch, error), Err(error) => return self.codec_error(ProtocolInteraction::Observe, error),
}; };
match self match self.projection.observe(context, &request) {
.projection Ok(tree) => encode_observe_response(&request, &tree, self.config.limits.wire)
.fetch_with_context(context, &request.path)
.map_err(|error| error.protocol_error())
{
Ok(object) => encode_fetch_response(&request, &object, self.config.limits.wire)
.unwrap_or_else(|error| self.read_encode_error(ProtocolInteraction::Fetch, error)),
Err(error) => self.protocol_error(ProtocolInteraction::Fetch, &error, None),
}
}
fn fetch_tree(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
let request = match decode_fetch_tree_request(body, self.config.limits.wire) {
Ok(request) => request,
Err(error) => return self.codec_error(ProtocolInteraction::FetchTree, error),
};
match self.projection.fetch_tree(context, &request) {
Ok(tree) => encode_fetch_tree_response(&request, &tree, self.config.limits.wire)
.unwrap_or_else(|error| { .unwrap_or_else(|error| {
self.read_encode_error(ProtocolInteraction::FetchTree, error) self.read_encode_error(ProtocolInteraction::Observe, error)
}), }),
Err(error) => self.protocol_error(ProtocolInteraction::FetchTree, &error, None), Err(error) => self.protocol_error(ProtocolInteraction::Observe, &error, None),
} }
} }
@@ -236,15 +216,10 @@ impl WipFsServer {
.iter() .iter()
.map(|item| { .map(|item| {
let outcome = match &item.request { let outcome = match &item.request {
BatchRequestKind::Fetch(request) => self BatchRequestKind::Observe(request) => self
.projection .projection
.fetch_with_context(context, &request.path) .observe(context, request)
.map(BatchSuccess::Fetch) .map(BatchSuccess::Observe),
.map_err(|error| error.protocol_error()),
BatchRequestKind::FetchTree(request) => self
.projection
.fetch_tree(context, request)
.map(BatchSuccess::FetchTree),
BatchRequestKind::FetchInterface(request) => self BatchRequestKind::FetchInterface(request) => self
.projection .projection
.fetch_interface(request) .fetch_interface(request)
@@ -674,11 +649,10 @@ fn write_all_with_deadline(
fn interaction(route: Route) -> ProtocolInteraction { fn interaction(route: Route) -> ProtocolInteraction {
match route { match route {
Route::Fetch => ProtocolInteraction::Fetch, Route::Observe => ProtocolInteraction::Observe,
Route::FetchTree => ProtocolInteraction::FetchTree,
Route::FetchInterface => ProtocolInteraction::FetchInterface, Route::FetchInterface => ProtocolInteraction::FetchInterface,
Route::CallOperation => ProtocolInteraction::CallOperation, Route::CallOperation => ProtocolInteraction::CallOperation,
Route::Batch => ProtocolInteraction::Fetch, Route::Batch => ProtocolInteraction::Observe,
} }
} }
+52 -34
View File
@@ -11,17 +11,17 @@ use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
use wip_https::{ use wip_https::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse,
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response, Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response,
decode_call_operation_response, decode_fetch_interface_response, decode_fetch_response, decode_call_operation_response, decode_fetch_interface_response, decode_observe_response,
decode_fetch_tree_response, encode_batch_request, encode_call_operation_request, encode_batch_request, encode_call_operation_request, encode_fetch_interface_request,
encode_fetch_interface_request, encode_fetch_request, encode_fetch_tree_request, encode_observe_request,
}; };
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, FetchInterfaceRequest, FetchRequest, FetchTreeRequest, InterfaceTarget, CallOperationRequest, FetchInterfaceRequest, InterfaceTarget, ObserveRequest,
ProtocolErrorCode, Target, Value, ProtocolErrorCode, Target, Value,
}; };
#[test] #[test]
fn real_listener_serves_four_core_interactions_batch_and_write() { fn real_listener_serves_three_core_interactions_batch_and_write() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file"); std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file");
std::fs::create_dir(temporary.path().join("nested")).expect("create directory"); std::fs::create_dir(temporary.path().join("nested")).expect("create directory");
@@ -46,32 +46,39 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port())) let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
.expect("client endpoint"); .expect("client endpoint");
let file_request = FetchRequest { let file_request = ObserveRequest {
path: "/hello.txt".into(), path: "/hello.txt".into(),
depth: 0,
}; };
let response = exchange( let response = exchange(
address, address,
encode_fetch_request(&endpoint, &file_request, limits).expect("encode fetch"), encode_observe_request(&endpoint, &file_request, limits).expect("encode observation"),
); );
let file = let file = success(
success(decode_fetch_response(&file_request, &response, limits).expect("decode fetch")); decode_observe_response(&file_request, &response, limits).expect("decode observation"),
)
.object;
assert_eq!(file.name, "hello.txt"); assert_eq!(file.name, "hello.txt");
assert_eq!(file.interfaces.len(), 2); assert_eq!(file.interfaces.len(), 2);
assert!(file.validator.is_some()); assert!(file.validator.is_some());
let tree_request = FetchTreeRequest { let tree_request = ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 10,
}; };
let response = exchange( let response = exchange(
address, address,
encode_fetch_tree_request(&endpoint, &tree_request, limits).expect("encode tree"), encode_observe_request(&endpoint, &tree_request, limits).expect("encode tree"),
); );
let tree = let tree =
success(decode_fetch_tree_response(&tree_request, &response, limits).expect("decode tree")); success(decode_observe_response(&tree_request, &response, limits).expect("decode tree"));
assert!(!tree.truncated); assert_eq!(
assert_eq!(tree.root.children.len(), 2); tree.children
.as_ref()
.expect("root children observed")
.len(),
2
);
let read_interface_request = FetchInterfaceRequest { let read_interface_request = FetchInterfaceRequest {
interface: file.interfaces[0].clone(), interface: file.interfaces[0].clone(),
@@ -122,14 +129,16 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
requests: vec![ requests: vec![
BatchRequestItem { BatchRequestItem {
id: "root".into(), id: "root".into(),
request: BatchRequestKind::Fetch(FetchRequest { path: "/".into() }), request: BatchRequestKind::Observe(ObserveRequest {
path: "/".into(),
depth: 0,
}),
}, },
BatchRequestItem { BatchRequestItem {
id: "tree".into(), id: "tree".into(),
request: BatchRequestKind::FetchTree(FetchTreeRequest { request: BatchRequestKind::Observe(ObserveRequest {
path: "/nested".into(), path: "/nested".into(),
depth: 1, depth: 1,
limit: 4,
}), }),
}, },
BatchRequestItem { BatchRequestItem {
@@ -230,7 +239,7 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
.expect("set stalled read timeout"); .expect("set stalled read timeout");
write!( write!(
stalled, stalled,
"POST /wip/v1/fetch HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{" "POST /wip/v1/observe HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{"
) )
.expect("write partial request"); .expect("write partial request");
stalled.flush().expect("flush partial request"); stalled.flush().expect("flush partial request");
@@ -238,13 +247,17 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port())) let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
.expect("client endpoint"); .expect("client endpoint");
let fetch = FetchRequest { path: "/".into() }; let observe = ObserveRequest {
path: "/".into(),
depth: 0,
};
let started = Instant::now(); let started = Instant::now();
let response = exchange( let response = exchange(
address, address,
encode_fetch_request(&endpoint, &fetch, limits).expect("encode fetch"), encode_observe_request(&endpoint, &observe, limits).expect("encode observation"),
); );
let _root = success(decode_fetch_response(&fetch, &response, limits).expect("decode fetch")); let _root =
success(decode_observe_response(&observe, &response, limits).expect("decode observation"));
assert!( assert!(
started.elapsed() < Duration::from_secs(1), started.elapsed() < Duration::from_secs(1),
"the valid request remained starved after the intake deadline" "the valid request remained starved after the intake deadline"
@@ -277,7 +290,7 @@ fn batch_keeps_known_malformed_arguments_item_local() {
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let body = br#"{"requests":[{"id":"ok","method":"fetch","arguments":{"path":"/"}},{"id":"bad","method":"fetch_tree","arguments":{"path":"/","depth":"bad","limit":1}}]}"#; let body = br#"{"requests":[{"id":"ok","method":"observe","arguments":{"path":"/","depth":0}},{"id":"bad","method":"observe","arguments":{"path":"/","depth":"bad"}}]}"#;
let logical = decode_batch_request(body, limits).expect("decode item-local invalid request"); let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
assert!(matches!( assert!(matches!(
logical.requests[1].request, logical.requests[1].request,
@@ -296,7 +309,7 @@ fn batch_keeps_known_malformed_arguments_item_local() {
); );
assert!(matches!( assert!(matches!(
batch.responses[0].outcome, batch.responses[0].outcome,
Ok(BatchSuccess::Fetch(_)) Ok(BatchSuccess::Observe(_))
)); ));
assert!(batch.responses[1].outcome.is_err()); assert!(batch.responses[1].outcome.is_err());
} }
@@ -331,13 +344,15 @@ fn backend_invalid_unicode_path_is_not_found_not_internal() {
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let request = FetchRequest { let request = ObserveRequest {
path: "/bad\0name".into(), path: "/bad\0name".into(),
depth: 0,
}; };
let response = server let response = server.handle_http(
.handle_http(encode_fetch_request(&endpoint, &request, limits).expect("encode fetch")); encode_observe_request(&endpoint, &request, limits).expect("encode observation"),
);
assert_eq!(response.status(), http::StatusCode::NOT_FOUND); assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
match decode_fetch_response(&request, &response, limits).expect("decode missing response") { match decode_observe_response(&request, &response, limits).expect("decode missing response") {
DecodedResponse::ProtocolFailure { error, .. } => { DecodedResponse::ProtocolFailure { error, .. } => {
assert_eq!(error.code, ProtocolErrorCode::NotFound); assert_eq!(error.code, ProtocolErrorCode::NotFound);
} }
@@ -355,14 +370,17 @@ fn read_response_size_failure_preserves_non_commit_guarantee() {
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let fetch_request = FetchRequest { let observe_request = ObserveRequest {
path: "/large".into(), path: "/large".into(),
depth: 0,
}; };
let response = server.handle_http( let response = server.handle_http(
encode_fetch_request(&endpoint, &fetch_request, limits).expect("encode fetch"), encode_observe_request(&endpoint, &observe_request, limits).expect("encode observation"),
); );
let file = let file = success(
success(decode_fetch_response(&fetch_request, &response, limits).expect("decode fetch")); decode_observe_response(&observe_request, &response, limits).expect("decode observation"),
)
.object;
let interface_request = FetchInterfaceRequest { let interface_request = FetchInterfaceRequest {
interface: file.interfaces[0].clone(), interface: file.interfaces[0].clone(),
}; };
@@ -405,11 +423,11 @@ fn assert_batch_successes(batch: &BatchResponse) {
assert_eq!(batch.responses.len(), 3); assert_eq!(batch.responses.len(), 3);
assert!(matches!( assert!(matches!(
batch.responses[0].outcome, batch.responses[0].outcome,
Ok(BatchSuccess::Fetch(_)) Ok(BatchSuccess::Observe(_))
)); ));
assert!(matches!( assert!(matches!(
batch.responses[1].outcome, batch.responses[1].outcome,
Ok(BatchSuccess::FetchTree(_)) Ok(BatchSuccess::Observe(_))
)); ));
assert!(matches!( assert!(matches!(
batch.responses[2].outcome, batch.responses[2].outcome,
+7 -7
View File
@@ -352,7 +352,7 @@ impl InterfaceRegistry {
/// Observes a registered interface by its host-side reference. /// Observes a registered interface by its host-side reference.
#[must_use] #[must_use]
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> { pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
self.artifacts.get(reference).map(Self::observation) self.artifacts.get(reference).map(Self::observation)
} }
@@ -863,13 +863,13 @@ pub trait ObjectProvider {
true true
} }
/// Fetches the canonical object projection currently published at `path`. /// Observes the canonical object projection currently published at `path`.
fn fetch(&self, path: &str) -> Result<Object, CallError> { fn observe_object(&self, path: &str) -> Result<Object, CallError> {
self.fetch_with_context(&ExecutionContext::default(), path) self.observe_object_with_context(&ExecutionContext::default(), path)
} }
/// Fetches an object while propagating runtime execution controls. /// Observes an object while propagating runtime execution controls.
fn fetch_with_context( fn observe_object_with_context(
&self, &self,
context: &ExecutionContext, context: &ExecutionContext,
path: &str, path: &str,
@@ -882,7 +882,7 @@ pub trait ObjectProvider {
.map_err(|_| { .map_err(|_| {
CallError::new( CallError::new(
CallErrorKind::Internal, CallErrorKind::Internal,
"provider panicked while fetching an object", "provider panicked while observing an object",
) )
})?; })?;
let object = match resolved { let object = match resolved {
+8 -8
View File
@@ -467,7 +467,7 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
#[test] #[test]
fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() { fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
let (_, provider, _) = registry_and_provider(); let (_, provider, _) = registry_and_provider();
let object = provider.fetch("/document").unwrap(); let object = provider.observe_object("/document").unwrap();
assert_eq!(object.name, "document"); assert_eq!(object.name, "document");
assert_eq!(object.description.as_deref(), Some("public document")); assert_eq!(object.description.as_deref(), Some("public document"));
@@ -483,7 +483,7 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
assert!(!format!("{object:?}").contains("secret-handle")); assert!(!format!("{object:?}").contains("secret-handle"));
assert!(!format!("{object:?}").contains("/document")); assert!(!format!("{object:?}").contains("/document"));
let empty = provider.fetch("/empty").unwrap(); let empty = provider.observe_object("/empty").unwrap();
assert!(empty.interfaces.is_empty()); assert!(empty.interfaces.is_empty());
assert_eq!(empty.r#ref, None); assert_eq!(empty.r#ref, None);
assert_eq!(empty.validator, None); assert_eq!(empty.validator, None);
@@ -518,13 +518,13 @@ fn duplicate_exposure_is_rejected_and_private_state_is_not_projected() {
fn paths_are_resolution_context_not_object_identity() { fn paths_are_resolution_context_not_object_identity() {
let (_, provider, _) = registry_and_provider(); let (_, provider, _) = registry_and_provider();
let left = provider.fetch("/left/document").unwrap(); let left = provider.observe_object("/left/document").unwrap();
let right = provider.fetch("/right/document").unwrap(); let right = provider.observe_object("/right/document").unwrap();
assert_eq!(left, right); assert_eq!(left, right);
assert_eq!(left.r#ref.as_deref(), Some("shared-ref")); assert_eq!(left.r#ref.as_deref(), Some("shared-ref"));
let first = provider.fetch("/switch").unwrap(); let first = provider.observe_object("/switch").unwrap();
let second = provider.fetch("/switch").unwrap(); let second = provider.observe_object("/switch").unwrap();
assert_ne!(first.r#ref, second.r#ref); assert_ne!(first.r#ref, second.r#ref);
assert_ne!(first.validator, second.validator); assert_ne!(first.validator, second.validator);
} }
@@ -603,7 +603,7 @@ fn call_failures_follow_canonical_precedence_without_execution() {
} }
assert_eq!(provider.resolutions.get(), resolutions); assert_eq!(provider.resolutions.get(), resolutions);
assert_failure( assert_failure(
provider.fetch("/invalid/").unwrap_err(), provider.observe_object("/invalid/").unwrap_err(),
CallErrorKind::InvalidRequest, CallErrorKind::InvalidRequest,
ProtocolErrorCode::InvalidRequest, ProtocolErrorCode::InvalidRequest,
); );
@@ -849,7 +849,7 @@ fn invalid_host_responses_and_uncertain_operation_failures_use_canonical_codes()
let (_, provider, _) = registry_and_provider(); let (_, provider, _) = registry_and_provider();
assert_failure( assert_failure(
provider.fetch("/invalid-object").unwrap_err(), provider.observe_object("/invalid-object").unwrap_err(),
CallErrorKind::Internal, CallErrorKind::Internal,
ProtocolErrorCode::Internal, ProtocolErrorCode::Internal,
); );
+23 -49
View File
@@ -4,8 +4,8 @@ use http::header::{ACCEPT, CONTENT_TYPE};
use http::{Method, Request, Response, StatusCode}; use http::{Method, Request, Response, StatusCode};
use serde_json::{Map, Value as Json}; use serde_json::{Map, Value as Json};
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, ObserveRequest,
FetchTreeResponse, INTERFACE_FORMAT_V1, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ObserveResponse, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
}; };
use crate::response::{self, DecodedResponse}; use crate::response::{self, DecodedResponse};
@@ -17,10 +17,8 @@ use crate::{
/// A retrieval method accepted by the standard v1 batch endpoint. /// A retrieval method accepted by the standard v1 batch endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchMethod { pub enum BatchMethod {
/// Fetch one object. /// Observe one object and its indexable descendants.
Fetch, Observe,
/// Fetch one bounded tree.
FetchTree,
/// Fetch one interface descriptor. /// Fetch one interface descriptor.
FetchInterface, FetchInterface,
} }
@@ -28,16 +26,14 @@ pub enum BatchMethod {
impl BatchMethod { impl BatchMethod {
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Fetch => "fetch", Self::Observe => "observe",
Self::FetchTree => "fetch_tree",
Self::FetchInterface => "fetch_interface", Self::FetchInterface => "fetch_interface",
} }
} }
fn interaction(self) -> ProtocolInteraction { fn interaction(self) -> ProtocolInteraction {
match self { match self {
Self::Fetch => ProtocolInteraction::Fetch, Self::Observe => ProtocolInteraction::Observe,
Self::FetchTree => ProtocolInteraction::FetchTree,
Self::FetchInterface => ProtocolInteraction::FetchInterface, Self::FetchInterface => ProtocolInteraction::FetchInterface,
} }
} }
@@ -46,10 +42,8 @@ impl BatchMethod {
/// One retrieval interaction accepted by the standard v1 batch endpoint. /// One retrieval interaction accepted by the standard v1 batch endpoint.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchRequestKind { pub enum BatchRequestKind {
/// Fetch one object. /// Observe one object and its indexable descendants.
Fetch(FetchRequest), Observe(ObserveRequest),
/// Fetch one bounded tree.
FetchTree(FetchTreeRequest),
/// Fetch one interface descriptor. /// Fetch one interface descriptor.
FetchInterface(FetchInterfaceRequest), FetchInterface(FetchInterfaceRequest),
/// A known method whose `arguments` are not a valid logical request. /// A known method whose `arguments` are not a valid logical request.
@@ -64,8 +58,7 @@ pub enum BatchRequestKind {
impl BatchRequestKind { impl BatchRequestKind {
fn interaction(&self) -> ProtocolInteraction { fn interaction(&self) -> ProtocolInteraction {
match self { match self {
Self::Fetch(_) => ProtocolInteraction::Fetch, Self::Observe(_) => ProtocolInteraction::Observe,
Self::FetchTree(_) => ProtocolInteraction::FetchTree,
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface, Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
Self::Invalid { method, .. } => method.interaction(), Self::Invalid { method, .. } => method.interaction(),
} }
@@ -91,10 +84,8 @@ pub struct BatchRequest {
/// Successful result of one batch item. /// Successful result of one batch item.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum BatchSuccess { pub enum BatchSuccess {
/// Successful `fetch` result. /// Successful `observe` result.
Fetch(FetchResponse), Observe(ObserveResponse),
/// Successful `fetch_tree` result.
FetchTree(FetchTreeResponse),
/// Successful `fetch_interface` result. /// Successful `fetch_interface` result.
FetchInterface(FetchInterfaceResponse), FetchInterface(FetchInterfaceResponse),
} }
@@ -135,10 +126,7 @@ fn validate_ids<'a>(ids: impl Iterator<Item = &'a str>, maximum: usize) -> Resul
fn request_item_to_json(item: &BatchRequestItem) -> Result<Json, CodecError> { fn request_item_to_json(item: &BatchRequestItem) -> Result<Json, CodecError> {
let (method, arguments) = match &item.request { let (method, arguments) = match &item.request {
BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)), BatchRequestKind::Observe(value) => ("observe", json::observe_request_to_json(value)),
BatchRequestKind::FetchTree(value) => {
("fetch_tree", json::fetch_tree_request_to_json(value))
}
BatchRequestKind::FetchInterface(value) => ( BatchRequestKind::FetchInterface(value) => (
"fetch_interface", "fetch_interface",
json::fetch_interface_request_to_json(value), json::fetch_interface_request_to_json(value),
@@ -160,8 +148,7 @@ fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, Codec
)?; )?;
for item in &request.requests { for item in &request.requests {
match &item.request { match &item.request {
BatchRequestKind::Fetch(value) => value.validate()?, BatchRequestKind::Observe(value) => value.validate()?,
BatchRequestKind::FetchTree(value) => value.validate()?,
BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {} BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {}
} }
} }
@@ -190,22 +177,18 @@ fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, Codec
let method_name = string(required(fields, "method")?, &format!("{field}.method"))?; let method_name = string(required(fields, "method")?, &format!("{field}.method"))?;
let arguments = required(fields, "arguments")?; let arguments = required(fields, "arguments")?;
let method = match method_name.as_str() { let method = match method_name.as_str() {
"fetch" => BatchMethod::Fetch, "observe" => BatchMethod::Observe,
"fetch_tree" => BatchMethod::FetchTree,
"fetch_interface" => BatchMethod::FetchInterface, "fetch_interface" => BatchMethod::FetchInterface,
_ => { _ => {
return Err(CodecError::InvalidField { return Err(CodecError::InvalidField {
field: format!("{field}.method"), field: format!("{field}.method"),
reason: "batch supports only fetch, fetch_tree, and fetch_interface".into(), reason: "batch supports only observe and fetch_interface".into(),
}); });
} }
}; };
let decoded = match method { let decoded = match method {
BatchMethod::Fetch => { BatchMethod::Observe => {
json::fetch_request_from_json(arguments).map(BatchRequestKind::Fetch) json::observe_request_from_json(arguments).map(BatchRequestKind::Observe)
}
BatchMethod::FetchTree => {
json::fetch_tree_request_from_json(arguments).map(BatchRequestKind::FetchTree)
} }
BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments) BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments)
.map(BatchRequestKind::FetchInterface), .map(BatchRequestKind::FetchInterface),
@@ -249,8 +232,7 @@ pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result<BatchRequest,
fn success_to_json(success: &BatchSuccess) -> Json { fn success_to_json(success: &BatchSuccess) -> Json {
match success { match success {
BatchSuccess::Fetch(value) => json::object_to_json(value), BatchSuccess::Observe(value) => json::observe_response_to_json(value),
BatchSuccess::FetchTree(value) => json::fetch_tree_response_to_json(value),
BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value), BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value),
} }
} }
@@ -299,12 +281,9 @@ fn correlate(
}); });
}; };
match (&item.outcome, *kind) { match (&item.outcome, *kind) {
(Ok(BatchSuccess::Fetch(value)), BatchRequestKind::Fetch(request)) => { (Ok(BatchSuccess::Observe(value)), BatchRequestKind::Observe(request)) => {
request.validate_response(value)?; request.validate_response(value)?;
} }
(Ok(BatchSuccess::FetchTree(value)), BatchRequestKind::FetchTree(request)) => {
value.validate_for(request)?;
}
( (
Ok(BatchSuccess::FetchInterface(value)), Ok(BatchSuccess::FetchInterface(value)),
BatchRequestKind::FetchInterface(request), BatchRequestKind::FetchInterface(request),
@@ -459,15 +438,10 @@ fn response_from_json(
Err(error) Err(error)
} }
(Some(result), None) => Ok(match kind { (Some(result), None) => Ok(match kind {
BatchRequestKind::Fetch(request) => { BatchRequestKind::Observe(request) => {
let value = json::object_from_json(result, "result")?; let value = json::observe_response_from_json(result)?;
request.validate_response(&value)?; request.validate_response(&value)?;
BatchSuccess::Fetch(value) BatchSuccess::Observe(value)
}
BatchRequestKind::FetchTree(request) => {
let value = json::fetch_tree_response_from_json(result)?;
value.validate_for(request)?;
BatchSuccess::FetchTree(value)
} }
BatchRequestKind::FetchInterface(request) => { BatchRequestKind::FetchInterface(request) => {
let value = json::fetch_interface_response_from_json(result)?; let value = json::fetch_interface_response_from_json(result)?;
@@ -509,7 +483,7 @@ pub fn decode_batch_response<B: AsRef<[u8]>>(
let decoded = response::decode( let decoded = response::decode(
value.status(), value.status(),
value.body().as_ref(), value.body().as_ref(),
ProtocolInteraction::Fetch, ProtocolInteraction::Observe,
limits, limits,
|json| response_from_json(json, request, limits), |json| response_from_json(json, request, limits),
) )
+23 -80
View File
@@ -5,8 +5,8 @@ use http::header::{ACCEPT, CONTENT_TYPE};
use http::{HeaderMap, Method, Request, Response, StatusCode}; use http::{HeaderMap, Method, Request, Response, StatusCode};
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, ObserveRequest, ObserveResponse,
InterfaceDescriptor, InterfaceTarget, ProtocolError, ProtocolInteraction, Target, ProtocolError, ProtocolInteraction, Target,
}; };
use crate::response::{self, DecodedResponse}; use crate::response::{self, DecodedResponse};
@@ -264,49 +264,25 @@ pub fn validate_http_request<B>(
Ok(()) Ok(())
} }
/// Encodes a `fetch` HTTP request. /// Encodes an `observe` HTTP request.
pub fn encode_fetch_request( pub fn encode_observe_request(
endpoint: &Endpoint, endpoint: &Endpoint,
value: &FetchRequest, value: &ObserveRequest,
limits: Limits, limits: Limits,
) -> Result<Request<Vec<u8>>, EncodeHttpError> { ) -> Result<Request<Vec<u8>>, EncodeHttpError> {
value.validate().map_err(CodecError::from)?; value.validate().map_err(CodecError::from)?;
let body = json::serialize( let body = json::serialize(
&json::fetch_request_to_json(value), &json::observe_request_to_json(value),
BodyKind::Request, BodyKind::Request,
limits, limits,
)?; )?;
Ok(request(endpoint, Route::Fetch, body)?) Ok(request(endpoint, Route::Observe, body)?)
} }
/// Decodes a `fetch` request body. /// Decodes an `observe` request body.
pub fn decode_fetch_request(body: &[u8], limits: Limits) -> Result<FetchRequest, CodecError> { pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequest, CodecError> {
let value = json::parse(body, BodyKind::Request, limits)?; let value = json::parse(body, BodyKind::Request, limits)?;
json::fetch_request_from_json(&value) json::observe_request_from_json(&value)
}
/// Encodes a `fetch_tree` HTTP request.
pub fn encode_fetch_tree_request(
endpoint: &Endpoint,
value: &FetchTreeRequest,
limits: Limits,
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
value.validate().map_err(CodecError::from)?;
let body = json::serialize(
&json::fetch_tree_request_to_json(value),
BodyKind::Request,
limits,
)?;
Ok(request(endpoint, Route::FetchTree, body)?)
}
/// Decodes a `fetch_tree` request body.
pub fn decode_fetch_tree_request(
body: &[u8],
limits: Limits,
) -> Result<FetchTreeRequest, CodecError> {
let value = json::parse(body, BodyKind::Request, limits)?;
json::fetch_tree_request_from_json(&value)
} }
/// Encodes a `fetch_interface` HTTP request. /// Encodes a `fetch_interface` HTTP request.
@@ -407,72 +383,39 @@ pub fn decode_call_operation_request(
json::call_request_from_json(&value, descriptor) json::call_request_from_json(&value, descriptor)
} }
/// Encodes a successful `fetch` response as a direct body with HTTP 200. /// Encodes a successful `observe` response as a direct body with HTTP 200.
pub fn encode_fetch_response( pub fn encode_observe_response(
request_value: &FetchRequest, request_value: &ObserveRequest,
value: &FetchResponse, value: &ObserveResponse,
limits: Limits, limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> { ) -> Result<Response<Vec<u8>>, EncodeHttpError> {
request_value request_value
.validate_response(value) .validate_response(value)
.map_err(CodecError::from)?; .map_err(CodecError::from)?;
let body = json::serialize(&json::object_to_json(value), BodyKind::Response, limits)?;
Ok(response(StatusCode::OK, body)?)
}
/// Decodes and classifies a `fetch` client response.
pub fn decode_fetch_response<B: AsRef<[u8]>>(
request_value: &FetchRequest,
value: &Response<B>,
limits: Limits,
) -> Result<DecodedResponse<FetchResponse>, ClientResponseError> {
require_response_content_type(value)?;
response::decode(
value.status(),
value.body().as_ref(),
ProtocolInteraction::Fetch,
limits,
|json| {
let object = json::object_from_json(json, "response")?;
request_value.validate_response(&object)?;
Ok(object)
},
)
}
/// Encodes a successful `fetch_tree` response as a direct body with HTTP 200.
pub fn encode_fetch_tree_response(
request_value: &FetchTreeRequest,
value: &FetchTreeResponse,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
value
.validate_for(request_value)
.map_err(CodecError::from)?;
let body = json::serialize( let body = json::serialize(
&json::fetch_tree_response_to_json(value), &json::observe_response_to_json(value),
BodyKind::Response, BodyKind::Response,
limits, limits,
)?; )?;
Ok(response(StatusCode::OK, body)?) Ok(response(StatusCode::OK, body)?)
} }
/// Decodes and classifies a `fetch_tree` client response. /// Decodes and classifies an `observe` client response.
pub fn decode_fetch_tree_response<B: AsRef<[u8]>>( pub fn decode_observe_response<B: AsRef<[u8]>>(
request_value: &FetchTreeRequest, request_value: &ObserveRequest,
value: &Response<B>, value: &Response<B>,
limits: Limits, limits: Limits,
) -> Result<DecodedResponse<FetchTreeResponse>, ClientResponseError> { ) -> Result<DecodedResponse<ObserveResponse>, ClientResponseError> {
require_response_content_type(value)?; require_response_content_type(value)?;
response::decode( response::decode(
value.status(), value.status(),
value.body().as_ref(), value.body().as_ref(),
ProtocolInteraction::FetchTree, ProtocolInteraction::Observe,
limits, limits,
|json| { |json| {
let tree = json::fetch_tree_response_from_json(json)?; let observation = json::observe_response_from_json(json)?;
tree.validate_for(request_value)?; request_value.validate_response(&observation)?;
Ok(tree) Ok(observation)
}, },
) )
} }
+5 -9
View File
@@ -9,10 +9,8 @@ pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
/// A fixed standard WIP over HTTPS v1 route. /// A fixed standard WIP over HTTPS v1 route.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Route { pub enum Route {
/// Fetch one object. /// Observe one object and its indexable descendants.
Fetch, Observe,
/// Fetch a bounded object tree.
FetchTree,
/// Fetch an interface descriptor. /// Fetch an interface descriptor.
FetchInterface, FetchInterface,
/// Call one operation. /// Call one operation.
@@ -26,8 +24,7 @@ impl Route {
#[must_use] #[must_use]
pub const fn relative_reference(self) -> &'static str { pub const fn relative_reference(self) -> &'static str {
match self { match self {
Self::Fetch => "v1/fetch", Self::Observe => "v1/observe",
Self::FetchTree => "v1/fetch_tree",
Self::FetchInterface => "v1/fetch_interface", Self::FetchInterface => "v1/fetch_interface",
Self::CallOperation => "v1/call_operation", Self::CallOperation => "v1/call_operation",
Self::Batch => "v1/batch", Self::Batch => "v1/batch",
@@ -87,9 +84,8 @@ impl Endpoint {
/// are not route components and therefore never match. /// are not route components and therefore never match.
#[must_use] #[must_use]
pub fn recognize(&self, candidate: &Url) -> Option<Route> { pub fn recognize(&self, candidate: &Url) -> Option<Route> {
const ROUTES: [Route; 5] = [ const ROUTES: [Route; 4] = [
Route::Fetch, Route::Observe,
Route::FetchTree,
Route::FetchInterface, Route::FetchInterface,
Route::CallOperation, Route::CallOperation,
Route::Batch, Route::Batch,
+35 -41
View File
@@ -7,9 +7,9 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
use serde_json::{Map, Number, Value as Json}; 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, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, ObserveResponse, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
TypeDeclaration, TypeExpr, UnionCase, Value, TypeDeclaration, TypeExpr, UnionCase, Value,
}; };
@@ -271,29 +271,15 @@ fn insert_optional_bytes(map: &mut Map<String, Json>, name: &str, value: &Option
} }
} }
pub(crate) fn fetch_request_to_json(request: &FetchRequest) -> Json { pub(crate) fn observe_request_to_json(request: &ObserveRequest) -> Json {
serde_json::json!({"path": request.path}) serde_json::json!({"path": request.path, "depth": request.depth})
} }
pub(crate) fn fetch_request_from_json(value: &Json) -> Result<FetchRequest, CodecError> { pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest, CodecError> {
let fields = exact_object(value, "request", &["path"])?; let fields = exact_object(value, "request", &["path", "depth"])?;
let request = FetchRequest { let request = ObserveRequest {
path: string(required(fields, "path")?, "path")?,
};
request.validate()?;
Ok(request)
}
pub(crate) fn fetch_tree_request_to_json(request: &FetchTreeRequest) -> Json {
serde_json::json!({"path": request.path, "depth": request.depth, "limit": request.limit})
}
pub(crate) fn fetch_tree_request_from_json(value: &Json) -> Result<FetchTreeRequest, CodecError> {
let fields = exact_object(value, "request", &["path", "depth", "limit"])?;
let request = FetchTreeRequest {
path: string(required(fields, "path")?, "path")?, path: string(required(fields, "path")?, "path")?,
depth: u32_number(required(fields, "depth")?, "depth")?, depth: u32_number(required(fields, "depth")?, "depth")?,
limit: u32_number(required(fields, "limit")?, "limit")?,
}; };
request.validate()?; request.validate()?;
Ok(request) Ok(request)
@@ -360,35 +346,43 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
Ok(object) Ok(object)
} }
fn tree_node_to_json(node: &ObjectTreeNode) -> Json { fn observation_to_json(node: &ObjectObservation) -> Json {
serde_json::json!({ let mut fields = Map::new();
"object": object_to_json(&node.object), fields.insert("object".into(), object_to_json(&node.object));
"children": node.children.iter().map(tree_node_to_json).collect::<Vec<_>>() if let Some(children) = &node.children {
}) fields.insert(
"children".into(),
Json::Array(children.iter().map(observation_to_json).collect()),
);
}
Json::Object(fields)
} }
fn tree_node_from_json(value: &Json, field: &str) -> Result<ObjectTreeNode, CodecError> { fn observation_from_json(value: &Json, field: &str) -> Result<ObjectObservation, CodecError> {
let fields = exact_object(value, field, &["object", "children"])?; let fields = exact_object(value, field, &["object", "children"])?;
Ok(ObjectTreeNode { let children = optional(fields, "children")?
object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?, .map(|value| {
children: array(required(fields, "children")?, &qualified(field, "children"))? array(value, &qualified(field, "children"))?
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, child)| tree_node_from_json(child, &format!("{field}.children[{index}]"))) .map(|(index, child)| {
.collect::<Result<Vec<_>, _>>()?, observation_from_json(child, &format!("{field}.children[{index}]"))
})
.collect::<Result<Vec<_>, _>>()
})
.transpose()?;
Ok(ObjectObservation {
object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?,
children,
}) })
} }
pub(crate) fn fetch_tree_response_to_json(response: &FetchTreeResponse) -> Json { pub(crate) fn observe_response_to_json(response: &ObserveResponse) -> Json {
serde_json::json!({"root": tree_node_to_json(&response.root), "truncated": response.truncated}) observation_to_json(response)
} }
pub(crate) fn fetch_tree_response_from_json(value: &Json) -> Result<FetchTreeResponse, CodecError> { pub(crate) fn observe_response_from_json(value: &Json) -> Result<ObserveResponse, CodecError> {
let fields = exact_object(value, "response", &["root", "truncated"])?; observation_from_json(value, "response")
Ok(FetchTreeResponse {
root: tree_node_from_json(required(fields, "root")?, "root")?,
truncated: boolean(required(fields, "truncated")?, "truncated")?,
})
} }
fn documentation_to_json(documentation: &Documentation) -> Json { fn documentation_to_json(documentation: &Documentation) -> Json {
+10 -11
View File
@@ -11,17 +11,17 @@
//! does not infer WIP validator semantics from `ETag` or `If-Match`. //! does not infer WIP validator semantics from `ETag` or `If-Match`.
//! //!
//! ``` //! ```
//! use wip_https::{Endpoint, Limits, encode_fetch_request}; //! use wip_https::{Endpoint, Limits, encode_observe_request};
//! use wip_protocol::FetchRequest; //! use wip_protocol::ObserveRequest;
//! //!
//! let endpoint = Endpoint::parse("https://example.test/api/wip")?; //! let endpoint = Endpoint::parse("https://example.test/api/wip")?;
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?; //! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?;
//! let request = encode_fetch_request( //! let request = encode_observe_request(
//! &endpoint, //! &endpoint,
//! &FetchRequest { path: "/items/123".into() }, //! &ObserveRequest { path: "/items/123".into(), depth: 0 },
//! limits, //! limits,
//! )?; //! )?;
//! assert_eq!(request.uri(), "https://example.test/api/wip/v1/fetch"); //! assert_eq!(request.uri(), "https://example.test/api/wip/v1/observe");
//! assert_eq!(request.method(), http::Method::POST); //! assert_eq!(request.method(), http::Method::POST);
//! # Ok::<(), Box<dyn std::error::Error>>(()) //! # Ok::<(), Box<dyn std::error::Error>>(())
//! ``` //! ```
@@ -44,12 +44,11 @@ pub use batch::{
pub use codec::{ pub use codec::{
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata, CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request, decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response, decode_fetch_interface_response, decode_observe_request, decode_observe_response,
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request, encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request,
encode_call_operation_response, encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_request, encode_observe_response,
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response, encode_protocol_error_response, encode_resource_limit_error_response, is_json_content_type,
encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response, validate_http_request,
encode_resource_limit_error_response, is_json_content_type, validate_http_request,
}; };
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route}; pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits, ResourceLimitKind}; pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits, ResourceLimitKind};
+30 -38
View File
@@ -1,7 +1,7 @@
use wip_https::http::StatusCode; use wip_https::http::StatusCode;
use wip_https::wip_protocol::{ use wip_https::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode,
}; };
use wip_https::{ use wip_https::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
@@ -15,13 +15,27 @@ fn limits() -> Limits {
Limits::new(100_000, 100_000, 64, 4).unwrap() Limits::new(100_000, 100_000, 64, 4).unwrap()
} }
fn observed_item(validator: Option<Vec<u8>>) -> ObjectObservation {
ObjectObservation {
object: Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator,
},
children: None,
}
}
fn request() -> BatchRequest { fn request() -> BatchRequest {
BatchRequest { BatchRequest {
requests: vec![ requests: vec![
BatchRequestItem { BatchRequestItem {
id: "object".into(), id: "object".into(),
request: BatchRequestKind::Fetch(FetchRequest { request: BatchRequestKind::Observe(ObserveRequest {
path: "/item".into(), path: "/item".into(),
depth: 0,
}), }),
}, },
BatchRequestItem { BatchRequestItem {
@@ -57,13 +71,9 @@ fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
}, },
BatchResponseItem { BatchResponseItem {
id: "object".into(), id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object { outcome: Ok(BatchSuccess::Observe(observed_item(Some(vec![
name: "item".into(), 0, 1, 2, 255,
description: None, ])))),
interfaces: vec![],
r#ref: None,
validator: Some(vec![0, 1, 2, 255]),
})),
}, },
], ],
}; };
@@ -121,18 +131,18 @@ fn batch_interface_validator_round_trips_in_item_body() {
fn malformed_known_method_arguments_are_item_local() { fn malformed_known_method_arguments_are_item_local() {
let body = br#"{ let body = br#"{
"requests": [ "requests": [
{"id":"valid","method":"fetch","arguments":{"path":"/item"}}, {"id":"valid","method":"observe","arguments":{"path":"/item","depth":0}},
{"id":"invalid","method":"fetch_tree","arguments":{"path":"/","depth":"deep","limit":1}} {"id":"invalid","method":"observe","arguments":{"path":"/","depth":"deep"}}
] ]
}"#; }"#;
let decoded = decode_batch_request(body, limits()).unwrap(); let decoded = decode_batch_request(body, limits()).unwrap();
assert!(matches!( assert!(matches!(
decoded.requests[0].request, decoded.requests[0].request,
BatchRequestKind::Fetch(_) BatchRequestKind::Observe(_)
)); ));
let invalid = match &decoded.requests[1].request { let invalid = match &decoded.requests[1].request {
BatchRequestKind::Invalid { method, error } => { BatchRequestKind::Invalid { method, error } => {
assert_eq!(*method, BatchMethod::FetchTree); assert_eq!(*method, BatchMethod::Observe);
assert_eq!(error.code, ProtocolErrorCode::InvalidRequest); assert_eq!(error.code, ProtocolErrorCode::InvalidRequest);
error.clone() error.clone()
} }
@@ -143,13 +153,7 @@ fn malformed_known_method_arguments_are_item_local() {
responses: vec![ responses: vec![
BatchResponseItem { BatchResponseItem {
id: "valid".into(), id: "valid".into(),
outcome: Ok(BatchSuccess::Fetch(Object { outcome: Ok(BatchSuccess::Observe(observed_item(None))),
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
}, },
BatchResponseItem { BatchResponseItem {
id: "invalid".into(), id: "invalid".into(),
@@ -171,15 +175,15 @@ fn invalid_batch_envelopes_are_rejected_at_top_level() {
let malformed_item = br#"{ let malformed_item = br#"{
"requests": [ "requests": [
{"id":"item","method":"fetch","arguments":{"path":"/"},"extra":true} {"id":"item","method":"observe","arguments":{"path":"/","depth":0},"extra":true}
] ]
}"#; }"#;
assert!(decode_batch_request(malformed_item, limits()).is_err()); assert!(decode_batch_request(malformed_item, limits()).is_err());
let duplicate = br#"{ let duplicate = br#"{
"requests": [ "requests": [
{"id":"same","method":"fetch","arguments":{"path":"/a"}}, {"id":"same","method":"observe","arguments":{"path":"/a","depth":0}},
{"id":"same","method":"fetch","arguments":{"path":"/b"}} {"id":"same","method":"observe","arguments":{"path":"/b","depth":0}}
] ]
}"#; }"#;
assert!(decode_batch_request(duplicate, limits()).is_err()); assert!(decode_batch_request(duplicate, limits()).is_err());
@@ -228,13 +232,7 @@ fn batch_success_body_requires_exactly_http_200() {
responses: vec![ responses: vec![
BatchResponseItem { BatchResponseItem {
id: "object".into(), id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object { outcome: Ok(BatchSuccess::Observe(observed_item(None))),
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
}, },
BatchResponseItem { BatchResponseItem {
id: "interface".into(), id: "interface".into(),
@@ -340,13 +338,7 @@ fn batch_response_must_have_exactly_the_requested_ids() {
let missing = BatchResponse { let missing = BatchResponse {
responses: vec![BatchResponseItem { responses: vec![BatchResponseItem {
id: "object".into(), id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object { outcome: Ok(BatchSuccess::Observe(observed_item(None))),
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
}], }],
}; };
assert!(encode_batch_response(&request, &missing, limits()).is_err()); assert!(encode_batch_response(&request, &missing, limits()).is_err());
+69 -51
View File
@@ -3,20 +3,18 @@ use std::collections::BTreeMap;
use wip_https::http::StatusCode; use wip_https::http::StatusCode;
use wip_https::wip_protocol::{ use wip_https::wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, Object, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
TypeDeclaration, TypeExpr, UnionCase, Value, TypeExpr, UnionCase, Value,
}; };
use wip_https::{ use wip_https::{
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request, ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request,
decode_call_operation_response, decode_fetch_interface_request, decode_call_operation_response, decode_fetch_interface_request,
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response, decode_fetch_interface_response, decode_observe_request, decode_observe_response,
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request, encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request,
encode_call_operation_response, encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_request, encode_observe_response,
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
encode_fetch_tree_request, encode_fetch_tree_response,
}; };
fn limits() -> Limits { fn limits() -> Limits {
@@ -196,14 +194,11 @@ fn call_request() -> CallOperationRequest {
#[test] #[test]
fn every_single_interaction_request_accepts_json() { fn every_single_interaction_request_accepts_json() {
let fetch = let observe = encode_observe_request(
encode_fetch_request(&endpoint(), &FetchRequest { path: "/".into() }, limits()).unwrap();
let fetch_tree = encode_fetch_tree_request(
&endpoint(), &endpoint(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 0,
limit: 1,
}, },
limits(), limits(),
) )
@@ -219,59 +214,75 @@ fn every_single_interaction_request_accepts_json() {
let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits()) let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits())
.unwrap(); .unwrap();
for request in [fetch, fetch_tree, fetch_interface, call] { for request in [observe, fetch_interface, call] {
assert_eq!(request.headers()["accept"], "application/json"); assert_eq!(request.headers()["accept"], "application/json");
} }
} }
#[test] #[test]
fn fetch_round_trips_direct_body_and_body_validator() { fn depth_zero_observe_round_trips_direct_body_and_validator() {
let request = FetchRequest { let request = ObserveRequest {
path: "/items/123".into(), path: "/items/123".into(),
depth: 0,
}; };
let encoded_request = encode_fetch_request(&endpoint(), &request, limits()).unwrap(); let encoded_request = encode_observe_request(&endpoint(), &request, limits()).unwrap();
assert_eq!(encoded_request.uri().path(), "/wip/v1/fetch"); assert_eq!(encoded_request.uri().path(), "/wip/v1/observe");
assert_eq!( assert_eq!(
decode_fetch_request(encoded_request.body(), limits()).unwrap(), decode_observe_request(encoded_request.body(), limits()).unwrap(),
request request
); );
let expected = object("123", &[0, 1, 2, 255]); let expected = ObjectObservation {
let encoded = encode_fetch_response(&request, &expected, limits()).unwrap(); object: object("123", &[0, 1, 2, 255]),
children: None,
};
let encoded = encode_observe_response(&request, &expected, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK); assert_eq!(encoded.status(), StatusCode::OK);
assert!(
!std::str::from_utf8(encoded.body())
.unwrap()
.contains("children")
);
assert_eq!( assert_eq!(
decode_fetch_response(&request, &encoded, limits()).unwrap(), decode_observe_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(expected) DecodedResponse::Success(expected)
); );
assert!(encoded.headers().get("etag").is_none()); assert!(encoded.headers().get("etag").is_none());
} }
#[test] #[test]
fn tree_and_interface_round_trip_nested_logical_models() { fn observe_and_interface_round_trip_nested_logical_models() {
let tree_request = FetchTreeRequest { let observe_request = ObserveRequest {
path: "/items".into(), path: "/items".into(),
depth: 1, depth: 1,
limit: 2,
}; };
let encoded_request = encode_fetch_tree_request(&endpoint(), &tree_request, limits()).unwrap(); let encoded_request = encode_observe_request(&endpoint(), &observe_request, limits()).unwrap();
assert_eq!( assert_eq!(
decode_fetch_tree_request(encoded_request.body(), limits()).unwrap(), decode_observe_request(encoded_request.body(), limits()).unwrap(),
tree_request observe_request
); );
let tree = FetchTreeResponse { let observation = ObjectObservation {
root: ObjectTreeNode {
object: object("items", b"root-validator"), object: object("items", b"root-validator"),
children: vec![ObjectTreeNode { children: Some(vec![ObjectObservation {
object: object("123", b"child-validator"), object: object("123", b"child-validator"),
children: vec![], children: None,
}], }]),
},
truncated: false,
}; };
let encoded = encode_fetch_tree_response(&tree_request, &tree, limits()).unwrap(); let encoded = encode_observe_response(&observe_request, &observation, limits()).unwrap();
assert_eq!( assert_eq!(
decode_fetch_tree_response(&tree_request, &encoded, limits()).unwrap(), decode_observe_response(&observe_request, &encoded, limits()).unwrap(),
DecodedResponse::Success(tree) DecodedResponse::Success(observation)
);
let empty = ObjectObservation {
object: object("items", b"root-validator"),
children: Some(vec![]),
};
let encoded_empty = encode_observe_response(&observe_request, &empty, limits()).unwrap();
assert!(
std::str::from_utf8(encoded_empty.body())
.unwrap()
.contains("\"children\":[]")
); );
let interface_request = FetchInterfaceRequest { let interface_request = FetchInterfaceRequest {
@@ -363,8 +374,10 @@ fn descriptor_guided_values_and_validators_round_trip_both_directions() {
#[test] #[test]
fn malformed_unknown_null_and_noncanonical_base64_are_rejected() { fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
let limits = limits(); let limits = limits();
assert!(decode_fetch_request(br#"{"path":"/","extra":true}"#, limits).is_err()); assert!(decode_observe_request(br#"{"path":"/","depth":0,"extra":true}"#, limits).is_err());
assert!(decode_fetch_request(br#"{"path":"relative","path":"/"}"#, limits).is_err()); assert!(
decode_observe_request(br#"{"path":"relative","path":"/","depth":0}"#, limits).is_err()
);
let request = call_request(); let request = call_request();
let descriptor = descriptor(); let descriptor = descriptor();
@@ -393,16 +406,16 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
#[test] #[test]
fn caller_limits_bound_bytes_and_nesting() { fn caller_limits_bound_bytes_and_nesting() {
let tiny = Limits::new(8, 8, 2, 1).unwrap(); let tiny = Limits::new(8, 8, 2, 1).unwrap();
let too_large = decode_fetch_request(br#"{"path":"/long"}"#, tiny).unwrap_err(); let too_large = decode_observe_request(br#"{"path":"/long","depth":0}"#, tiny).unwrap_err();
assert!(matches!(&too_large, CodecError::BodyTooLarge { .. })); assert!(matches!(&too_large, CodecError::BodyTooLarge { .. }));
assert_eq!( assert_eq!(
too_large.resource_limit_kind(), too_large.resource_limit_kind(),
Some(ResourceLimitKind::RepresentationBytes) Some(ResourceLimitKind::RepresentationBytes)
); );
let nested = br#"{"path":[[["/"]]]}"#; let nested = br#"{"path":[[["/"]]],"depth":0}"#;
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap(); let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
let too_deep = decode_fetch_request(nested, nesting_limited).unwrap_err(); let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err();
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. })); assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
assert_eq!( assert_eq!(
too_deep.resource_limit_kind(), too_deep.resource_limit_kind(),
@@ -420,11 +433,15 @@ fn caller_limits_bound_bytes_and_nesting() {
.is_err() .is_err()
); );
assert!( assert!(
encode_fetch_response( encode_observe_response(
&FetchRequest { &ObserveRequest {
path: "/item".into(), path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object("item", b"validator"),
children: None,
}, },
&object("item", b"validator"),
outbound_limited, outbound_limited,
) )
.is_err() .is_err()
@@ -488,11 +505,12 @@ fn invalid_success_is_classified_as_invalid_response() {
let response = wip_https::http::Response::builder() let response = wip_https::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(br#"{"name":"wrong","interfaces":[]}"#.to_vec()) .body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec())
.unwrap(); .unwrap();
let error = decode_fetch_response( let error = decode_observe_response(
&FetchRequest { &ObserveRequest {
path: "/expected".into(), path: "/expected".into(),
depth: 0,
}, },
&response, &response,
limits(), limits(),
+26 -20
View File
@@ -1,10 +1,10 @@
use wip_https::http::{Response, StatusCode}; use wip_https::http::{Response, StatusCode};
use wip_https::wip_protocol::{ use wip_https::wip_protocol::{
FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
}; };
use wip_https::{ use wip_https::{
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind, ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
TransportFailureKind, decode_fetch_response, encode_protocol_error_response, TransportFailureKind, decode_observe_response, encode_protocol_error_response,
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error, encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error,
status_for_resource_limit, status_for_resource_limit,
}; };
@@ -13,8 +13,11 @@ fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap() Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap()
} }
fn request() -> FetchRequest { fn request() -> ObserveRequest {
FetchRequest { path: "/".into() } ObserveRequest {
path: "/".into(),
depth: 0,
}
} }
fn response(status: StatusCode, body: impl Into<Vec<u8>>) -> Response<Vec<u8>> { fn response(status: StatusCode, body: impl Into<Vec<u8>>) -> Response<Vec<u8>> {
@@ -132,7 +135,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
for (kind, status) in cases { for (kind, status) in cases {
assert_eq!(status_for_resource_limit(kind), status); assert_eq!(status_for_resource_limit(kind), status);
let encoded = encode_resource_limit_error_response( let encoded = encode_resource_limit_error_response(
ProtocolInteraction::Fetch, ProtocolInteraction::Observe,
&error, &error,
kind, kind,
limits(), limits(),
@@ -140,7 +143,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
.unwrap(); .unwrap();
assert_eq!(encoded.status(), status); assert_eq!(encoded.status(), status);
assert_eq!( assert_eq!(
decode_fetch_response(&request(), &encoded, limits()).unwrap(), decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure { DecodedResponse::ProtocolFailure {
error: error.clone(), error: error.clone(),
status_mismatch: None, status_mismatch: None,
@@ -149,7 +152,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
} }
let default = let default =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS); assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS);
} }
@@ -161,7 +164,7 @@ fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
.body(b"rate limited".to_vec()) .body(b"rate limited".to_vec())
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
decode_fetch_response(&request(), &response, limits()).unwrap_err(), decode_observe_response(&request(), &response, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure) ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::TOO_MANY_REQUESTS if failure.status == StatusCode::TOO_MANY_REQUESTS
&& failure.kind == TransportFailureKind::HttpStatus && failure.kind == TransportFailureKind::HttpStatus
@@ -170,10 +173,10 @@ fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
#[test] #[test]
fn valid_success_body_requires_exactly_http_200() { fn valid_success_body_requires_exactly_http_200() {
let success_body = br#"{"name":"","interfaces":[]}"#; let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] { for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
assert!(matches!( assert!(matches!(
decode_fetch_response(&request(), &response(status, success_body), limits()) decode_observe_response(&request(), &response(status, success_body), limits())
.unwrap_err(), .unwrap_err(),
ClientResponseError::InvalidResponse { ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::NonCanonicalSuccessStatus, kind: InvalidResponseKind::NonCanonicalSuccessStatus,
@@ -187,9 +190,11 @@ fn valid_success_body_requires_exactly_http_200() {
fn server_encoder_rejects_codes_disallowed_for_the_interaction() { fn server_encoder_rejects_codes_disallowed_for_the_interaction() {
let error = ProtocolError { let error = ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound, code: ProtocolErrorCode::InterfaceNotFound,
message: "not valid for fetch".into(), message: "not valid for observe".into(),
}; };
assert!(encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).is_err()); assert!(
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).is_err()
);
} }
#[test] #[test]
@@ -199,11 +204,11 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
message: "missing".into(), message: "missing".into(),
}; };
let mut encoded = let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT; *encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!( assert_eq!(
decode_fetch_response(&request(), &encoded, limits()).unwrap(), decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure { DecodedResponse::ProtocolFailure {
error, error,
status_mismatch: Some(wip_https::StatusMismatch { status_mismatch: Some(wip_https::StatusMismatch {
@@ -218,7 +223,7 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
fn envelope_and_status_contradictions_are_invalid_responses() { fn envelope_and_status_contradictions_are_invalid_responses() {
let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#; let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#;
let on_success = let on_success =
decode_fetch_response(&request(), &response(StatusCode::OK, error_body), limits()) decode_observe_response(&request(), &response(StatusCode::OK, error_body), limits())
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
on_success, on_success,
@@ -228,8 +233,8 @@ fn envelope_and_status_contradictions_are_invalid_responses() {
} }
)); ));
let success_body = br#"{"name":"","interfaces":[]}"#; let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
let on_error = decode_fetch_response( let on_error = decode_observe_response(
&request(), &request(),
&response(StatusCode::BAD_GATEWAY, success_body), &response(StatusCode::BAD_GATEWAY, success_body),
limits(), limits(),
@@ -247,7 +252,8 @@ fn envelope_and_status_contradictions_are_invalid_responses() {
#[test] #[test]
fn disallowed_protocol_code_is_invalid_instead_of_reclassified() { fn disallowed_protocol_code_is_invalid_instead_of_reclassified() {
let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#; let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#;
let error = decode_fetch_response(&request(), &response(StatusCode::NOT_FOUND, body), limits()) let error =
decode_observe_response(&request(), &response(StatusCode::NOT_FOUND, body), limits())
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
error, error,
@@ -282,7 +288,7 @@ fn proxy_authentication_and_endpoint_failures_stay_transport_failures() {
.status(status) .status(status)
.body(body.to_vec()) .body(body.to_vec())
.unwrap(); .unwrap();
let error = decode_fetch_response(&request(), &response, limits()).unwrap_err(); let error = decode_observe_response(&request(), &response, limits()).unwrap_err();
assert!(matches!( assert!(matches!(
error, error,
ClientResponseError::TransportBinding(failure) ClientResponseError::TransportBinding(failure)
@@ -298,7 +304,7 @@ fn malformed_non_success_envelope_is_transport_binding_failure() {
br#"{"error":{"code":"not_found","message":null}}"#, br#"{"error":{"code":"not_found","message":null}}"#,
); );
assert!(matches!( assert!(matches!(
decode_fetch_response(&request(), &malformed, limits()).unwrap_err(), decode_observe_response(&request(), &malformed, limits()).unwrap_err(),
ClientResponseError::TransportBinding(_) ClientResponseError::TransportBinding(_)
)); ));
} }
+17 -18
View File
@@ -7,10 +7,9 @@ fn endpoint_is_https_absolute_queryless_and_canonical() {
assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/"); assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/");
let expected = [ let expected = [
(Route::Fetch, "https://example.test/root/prefix/v1/fetch"),
( (
Route::FetchTree, Route::Observe,
"https://example.test/root/prefix/v1/fetch_tree", "https://example.test/root/prefix/v1/observe",
), ),
( (
Route::FetchInterface, Route::FetchInterface,
@@ -31,7 +30,7 @@ fn endpoint_is_https_absolute_queryless_and_canonical() {
} }
assert_eq!( assert_eq!(
endpoint.recognize( endpoint.recognize(
&wip_https::url::Url::parse("https://example.test/root/prefix/v1/fetch?path=/items") &wip_https::url::Url::parse("https://example.test/root/prefix/v1/observe?path=/items")
.unwrap() .unwrap()
), ),
None None
@@ -41,8 +40,8 @@ fn endpoint_is_https_absolute_queryless_and_canonical() {
#[test] #[test]
fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() { fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() {
let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap(); let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap();
let route = endpoint.route(Route::Fetch); let route = endpoint.route(Route::Observe);
assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/fetch"); assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/observe");
assert!(!route.as_str().contains("items")); assert!(!route.as_str().contains("items"));
} }
@@ -51,27 +50,27 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
let endpoint = Endpoint::parse("https://example.test/wip").unwrap(); let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
let valid = Request::builder() let valid = Request::builder()
.method(Method::POST) .method(Method::POST)
.uri("/wip/v1/fetch") .uri("/wip/v1/observe")
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &valid).is_ok()); assert!(validate_http_request(&endpoint, Route::Observe, &valid).is_ok());
let wrong_method = Request::builder() let wrong_method = Request::builder()
.method(Method::GET) .method(Method::GET)
.uri("/wip/v1/fetch") .uri("/wip/v1/observe")
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_method).is_err()); assert!(validate_http_request(&endpoint, Route::Observe, &wrong_method).is_err());
let wrong_route = Request::builder() let wrong_route = Request::builder()
.method(Method::POST) .method(Method::POST)
.uri("/wip/v1/fetch_tree") .uri("/wip/v1/fetch_interface")
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err()); assert!(validate_http_request(&endpoint, Route::Observe, &wrong_route).is_err());
for accept in [ for accept in [
"text/plain, */*;q=1, application/json;q=0", "text/plain, */*;q=1, application/json;q=0",
@@ -80,13 +79,13 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
] { ] {
let incompatible_accept = Request::builder() let incompatible_accept = Request::builder()
.method(Method::POST) .method(Method::POST)
.uri("/wip/v1/fetch") .uri("/wip/v1/observe")
.header("content-type", "application/json") .header("content-type", "application/json")
.header("accept", accept) .header("accept", accept)
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
let error = let error =
validate_http_request(&endpoint, Route::Fetch, &incompatible_accept).unwrap_err(); validate_http_request(&endpoint, Route::Observe, &incompatible_accept).unwrap_err();
assert!(matches!(&error, RequestMetadataError::NotAcceptable)); assert!(matches!(&error, RequestMetadataError::NotAcceptable));
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE); assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
} }
@@ -97,23 +96,23 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
] { ] {
let compatible_accept = Request::builder() let compatible_accept = Request::builder()
.method(Method::POST) .method(Method::POST)
.uri("/wip/v1/fetch") .uri("/wip/v1/observe")
.header("content-type", "application/json") .header("content-type", "application/json")
.header("accept", accept) .header("accept", accept)
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &compatible_accept).is_ok()); assert!(validate_http_request(&endpoint, Route::Observe, &compatible_accept).is_ok());
} }
let unsupported_media_type = Request::builder() let unsupported_media_type = Request::builder()
.method(Method::POST) .method(Method::POST)
.uri("/wip/v1/fetch") .uri("/wip/v1/observe")
.header("content-type", "text/plain") .header("content-type", "text/plain")
.header("accept", "application/json") .header("accept", "application/json")
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
let error = let error =
validate_http_request(&endpoint, Route::Fetch, &unsupported_media_type).unwrap_err(); validate_http_request(&endpoint, Route::Observe, &unsupported_media_type).unwrap_err();
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType)); assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
} }
+13 -17
View File
@@ -62,7 +62,7 @@ pub enum PathSegment {
Return, Return,
/// Object at a Worldspace path. /// Object at a Worldspace path.
Object(String), Object(String),
/// Child object in a tree response. /// Child object in an observation response.
Child(String), Child(String),
} }
@@ -163,17 +163,15 @@ pub enum ValidationErrorKind {
/// Shared opaque object reference. /// Shared opaque object reference.
reference: String, reference: String,
}, },
/// A `fetch_tree` request used a zero limit. /// An observation omitted children before reaching the requested depth.
ZeroTreeLimit, ChildrenMissingBeforeDepth {
/// A tree response exceeds the requested maximum depth. /// Node depth where children were required.
TreeDepthExceeded { depth: u32,
/// Requested maximum depth.
maximum: u32,
}, },
/// A tree response exceeds the requested maximum node count. /// An observation included children at the requested depth boundary.
TreeLimitExceeded { ChildrenAtDepthBoundary {
/// Requested maximum node count. /// Boundary depth where children must be unobserved.
limit: u32, depth: u32,
}, },
/// A response refers to a different interface than its request. /// A response refers to a different interface than its request.
InterfaceResponseMismatch { InterfaceResponseMismatch {
@@ -226,10 +224,8 @@ impl Error for ValidationError {}
/// One core interaction whose failure codes form a closed set. /// One core interaction whose failure codes form a closed set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolInteraction { pub enum ProtocolInteraction {
/// Fetch one object by its Worldspace path. /// Observe one object and its indexable descendants to a requested depth.
Fetch, Observe,
/// Fetch a bounded tree of objects.
FetchTree,
/// Fetch one interface descriptor by its opaque reference. /// Fetch one interface descriptor by its opaque reference.
FetchInterface, FetchInterface,
/// Invoke one operation on a path-targeted object. /// Invoke one operation on a path-targeted object.
@@ -315,11 +311,11 @@ impl ProtocolErrorCode {
OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded, OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded,
ValidatorMismatch, ValidatorRequired, ValidatorMismatch, ValidatorRequired,
}; };
use ProtocolInteraction::{CallOperation, Fetch, FetchInterface, FetchTree}; use ProtocolInteraction::{CallOperation, FetchInterface, Observe};
match (interaction, self) { match (interaction, self) {
(_, InvalidRequest | ResourceLimitExceeded | Internal) => true, (_, InvalidRequest | ResourceLimitExceeded | Internal) => true,
(Fetch | FetchTree, NotFound) => true, (Observe, NotFound) => true,
(FetchInterface, InterfaceNotFound) => true, (FetchInterface, InterfaceNotFound) => true,
( (
CallOperation, CallOperation,
+4 -6
View File
@@ -11,8 +11,7 @@
//! | Interface Descriptor | [`InterfaceDescriptor`] | //! | Interface Descriptor | [`InterfaceDescriptor`] |
//! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] | //! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] |
//! | Schema-neutral primitive/composite value | [`Value`] | //! | Schema-neutral primitive/composite value | [`Value`] |
//! | `fetch` | [`FetchRequest`] / [`FetchResponse`] | //! | `observe` | [`ObserveRequest`] / [`ObserveResponse`] |
//! | `fetch_tree` | [`FetchTreeRequest`] / [`FetchTreeResponse`] |
//! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] | //! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] |
//! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] | //! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] |
//! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] | //! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] |
@@ -53,10 +52,9 @@ pub use error::{
}; };
pub use model::{ pub use model::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object,
FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, ObjectTreeNode, ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
TypeDeclaration, TypeExpr, UnionCase, Value,
}; };
/// Validates a canonical absolute Worldspace path. /// Validates a canonical absolute Worldspace path.
+15 -50
View File
@@ -218,44 +218,26 @@ impl Value {
} }
} }
/// Request for the object currently published at one path. /// Request for the object currently published at one path and its indexable descendants.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchRequest { pub struct ObserveRequest {
/// Canonical absolute Worldspace path. /// Canonical absolute path of the observation root.
pub path: String, pub path: String,
} /// Maximum observed depth, where the requested object has depth zero.
/// Successful `fetch` response.
pub type FetchResponse = Object;
/// Request for a bounded breadth-first object-tree observation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchTreeRequest {
/// Canonical absolute path of the tree root.
pub path: String,
/// Maximum depth, where the requested object has depth zero.
pub depth: u32, pub depth: u32,
/// Maximum total node count, including the root. Must be nonzero.
pub limit: u32,
} }
/// One node in a tree observation. /// One node in an object observation.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectTreeNode { pub struct ObjectObservation {
/// Object observed at this tree position. /// Object observed at this Worldspace position.
pub object: Object, pub object: Object,
/// Direct children in host-defined traversal order. /// Complete direct children when observed, or `None` at the requested depth boundary.
pub children: Vec<ObjectTreeNode>, pub children: Option<Vec<ObjectObservation>>,
} }
/// Successful `fetch_tree` response. /// Successful `observe` response.
#[derive(Debug, Clone, PartialEq, Eq)] pub type ObserveResponse = ObjectObservation;
pub struct FetchTreeResponse {
/// Observed tree rooted at the requested path.
pub root: ObjectTreeNode,
/// Whether public indexable children were omitted by depth or limit.
pub truncated: bool,
}
/// Request for the current descriptor of one opaque interface reference. /// Request for the current descriptor of one opaque interface reference.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -372,35 +354,18 @@ impl InterfaceDescriptor {
} }
} }
impl FetchRequest { impl ObserveRequest {
/// Validates the request path. /// Validates the request path.
pub fn validate(&self) -> Result<(), ValidationError> { pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_path(&self.path) crate::validation::validate_path(&self.path)
} }
/// Validates an object returned for this request. /// Validates observation shape, names, and requested depth.
/// ///
/// Failure means the Client received an invalid success response; it is not a /// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`]. /// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> { pub fn validate_response(&self, response: &ObserveResponse) -> Result<(), ValidationError> {
response.validate_at_path(&self.path) crate::validation::validate_observe_response(response, self)
}
}
impl FetchTreeRequest {
/// Validates the request path and nonzero node limit.
pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_request(self)
}
}
impl FetchTreeResponse {
/// Validates tree shape, names, depth, and node limit for `request`.
///
/// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_response(self, request)
} }
} }
+22 -51
View File
@@ -1,10 +1,10 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use crate::{ use crate::{
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
FetchTreeResponse, InterfaceDescriptor, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
Object, ObjectTreeNode, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, ValidationError,
UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind, ValidationErrorKind, Value, ValueKind,
}; };
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> { pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
@@ -604,60 +604,38 @@ fn find_operation<'a>(
}) })
} }
pub(crate) fn validate_fetch_tree_request( pub(crate) fn validate_observe_response(
request: &FetchTreeRequest, response: &ObjectObservation,
) -> Result<(), ValidationError> { request: &ObserveRequest,
validate_path(&request.path)?;
if request.limit == 0 {
Err(ValidationError::new(ValidationErrorKind::ZeroTreeLimit))
} else {
Ok(())
}
}
pub(crate) fn validate_fetch_tree_response(
response: &FetchTreeResponse,
request: &FetchTreeRequest,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
request.validate()?; request.validate()?;
let mut node_count = 0_u32; validate_observation_node(response, &request.path, 0, request.depth)
validate_tree_node(
&response.root,
&request.path,
0,
request.depth,
request.limit,
&mut node_count,
)
} }
fn validate_tree_node( fn validate_observation_node(
node: &ObjectTreeNode, node: &ObjectObservation,
path: &str, path: &str,
depth: u32, depth: u32,
maximum_depth: u32, maximum_depth: u32,
limit: u32,
node_count: &mut u32,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
if depth > maximum_depth { node.object.validate_at_path(path)?;
if depth == maximum_depth {
if node.children.is_some() {
return Err( return Err(
ValidationError::new(ValidationErrorKind::TreeDepthExceeded { ValidationError::new(ValidationErrorKind::ChildrenAtDepthBoundary { depth })
maximum: maximum_depth,
})
.prepend(PathSegment::Object(path.to_owned())), .prepend(PathSegment::Object(path.to_owned())),
); );
} }
*node_count = node_count.saturating_add(1); return Ok(());
if *node_count > limit {
return Err(
ValidationError::new(ValidationErrorKind::TreeLimitExceeded { limit })
.prepend(PathSegment::Object(path.to_owned())),
);
} }
node.object.validate_at_path(path)?; let children = node.children.as_ref().ok_or_else(|| {
ValidationError::new(ValidationErrorKind::ChildrenMissingBeforeDepth { depth })
.prepend(PathSegment::Object(path.to_owned()))
})?;
let mut names = HashSet::new(); let mut names = HashSet::new();
for child in &node.children { for child in children {
validate_object_name(&child.object.name, false) validate_object_name(&child.object.name, false)
.map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?; .map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?;
if !names.insert(child.object.name.as_str()) { if !names.insert(child.object.name.as_str()) {
@@ -673,14 +651,7 @@ fn validate_tree_node(
} else { } else {
format!("{path}/{}", child.object.name) format!("{path}/{}", child.object.name)
}; };
validate_tree_node( validate_observation_node(child, &child_path, depth + 1, maximum_depth)?;
child,
&child_path,
depth.saturating_add(1),
maximum_depth,
limit,
node_count,
)?;
} }
Ok(()) Ok(())
} }
+63 -65
View File
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, ProtocolErrorCode,
ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr,
TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind, UnionCase, ValidationErrorKind, Value, ValueKind,
}; };
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value { fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
@@ -617,13 +617,26 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
} }
#[test] #[test]
fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() { fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
let fetch = FetchRequest { let observe = ObserveRequest {
path: "/articles/one".into(), path: "/articles/one".into(),
depth: 0,
}; };
fetch.validate().unwrap(); observe.validate().unwrap();
fetch.validate_response(&object("one")).unwrap(); observe
assert!(fetch.validate_response(&object("two")).is_err()); .validate_response(&ObjectObservation {
object: object("one"),
children: None,
})
.unwrap();
assert!(
observe
.validate_response(&ObjectObservation {
object: object("two"),
children: None,
})
.is_err()
);
for invalid in [ for invalid in [
"", "",
@@ -634,8 +647,9 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
"/a/../b", "/a/../b",
] { ] {
assert!(matches!( assert!(matches!(
FetchRequest { ObserveRequest {
path: invalid.into() path: invalid.into(),
depth: 0,
} }
.validate() .validate()
.unwrap_err() .unwrap_err()
@@ -666,61 +680,51 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
} }
#[test] #[test]
fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() { fn observe_response_validates_complete_children_and_depth_boundary() {
let request = FetchTreeRequest { let request = ObserveRequest {
path: "/articles".into(), path: "/articles".into(),
depth: 1, depth: 1,
limit: 3,
}; };
let leaf = |name: &str| ObjectTreeNode { let leaf = |name: &str| ObjectObservation {
object: object(name), object: object(name),
children: vec![], children: None,
}; };
let response = FetchTreeResponse { let response = ObjectObservation {
root: ObjectTreeNode {
object: object("articles"), object: object("articles"),
children: vec![leaf("one"), leaf("two")], children: Some(vec![leaf("one"), leaf("two")]),
},
truncated: false,
}; };
response.validate_for(&request).unwrap(); request.validate_response(&response).unwrap();
let zero = FetchTreeRequest { let empty = ObjectObservation {
limit: 0, object: object("articles"),
..request.clone() children: Some(vec![]),
}; };
assert_eq!( request.validate_response(&empty).unwrap();
zero.validate().unwrap_err().kind,
ValidationErrorKind::ZeroTreeLimit
);
let shallow = FetchTreeRequest { let missing = ObjectObservation {
object: object("articles"),
children: None,
};
assert!(matches!(
request.validate_response(&missing).unwrap_err().kind,
ValidationErrorKind::ChildrenMissingBeforeDepth { depth: 0 }
));
let boundary = ObserveRequest {
depth: 0, depth: 0,
..request.clone() ..request.clone()
}; };
assert!(matches!( assert!(matches!(
response.validate_for(&shallow).unwrap_err().kind, boundary.validate_response(&response).unwrap_err().kind,
ValidationErrorKind::TreeDepthExceeded { .. } ValidationErrorKind::ChildrenAtDepthBoundary { depth: 0 }
)); ));
let small = FetchTreeRequest { let duplicate = ObjectObservation {
limit: 2,
..request.clone()
};
assert!(matches!(
response.validate_for(&small).unwrap_err().kind,
ValidationErrorKind::TreeLimitExceeded { .. }
));
let duplicate = FetchTreeResponse {
root: ObjectTreeNode {
object: object("articles"), object: object("articles"),
children: vec![leaf("one"), leaf("one")], children: Some(vec![leaf("one"), leaf("one")]),
},
truncated: false,
}; };
assert!(matches!( assert!(matches!(
duplicate.validate_for(&request).unwrap_err().kind, request.validate_response(&duplicate).unwrap_err().kind,
ValidationErrorKind::DuplicateName { ValidationErrorKind::DuplicateName {
namespace: NameNamespace::ChildObject, namespace: NameNamespace::ChildObject,
.. ..
@@ -778,22 +782,9 @@ fn assert_allowed_codes(interaction: ProtocolInteraction, expected: &[ProtocolEr
} }
#[test] #[test]
fn fetch_error_codes_are_exhaustive() { fn observe_error_codes_are_exhaustive() {
assert_allowed_codes( assert_allowed_codes(
ProtocolInteraction::Fetch, ProtocolInteraction::Observe,
&[
ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound,
ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal,
],
);
}
#[test]
fn fetch_tree_error_codes_are_exhaustive() {
assert_allowed_codes(
ProtocolInteraction::FetchTree,
&[ &[
ProtocolErrorCode::InvalidRequest, ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound, ProtocolErrorCode::NotFound,
@@ -840,11 +831,18 @@ fn call_operation_error_codes_are_exhaustive() {
#[test] #[test]
fn invalid_success_payloads_remain_client_local_validation_failures() { fn invalid_success_payloads_remain_client_local_validation_failures() {
let fetch = FetchRequest { let observe = ObserveRequest {
path: "/articles/one".into(), path: "/articles/one".into(),
depth: 0,
}; };
assert!(matches!( assert!(matches!(
fetch.validate_response(&object("other")).unwrap_err().kind, observe
.validate_response(&ObjectObservation {
object: object("other"),
children: None,
})
.unwrap_err()
.kind,
ValidationErrorKind::ObjectNameMismatch { .. } ValidationErrorKind::ObjectNameMismatch { .. }
)); ));
+1 -1
View File
@@ -190,7 +190,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
audit: audit.clone(), audit: audit.clone(),
}; };
let object = provider.fetch(OBJECT_PATH)?; let object = provider.observe_object(OBJECT_PATH)?;
let catalog_response = registry let catalog_response = registry
.fetch_interface( .fetch_interface(
object object
+3 -4
View File
@@ -80,7 +80,6 @@ All required actions are keyboard-accessible.
| `←`/`h` | Collapse a node or move to its parent | | `←`/`h` | Collapse a node or move to its parent |
| `Tab` | Cycle Tree → Selected Entry → Operations → History | | `Tab` | Cycle Tree → Selected Entry → Operations → History |
| `r` | Reobserve the selected Entry and its direct subtree | | `r` | Reobserve the selected Entry and its direct subtree |
| `+` | Double the selected node limit and retry an incomplete/truncated tree |
| `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled | | `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled |
| `R` | Reconnect after connection failure | | `R` | Reconnect after connection failure |
| `?` | Open help | | `?` | Open help |
@@ -94,9 +93,9 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
## Screen model ## Screen model
- **Worldspace tree** starts at `/` and materializes direct children only when a - **Worldspace tree** starts at `/` and materializes direct children only when a
node is expanded. Text labels distinguish unloaded, loading, empty, stale, node is expanded. Text labels distinguish unloaded, loading, empty, stale, and
failed, and truncated state; color is supplemental. A truncated node is never failed state; color is supplemental. Every successful expansion is complete for
presented as a complete tree. its requested depth.
- **Selected Entry** shows only public WIP path, object name, description, ordered - **Selected Entry** shows only public WIP path, object name, description, ordered
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
+10 -50
View File
@@ -31,8 +31,6 @@ pub enum InputKey {
Backspace, Backspace,
/// Refresh the selected Entry. /// Refresh the selected Entry.
Refresh, Refresh,
/// Retry a truncated tree with a larger limit.
IncreaseLimit,
/// Cancel the newest cancel-safe retrieval. /// Cancel the newest cancel-safe retrieval.
CancelRead, CancelRead,
/// Show key help. /// Show key help.
@@ -123,14 +121,10 @@ pub struct EntryView {
pub tree_state: ViewState, pub tree_state: ViewState,
/// Whether the user expanded this row. /// Whether the user expanded this row.
pub expanded: bool, pub expanded: bool,
/// Whether the observed child list is incomplete.
pub truncated: bool,
/// Current bounded expansion limit.
pub limit: u32,
} }
impl EntryView { impl EntryView {
fn placeholder(path: impl Into<String>, default_limit: u32) -> Self { fn placeholder(path: impl Into<String>) -> Self {
Self { Self {
path: path.into(), path: path.into(),
object: None, object: None,
@@ -138,8 +132,6 @@ impl EntryView {
children: Vec::new(), children: Vec::new(),
tree_state: ViewState::Unloaded, tree_state: ViewState::Unloaded,
expanded: false, expanded: false,
truncated: false,
limit: default_limit,
} }
} }
} }
@@ -242,15 +234,11 @@ pub enum Action {
Expand { Expand {
/// Tree root path. /// Tree root path.
path: String, path: String,
/// Maximum returned nodes.
limit: u32,
}, },
/// Reobserve an Entry and its direct children. /// Reobserve an Entry and its direct children.
Refresh { Refresh {
/// Selected path. /// Selected path.
path: String, path: String,
/// Current tree limit.
limit: u32,
}, },
/// Retrieve one interface descriptor. /// Retrieve one interface descriptor.
FetchInterface(String), FetchInterface(String),
@@ -267,7 +255,7 @@ pub enum Action {
}, },
/// Cancel the newest safe retrieval. /// Cancel the newest safe retrieval.
CancelRead, CancelRead,
/// Reconnect and fetch the root. /// Reconnect and observe the root.
Reconnect, Reconnect,
/// Navigate to a descriptor-declared Entry result. /// Navigate to a descriptor-declared Entry result.
NavigateEntry(String), NavigateEntry(String),
@@ -302,8 +290,6 @@ pub struct App {
pub history: Vec<HistoryItem>, pub history: Vec<HistoryItem>,
/// Maximum retained presentation history. /// Maximum retained presentation history.
pub history_limit: usize, pub history_limit: usize,
/// Default node limit for lazy expansion.
pub default_tree_limit: u32,
/// One-line status for local action feedback. /// One-line status for local action feedback.
pub status: String, pub status: String,
} }
@@ -312,9 +298,8 @@ impl App {
/// Creates a disconnected model with `/` selected. /// Creates a disconnected model with `/` selected.
#[must_use] #[must_use]
pub fn new(endpoint: impl Into<String>) -> Self { pub fn new(endpoint: impl Into<String>) -> Self {
let default_tree_limit = 64;
let mut entries = BTreeMap::new(); let mut entries = BTreeMap::new();
entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit)); entries.insert("/".into(), EntryView::placeholder("/"));
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
connection: ConnectionState::Disconnected, connection: ConnectionState::Disconnected,
@@ -328,7 +313,6 @@ impl App {
in_flight: BTreeMap::new(), in_flight: BTreeMap::new(),
history: Vec::new(), history: Vec::new(),
history_limit: 64, history_limit: 64,
default_tree_limit,
status: "press ? for help".into(), status: "press ? for help".into(),
} }
} }
@@ -441,14 +425,11 @@ impl App {
let node = self let node = self
.entries .entries
.entry(self.selected_path.clone()) .entry(self.selected_path.clone())
.or_insert_with(|| { .or_insert_with(|| EntryView::placeholder(&self.selected_path));
EntryView::placeholder(&self.selected_path, self.default_tree_limit)
});
node.expanded = true; node.expanded = true;
if !matches!(node.tree_state, ViewState::Fresh) || node.truncated { if !matches!(node.tree_state, ViewState::Fresh) {
return vec![Action::Expand { return vec![Action::Expand {
path: node.path.clone(), path: node.path.clone(),
limit: node.limit,
}]; }];
} }
} }
@@ -464,18 +445,6 @@ impl App {
} }
} }
InputKey::Refresh => return vec![self.refresh_action()], InputKey::Refresh => return vec![self.refresh_action()],
InputKey::IncreaseLimit => {
let node = self
.entries
.get_mut(&self.selected_path)
.expect("selected exists");
node.limit = node.limit.saturating_mul(2).max(1);
node.expanded = true;
return vec![Action::Expand {
path: node.path.clone(),
limit: node.limit,
}];
}
_ => {} _ => {}
} }
self.operation_cursor = 0; self.operation_cursor = 0;
@@ -537,13 +506,8 @@ impl App {
} }
fn refresh_action(&self) -> Action { fn refresh_action(&self) -> Action {
let limit = self
.entries
.get(&self.selected_path)
.map_or(self.default_tree_limit, |entry| entry.limit);
Action::Refresh { Action::Refresh {
path: self.selected_path.clone(), path: self.selected_path.clone(),
limit,
} }
} }
@@ -591,7 +555,6 @@ impl App {
let children = observation.children.clone(); let children = observation.children.clone();
let entry = self.ensure_entry(&observation.path); let entry = self.ensure_entry(&observation.path);
entry.children = children.clone(); entry.children = children.clone();
entry.truncated = observation.truncated;
entry.tree_state = ViewState::from(&observation.state); entry.tree_state = ViewState::from(&observation.state);
entry.expanded = true; entry.expanded = true;
for path in children { for path in children {
@@ -725,7 +688,7 @@ impl App {
} }
self.entries self.entries
.entry(path.to_owned()) .entry(path.to_owned())
.or_insert_with(|| EntryView::placeholder(path, self.default_tree_limit)) .or_insert_with(|| EntryView::placeholder(path))
} }
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView { fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
@@ -799,23 +762,20 @@ mod tests {
} }
#[test] #[test]
fn tree_navigation_expansion_parent_and_limit_are_pure() { fn tree_navigation_and_lazy_expansion_are_pure() {
let mut app = App::new("https://example.test/wip"); let mut app = App::new("https://example.test/wip");
app.apply_tree(TreeObservation { app.apply_tree(TreeObservation {
path: "/".into(), path: "/".into(),
children: vec!["/a".into(), "/b".into()], children: vec!["/a".into(), "/b".into()],
truncated: true,
revision: request_id(), revision: request_id(),
state: ObservationState::Fresh, state: ObservationState::Fresh,
}); });
assert_eq!(app.visible_paths(), ["/", "/a", "/b"]); assert_eq!(app.visible_paths(), ["/", "/a", "/b"]);
app.reduce(InputKey::Down); app.reduce(InputKey::Down);
assert_eq!(app.selected_path, "/a"); assert_eq!(app.selected_path, "/a");
app.reduce(InputKey::Left);
assert_eq!(app.selected_path, "/");
assert!(matches!( assert!(matches!(
app.reduce(InputKey::IncreaseLimit).as_slice(), app.reduce(InputKey::Right).as_slice(),
[Action::Expand { path, limit: 128 }] if path == "/" [Action::Expand { path }] if path == "/a"
)); ));
} }
@@ -916,6 +876,6 @@ mod tests {
wip_client::SecurityContext::new("test"), wip_client::SecurityContext::new("test"),
) )
.unwrap(); .unwrap();
client.prepare_fetch(&session, "/").unwrap().id client.prepare_observe(&session, "/", 0).unwrap().id
} }
} }
+2 -21
View File
@@ -113,13 +113,8 @@ fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
ViewState::Fresh if node.children.is_empty() => " [empty]", ViewState::Fresh if node.children.is_empty() => " [empty]",
_ => "", _ => "",
}; };
let incomplete = if node.truncated {
" [truncated: + to increase limit]"
} else {
""
};
let object = state_suffix(&node.state); let object = state_suffix(&node.state);
format!("{object}{tree}{incomplete}") format!("{object}{tree}")
}, },
); );
suffix = sanitize(&suffix); suffix = sanitize(&suffix);
@@ -178,14 +173,6 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
"No current object metadata; refresh required (old metadata hidden).", "No current object metadata; refresh required (old metadata hidden).",
)); ));
} }
if entry.truncated {
lines.push(Line::styled(
"TRUNCATED: this is not a complete subtree.",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
} }
frame.render_widget( frame.render_widget(
Paragraph::new(lines) Paragraph::new(lines)
@@ -355,7 +342,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget( frame.render_widget(
Paragraph::new( Paragraph::new(
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\n+ increase truncated tree limit and retry\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.", "Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
) )
.block(Block::default().borders(Borders::ALL).title(" Help ")) .block(Block::default().borders(Borders::ALL).title(" Help "))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
@@ -625,7 +612,6 @@ mod tests {
fn snapshot_contains_non_color_state_markers_and_hides_validators() { fn snapshot_contains_non_color_state_markers_and_hides_validators() {
let mut app = App::new("https://example.test/wip/"); let mut app = App::new("https://example.test/wip/");
app.connection = ConnectionState::Connected; app.connection = ConnectionState::Connected;
app.entries.get_mut("/").unwrap().truncated = true;
app.entries.get_mut("/").unwrap().state = ViewState::Stale; app.entries.get_mut("/").unwrap().state = ViewState::Stale;
app.push_history(HistoryItem { app.push_history(HistoryItem {
kind: HistoryKind::OutcomeUnknown, kind: HistoryKind::OutcomeUnknown,
@@ -634,7 +620,6 @@ mod tests {
entry_target: None, entry_target: None,
}); });
let snapshot = rendered(&app, 110, 30); let snapshot = rendered(&app, 110, 30);
assert!(snapshot.contains("truncated"));
assert!(snapshot.contains("stale")); assert!(snapshot.contains("stale"));
assert!(snapshot.contains("OUTCOME UNKNOWN")); assert!(snapshot.contains("OUTCOME UNKNOWN"));
assert!(!snapshot.contains("validator")); assert!(!snapshot.contains("validator"));
@@ -740,8 +725,6 @@ mod tests {
children: vec![], children: vec![],
tree_state: ViewState::Unloaded, tree_state: ViewState::Unloaded,
expanded: false, expanded: false,
truncated: false,
limit: 1,
}, },
); );
} }
@@ -832,8 +815,6 @@ mod tests {
children: vec![], children: vec![],
tree_state: ViewState::Unloaded, tree_state: ViewState::Unloaded,
expanded: false, expanded: false,
truncated: false,
limit: 1,
}; };
assert_eq!(entry.path, "/"); assert_eq!(entry.path, "/");
} }
+17 -22
View File
@@ -304,8 +304,7 @@ impl<T: Transport> Runtime<T> {
/// Starts or restarts observation at Worldspace `/`. /// Starts or restarts observation at Worldspace `/`.
pub fn connect(&mut self) { pub fn connect(&mut self) {
self.app.connection = ConnectionState::Connecting; self.app.connection = ConnectionState::Connecting;
let limit = self.app.default_tree_limit; match self.client.prepare_observe(&self.session, "/", 1) {
match self.client.prepare_tree(&self.session, "/", 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false), Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false),
Err(error) => self.local_error("connect", error), Err(error) => self.local_error("connect", error),
} }
@@ -348,20 +347,12 @@ impl<T: Transport> Runtime<T> {
fn perform(&mut self, action: Action) -> bool { fn perform(&mut self, action: Action) -> bool {
match action { match action {
Action::Expand { path, limit } => { Action::Expand { path } => match self.client.prepare_observe(&self.session, &path, 1) {
match self.client.prepare_tree(&self.session, &path, 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("tree expansion", error), Err(error) => self.local_error("tree expansion", error),
} },
} Action::Refresh { path } => {
Action::Refresh { path, limit } => { match self.client.prepare_observe(&self.session, &path, 1) {
match self.client.prepare_fetch(&self.session, &path) {
Ok(request) => {
self.dispatch(request, InFlightKind::Object(path.clone()), false)
}
Err(error) => self.local_error("Entry refresh", error),
}
match self.client.prepare_tree(&self.session, &path, 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("subtree refresh", error), Err(error) => self.local_error("subtree refresh", error),
} }
@@ -402,7 +393,7 @@ impl<T: Transport> Runtime<T> {
Action::Reconnect => self.connect(), Action::Reconnect => self.connect(),
Action::NavigateEntry(path) => { Action::NavigateEntry(path) => {
self.app.navigate_entry(path.clone()); self.app.navigate_entry(path.clone());
match self.client.prepare_fetch(&self.session, &path) { match self.client.prepare_observe(&self.session, &path, 0) {
Ok(request) => self.dispatch(request, InFlightKind::Object(path), false), Ok(request) => self.dispatch(request, InFlightKind::Object(path), false),
Err(error) => self.local_error("Entry navigation", error), Err(error) => self.local_error("Entry navigation", error),
} }
@@ -580,15 +571,19 @@ impl<T: Transport> Runtime<T> {
fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) { fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) {
match completion { match completion {
Completion::Object(observation) => self.app.apply_object(observation), Completion::Object(observation) => {
Completion::Tree(observation) => { let path = observation.path.clone();
let root = observation.path.clone(); self.app.apply_object(observation);
if matches!(kind, Some(InFlightKind::Tree(_))) {
if let Some(observation) = self.client.tree(&self.session, &path).cloned() {
self.app.apply_tree(observation); self.app.apply_tree(observation);
}
self.sync_all_objects(); self.sync_all_objects();
if root == "/" { if path == "/" {
self.app.connection = ConnectionState::Connected; self.app.connection = ConnectionState::Connected;
} }
} }
}
Completion::Interface(observation) => self.app.apply_interface(observation), Completion::Interface(observation) => self.app.apply_interface(observation),
Completion::Call(record) => { Completion::Call(record) => {
let path = record.context.object().path.clone(); let path = record.context.object().path.clone();
@@ -708,7 +703,7 @@ impl<T: Transport> Runtime<T> {
fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) { fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) {
let title = match kind { let title = match kind {
Some(InFlightKind::Object(path)) => format!("fetch {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 {reference}"),
Some(InFlightKind::Operation { operation, .. }) => operation.clone(), Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
@@ -1029,7 +1024,7 @@ mod tests {
let transport = HttpTransport::new(&config, 1024).unwrap(); let transport = HttpTransport::new(&config, 1024).unwrap();
let off_origin = Request::builder() let off_origin = Request::builder()
.method("POST") .method("POST")
.uri("https://attacker.test/wip/v1/fetch") .uri("https://attacker.test/wip/v1/observe")
.body(Vec::new()) .body(Vec::new())
.unwrap(); .unwrap();
let error = transport.prepare(off_origin).unwrap_err(); let error = transport.prepare(off_origin).unwrap_err();
@@ -1039,7 +1034,7 @@ mod tests {
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap(); let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap();
let valid = Request::builder() let valid = Request::builder()
.method("POST") .method("POST")
.uri(endpoint.route(wip_https::Route::Fetch).as_str()) .uri(endpoint.route(wip_https::Route::Observe).as_str())
.body(Vec::new()) .body(Vec::new())
.unwrap(); .unwrap();
let prepared = transport.prepare(valid).unwrap(); let prepared = transport.prepare(valid).unwrap();
-1
View File
@@ -97,7 +97,6 @@ pub fn translate_key(event: KeyEvent, editing: bool) -> Option<InputKey> {
KeyCode::Char('l') => InputKey::Right, KeyCode::Char('l') => InputKey::Right,
KeyCode::Char('r') => InputKey::Refresh, KeyCode::Char('r') => InputKey::Refresh,
KeyCode::Char('R') => InputKey::Reconnect, KeyCode::Char('R') => InputKey::Reconnect,
KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit,
KeyCode::Char('c') => InputKey::CancelRead, KeyCode::Char('c') => InputKey::CancelRead,
KeyCode::Char('?') => InputKey::Help, KeyCode::Char('?') => InputKey::Help,
KeyCode::Char('q') => InputKey::Quit, KeyCode::Char('q') => InputKey::Quit,
+6 -6
View File
@@ -50,7 +50,7 @@ impl Transport for ScriptedTransport {
detail: "scripted local conversion failure".into(), detail: "scripted local conversion failure".into(),
}); });
} }
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") { if self.fail_tree_prepare && request.uri().path().ends_with("/v1/observe") {
return Err(TransportError { return Err(TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "scripted tree conversion failure".into(), detail: "scripted tree conversion failure".into(),
@@ -61,7 +61,7 @@ impl Transport for ScriptedTransport {
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> { fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel); let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
if request.uri().path().ends_with("/v1/fetch_tree") if request.uri().path().ends_with("/v1/observe")
&& self && self
.root_tree_protocol_failures .root_tree_protocol_failures
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
@@ -70,7 +70,7 @@ impl Transport for ScriptedTransport {
.is_ok() .is_ok()
{ {
return Ok(encode_protocol_error_response( return Ok(encode_protocol_error_response(
ProtocolInteraction::FetchTree, ProtocolInteraction::Observe,
&ProtocolError { &ProtocolError {
code: ProtocolErrorCode::Internal, code: ProtocolErrorCode::Internal,
message: "scripted startup failure".into(), message: "scripted startup failure".into(),
@@ -80,9 +80,9 @@ impl Transport for ScriptedTransport {
.expect("encode tree failure")); .expect("encode tree failure"));
} }
if self.delayed_after_startup && sequence > 0 { if self.delayed_after_startup && sequence > 0 {
// Each refresh starts object then tree. Make the first pair lose to // Delay the first refresh so the second wins and exercises exact
// the second pair to exercise exact RequestId ownership. // RequestId ownership.
let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 }; let delay = if sequence == 1 { 80 } else { 5 };
thread::sleep(Duration::from_millis(delay)); thread::sleep(Duration::from_millis(delay));
} }
if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") { if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {