diff --git a/README.md b/README.md index bc39333..985c876 100644 --- a/README.md +++ b/README.md @@ -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-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-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. | 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 resource/failure semantics. It resolves every object from current filesystem state, uses pinned directory-relative handles without following symlinks, omits -non-UTF-8 backend names, performs bounded breadth-first tree discovery, and -publishes validator-protected replacement only in explicit read-write mode. +non-UTF-8 backend names, returns complete recursive observations through the +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 delegated to a reverse proxy, while local plain HTTP requires an opt-in flag. diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index ca85723..495ca84 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -15,14 +15,13 @@ use std::fmt::{self, Display, Formatter}; use wip_https::http::{Request, Response}; use wip_https::{ ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response, - decode_fetch_interface_response, decode_fetch_response, decode_fetch_tree_response, - encode_call_operation_request, encode_fetch_interface_request, encode_fetch_request, - encode_fetch_tree_request, + decode_fetch_interface_response, decode_observe_response, encode_call_operation_request, + encode_fetch_interface_request, encode_observe_request, }; use wip_protocol::{ - CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchRequest, - FetchTreeRequest, FetchTreeResponse, InterfaceDescriptor, InterfaceTarget, Object, - ObjectTreeNode, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value, + CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor, + InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest, + OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value, }; /// Monotonic identity assigned to one logical request. @@ -84,7 +83,7 @@ impl SessionId { pub struct ClientLimits { /// Maximum simultaneously retained endpoint/security-context sessions. 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, /// Maximum interface observations retained by each session. pub interfaces_per_session: usize, @@ -217,8 +216,6 @@ pub struct TreeObservation { pub path: String, /// Materialized direct child paths in Host order. pub children: Vec, - /// Whether the latest expansion rooted here omitted indexable entries. - pub truncated: bool, /// Request revision that most recently owned this tree subject. pub revision: RequestId, /// Current expansion lifecycle. @@ -375,8 +372,6 @@ pub struct PreparedRequest { pub enum Completion { /// An object observation was updated. Object(ObjectObservation), - /// A lazy tree expansion was updated. - Tree(TreeObservation), /// An interface observation was updated. Interface(InterfaceObservation), /// An operation reached a terminal outcome. @@ -501,13 +496,9 @@ impl Session { #[derive(Debug)] enum Pending { - Fetch { + Observe { session: SessionId, - request: FetchRequest, - }, - Tree { - session: SessionId, - request: FetchTreeRequest, + request: ObserveRequest, }, Interface { session: SessionId, @@ -522,9 +513,7 @@ enum Pending { impl Pending { fn session(&self) -> &SessionId { match self { - Self::Fetch { session, .. } - | Self::Tree { session, .. } - | Self::Interface { session, .. } => session, + Self::Observe { session, .. } | Self::Interface { session, .. } => session, Self::Call { context, .. } => context.session(), } } @@ -641,70 +630,30 @@ impl Client { } } - /// Starts or refreshes an object observation. - pub fn prepare_fetch( - &mut self, - session: &SessionId, - path: impl Into, - ) -> Result { - 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( + /// Starts or refreshes an object observation through the requested depth. + pub fn prepare_observe( &mut self, session: &SessionId, path: impl Into, depth: u32, - limit: u32, ) -> Result { self.ensure_capacity()?; - let request = FetchTreeRequest { + let request = ObserveRequest { path: path.into(), depth, - limit, }; let endpoint = session_endpoint(session)?; 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()) })?; 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)?; - ensure_tree_slot(state, &request.path, maximum)?; + if request.depth > 0 { + ensure_tree_slot(state, &request.path, maximum)?; + } let entry = state .entries .entry(request.path.clone()) @@ -715,21 +664,26 @@ impl Client { }); entry.revision = id; entry.state = ObservationState::Loading(id); - let tree = state - .trees - .entry(request.path.clone()) - .or_insert(TreeObservation { - path: request.path.clone(), - children: Vec::new(), - truncated: false, - revision: id, - state: ObservationState::Loading(id), - }); - tree.revision = id; - tree.state = ObservationState::Loading(id); + if request.depth > 0 { + let tree = state + .trees + .entry(request.path.clone()) + .or_insert(TreeObservation { + path: request.path.clone(), + children: Vec::new(), + revision: id, + state: ObservationState::Loading(id), + }); + tree.revision = 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( id, - Pending::Tree { + Pending::Observe { session: session.clone(), request, }, @@ -928,11 +882,8 @@ impl Client { ) -> Result { let pending = self.pending.remove(&id).ok_or_else(unknown_request)?; match pending { - Pending::Fetch { session, request } => { - self.complete_fetch(id, &session, request, response) - } - Pending::Tree { session, request } => { - self.complete_tree(id, &session, request, response) + Pending::Observe { session, request } => { + self.complete_observe(id, &session, request, response) } Pending::Interface { session, request } => { self.complete_interface(id, &session, request, response) @@ -994,12 +945,9 @@ impl Client { other => { let session = other.session().clone(); let is_current = match &other { - Pending::Fetch { request, .. } => { + Pending::Observe { request, .. } => { self.entry_request_is_current(&session, &request.path, id) } - Pending::Tree { request, .. } => { - self.tree_request_is_current(&session, &request.path, id) - } Pending::Interface { request, .. } => { self.interface_request_is_current(&session, &request.interface, id) } @@ -1054,28 +1002,29 @@ impl Client { result } - fn complete_fetch( + fn complete_observe( &mut self, id: RequestId, session: &SessionId, - request: FetchRequest, + request: ObserveRequest, response: Response>, ) -> Result { if !self.entry_request_is_current(session, &request.path, id) { return self.reject_stale(session, id); } - match decode_fetch_response(&request, &response, self.wire_limits) { - Ok(DecodedResponse::Success(object)) => { + match decode_observe_response(&request, &response, self.wire_limits) { + Ok(DecodedResponse::Success(value)) => { let maximum = self.limits.objects_per_session; let mut candidate = self.session(session)?.clone(); 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)? - .entries - .get_mut(&request.path) - .expect("loading entry") - .state = ObservationState::Error(error.clone()); + set_observe_subject_state( + self.session_mut(session)?, + &request, + id, + ObservationState::Error(error.clone()), + ); self.push_failure_diagnostic(session, id, &error)?; return Err(error); } @@ -1085,62 +1034,6 @@ impl Client { self.sessions.insert(session.clone(), candidate); Ok(Completion::Object(observation)) } - Ok(DecodedResponse::ProtocolFailure { - error, - status_mismatch, - }) => { - self.record_status_mismatch(session, id, status_mismatch)?; - 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>, - ) -> Result { - 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 { error, status_mismatch, @@ -1154,20 +1047,17 @@ impl Client { error.to_string(), )) }; - set_tree_subject_state( + set_observe_subject_state( self.session_mut(session)?, - &request.path, + &request, id, observation_state, ); Ok(Completion::ProtocolFailure(error)) } - Err(error) => self.retrieval_decode_error( - id, - session, - RetrievalSubject::Tree(request.path), - error, - ), + Err(error) => { + self.retrieval_decode_error(id, session, RetrievalSubject::Observe(request), error) + } } } @@ -1372,17 +1262,10 @@ impl Client { ) -> Result { let error = classify_response_error(error); match subject { - RetrievalSubject::Object(path) => { - self.session_mut(session)? - .entries - .get_mut(&path) - .expect("entry exists") - .state = ObservationState::Error(error.clone()); - } - RetrievalSubject::Tree(path) => { - set_tree_subject_state( + RetrievalSubject::Observe(request) => { + set_observe_subject_state( self.session_mut(session)?, - &path, + &request, id, ObservationState::Error(error.clone()), ); @@ -1407,17 +1290,10 @@ impl Client { error: ClientError, ) -> Result<(), ClientError> { match pending { - Pending::Fetch { session, request } => { - self.session_mut(&session)? - .entries - .get_mut(&request.path) - .expect("entry exists") - .state = ObservationState::Error(error); - } - Pending::Tree { session, request } => { - set_tree_subject_state( + Pending::Observe { session, request } => { + set_observe_subject_state( self.session_mut(&session)?, - &request.path, + &request, id, 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( &self, session: &SessionId, @@ -1560,8 +1426,7 @@ impl Client { #[derive(Debug)] enum RetrievalSubject { - Object(String), - Tree(String), + Observe(ObserveRequest), 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, - path: &str, + request: &ObserveRequest, revision: RequestId, 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.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.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, paths: &[String], + observed_children_paths: &[String], maximum: usize, ) -> Result<(), ClientError> { while state.entries.len() @@ -1955,7 +1822,7 @@ fn reserve_tree_metadata( remove_entry(state, &candidate); } while state.trees.len() - + paths + + observed_children_paths .iter() .filter(|path| !state.trees.contains_key(*path)) .count() @@ -1965,7 +1832,8 @@ fn reserve_tree_metadata( .trees .iter() .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()) .ok_or_else(|| { @@ -1993,45 +1861,50 @@ fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) { } } -fn apply_tree( +fn apply_observation( state: &mut Session, root_path: &str, - response: FetchTreeResponse, + response: ProtocolObjectObservation, maximum: usize, request_id: RequestId, ) -> Result<(), ClientError> { let mut nodes = Vec::new(); - flatten_tree(root_path, &response.root, &mut nodes); + flatten_observation(root_path, &response, &mut nodes); if nodes.len() > maximum { return Err(ClientError::new( ClientErrorKind::Capacity, - "tree response exceeds the local observation bound", + "observation response exceeds the local observation bound", )); } let paths: Vec = nodes.iter().map(|(path, _, _)| path.clone()).collect(); - reserve_tree_metadata(state, &paths, maximum)?; + let observed_children_paths: Vec = 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(); - for (path, _, children) in &nodes { - if let Some(previous) = state - .trees - .get(path) - .filter(|previous| previous.revision <= request_id) - { - omitted.extend( - previous - .children - .iter() - .filter(|child| !children.contains(child)) - .cloned(), - ); - } - } - for path in omitted { - mark_subtree_stale(state, &path, request_id); + let mut omitted = Vec::new(); + for (path, _, children) in &nodes { + let Some(children) = children else { + continue; + }; + if let Some(previous) = state + .trees + .get(path) + .filter(|previous| previous.revision <= request_id) + { + omitted.extend( + previous + .children + .iter() + .filter(|child| !children.contains(child)) + .cloned(), + ); } } + for path in omitted { + mark_subtree_stale(state, &path, request_id); + } for (path, object, _) in &nodes { validate_object_consistency(state, object)?; @@ -2044,6 +1917,9 @@ fn apply_tree( } } for (path, _, children) in nodes { + let Some(children) = children else { + continue; + }; let newer_owner = state .trees .get(&path) @@ -2051,13 +1927,11 @@ fn apply_tree( if newer_owner { continue; } - let is_root = path == root_path; state.trees.insert( path.clone(), TreeObservation { path, children, - truncated: is_root && response.truncated, revision: request_id, state: ObservationState::Fresh, }, @@ -2066,19 +1940,26 @@ fn apply_tree( Ok(()) } -fn flatten_tree( +fn flatten_observation( path: &str, - node: &ObjectTreeNode, - output: &mut Vec<(String, Object, Vec)>, + observation: &ProtocolObjectObservation, + output: &mut Vec<(String, Object, Option>)>, ) { - let children: Vec = node - .children - .iter() - .map(|child| child_path(path, &child.object.name)) - .collect(); - output.push((path.to_owned(), node.object.clone(), children.clone())); - for (child, child_path) in node.children.iter().zip(children) { - flatten_tree(&child_path, child, output); + let children = observation.children.as_ref().map(|children| { + children + .iter() + .map(|child| child_path(path, &child.object.name)) + .collect::>() + }); + output.push(( + 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); + } } } diff --git a/crates/wip-client/tests/adapter.rs b/crates/wip-client/tests/adapter.rs index 5ad872f..456841c 100644 --- a/crates/wip-client/tests/adapter.rs +++ b/crates/wip-client/tests/adapter.rs @@ -6,12 +6,13 @@ use wip_client::{ }; use wip_https::http::{Response, StatusCode}; 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::{ - FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, - InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError, - ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, + FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, + Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, + ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, }; fn limits() -> Limits { @@ -76,7 +77,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp ]; for body in cases { 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 .complete(request.id, response(StatusCode::OK, body)) .unwrap_err(); @@ -106,7 +107,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() { ]; for (status, body) in cases { let (mut client, session) = setup(); - let request = client.prepare_fetch(&session, "/item").unwrap(); + let request = client.prepare_observe(&session, "/item", 0).unwrap(); assert_eq!( client .complete(request.id, response(status, body)) @@ -120,13 +121,13 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() { #[test] fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() { let (mut client, session) = setup(); - let request = client.prepare_fetch(&session, "/item").unwrap(); + let request = client.prepare_observe(&session, "/item", 0).unwrap(); let error = ProtocolError { code: ProtocolErrorCode::NotFound, message: "missing".into(), }; 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; assert_eq!( @@ -146,7 +147,7 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() { #[test] fn non_success_without_valid_envelope_remains_transport_failure() { 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 .complete( request.id, @@ -176,15 +177,19 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() { #[test] fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() { let (mut client, session) = setup(); - let fetch = client.prepare_fetch(&session, "/item").unwrap(); + let observation = client.prepare_observe(&session, "/item", 0).unwrap(); client .complete( - fetch.id, - encode_fetch_response( - &FetchRequest { + observation.id, + encode_observe_response( + &ObserveRequest { path: "/item".into(), + depth: 0, + }, + &ObjectObservation { + object: object(), + children: None, }, - &object(), limits(), ) .unwrap(), diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index 98274a7..460eb3b 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -6,20 +6,54 @@ use wip_client::{ }; use wip_https::http::Response; use wip_https::{ - Limits, encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response, - encode_fetch_tree_response, encode_protocol_error_response, + EncodeHttpError, Limits, encode_call_operation_response, encode_fetch_interface_response, + encode_observe_response, encode_protocol_error_response, }; use wip_protocol::{ - CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, - FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, - ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, - ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, + CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, + InterfaceDescriptor, Object, ObjectObservation, ObserveRequest, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, + TypeExpr, Value, }; fn wire_limits() -> Limits { Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap() } +#[derive(Clone)] +struct TestObservation { + object: Object, + children: Vec, +} + +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>, EncodeHttpError> { + encode_observe_response( + request, + &protocol_observation(&value.root, request.depth), + limits, + ) +} + fn client(objects: usize, interfaces: usize, in_flight: usize, history: usize) -> Client { Client::new( 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> { - encode_fetch_response(&FetchRequest { path: path.into() }, value, wire_limits()).unwrap() +fn observation_response(path: &str, value: &Object) -> Response> { + 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) { - let request = client.prepare_fetch(session, path).unwrap(); + let request = client.prepare_observe(session, path, 0).unwrap(); client - .complete(request.id, fetch_response(path, &value)) + .complete(request.id, observation_response(path, &value)) .unwrap(); } @@ -134,14 +179,14 @@ fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() { let session = client .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); - let old = client.prepare_fetch(&session, "/item").unwrap(); - let new = client.prepare_fetch(&session, "/item").unwrap(); + let old = client.prepare_observe(&session, "/item", 0).unwrap(); + let new = client.prepare_observe(&session, "/item", 0).unwrap(); assert_eq!( client .complete( old.id, - fetch_response("/item", &object("item", None, b"old")) + observation_response("/item", &object("item", None, b"old")) ) .unwrap(), Completion::StaleResponseRejected(old.id) @@ -154,7 +199,7 @@ fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() { client .complete( new.id, - fetch_response("/item", &object("item", None, b"new")), + observation_response("/item", &object("item", None, b"new")), ) .unwrap(); assert_eq!( @@ -182,11 +227,11 @@ fn normalized_object_cache_rejects_conflicts_and_evicts_to_its_bound() { 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"); changed.description = Some("contradiction".into()); let error = client - .complete(conflict.id, fetch_response("/b/shared", &changed)) + .complete(conflict.id, observation_response("/b/shared", &changed)) .unwrap_err(); assert_eq!(error.kind, ClientErrorKind::InvalidResponse); @@ -220,34 +265,33 @@ fn normalized_aliases_preserve_newer_request_ownership() { "/b/shared", object("shared", Some("shared-ref"), b"initial"), ); - let request = FetchTreeRequest { + let request = ObserveRequest { path: "/a".into(), depth: 1, - limit: 4, }; - let older_tree_response = || FetchTreeResponse { - root: ObjectTreeNode { + let older_tree_response = || TestObservationResponse { + root: TestObservation { object: object("a", None, b"root"), - children: vec![ObjectTreeNode { + children: vec![TestObservation { object: object("shared", Some("shared-ref"), b"older"), children: vec![], }], }, - truncated: false, }; - let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap(); - let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap(); + let older_tree = client.prepare_observe(&session, "/a", 1).unwrap(); + let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap(); client .complete( 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(); client .complete( 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(); assert_eq!( @@ -259,12 +303,13 @@ fn normalized_aliases_preserve_newer_request_ownership() { Some(b"newer".to_vec()) ); - let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap(); - let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap(); + let older_tree = client.prepare_observe(&session, "/a", 1).unwrap(); + let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap(); client .complete( 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(); assert!(matches!( @@ -278,7 +323,7 @@ fn normalized_aliases_preserve_newer_request_ownership() { client .complete( newer_alias.id, - fetch_response( + observation_response( "/b/shared", &object("shared", Some("shared-ref"), b"newest"), ), @@ -289,12 +334,12 @@ fn normalized_aliases_preserve_newer_request_ownership() { Some(b"newest".to_vec()) ); - let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap(); - let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap(); + let older_tree = client.prepare_observe(&session, "/a", 1).unwrap(); + let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap(); client .complete( newer_alias.id, - fetch_response( + observation_response( "/b/shared", &object("shared", Some("shared-ref"), b"newest"), ), @@ -305,17 +350,16 @@ fn normalized_aliases_preserve_newer_request_ownership() { let error = client .complete( older_tree.id, - encode_fetch_tree_response( + encode_test_observation_response( &request, - &FetchTreeResponse { - root: ObjectTreeNode { + &TestObservationResponse { + root: TestObservation { object: object("a", None, b"root"), - children: vec![ObjectTreeNode { + children: vec![TestObservation { object: contradictory, children: vec![], }], }, - truncated: false, }, wire_limits(), ) @@ -334,35 +378,33 @@ fn normalized_aliases_preserve_newer_request_ownership() { "/child", object("child", Some("child-ref"), b"stable"), ); - let older_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let newer_child = client.prepare_fetch(&session, "/child").unwrap(); + let older_tree = client.prepare_observe(&session, "/", 1).unwrap(); + let newer_child = client.prepare_observe(&session, "/child", 0).unwrap(); client .complete( newer_child.id, - fetch_response("/child", &object("child", Some("child-ref"), b"stable")), + observation_response("/child", &object("child", Some("child-ref"), b"stable")), ) .unwrap(); let mut contradictory = object("child", Some("child-ref"), b"stable"); contradictory.description = Some("same-path contradiction".into()); - let root_request = FetchTreeRequest { + let root_request = ObserveRequest { path: "/".into(), depth: 1, - limit: 4, }; let error = client .complete( older_tree.id, - encode_fetch_tree_response( + encode_test_observation_response( &root_request, - &FetchTreeResponse { - root: ObjectTreeNode { + &TestObservationResponse { + root: TestObservation { object: object("", None, b"root"), - children: vec![ObjectTreeNode { + children: vec![TestObservation { object: contradictory, children: vec![], }], }, - truncated: false, }, wire_limits(), ) @@ -377,61 +419,151 @@ fn normalized_aliases_preserve_newer_request_ownership() { } #[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 session = client .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); assert!(client.tree(&session, "/").is_none()); - let request = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let logical_request = FetchTreeRequest { + let request = client.prepare_observe(&session, "/", 1).unwrap(); + let logical_request = ObserveRequest { path: "/".into(), depth: 1, - limit: 4, }; - let tree = FetchTreeResponse { - root: ObjectTreeNode { + let tree = TestObservationResponse { + root: TestObservation { object: object("", None, b"root"), - children: vec![ObjectTreeNode { + children: vec![TestObservation { object: object("items", None, b"items"), children: vec![], }], }, - truncated: true, }; client .complete( request.id, - encode_fetch_tree_response(&logical_request, &tree, wire_limits()).unwrap(), + encode_test_observation_response(&logical_request, &tree, wire_limits()).unwrap(), ) .unwrap(); assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]); - assert!(client.tree(&session, "/").unwrap().truncated); assert!(client.object(&session, "/items").is_some()); - let refresh = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let refreshed = FetchTreeResponse { - root: ObjectTreeNode { + let refresh = client.prepare_observe(&session, "/", 1).unwrap(); + let refreshed = TestObservationResponse { + root: TestObservation { object: object("", None, b"root-2"), children: vec![], }, - truncated: false, }; client .complete( refresh.id, - encode_fetch_tree_response(&logical_request, &refreshed, wire_limits()).unwrap(), + encode_test_observation_response(&logical_request, &refreshed, wire_limits()).unwrap(), ) .unwrap(); assert!(client.tree(&session, "/").unwrap().children.is_empty()); - assert!(!client.tree(&session, "/").unwrap().truncated); assert_eq!( client.object(&session, "/items").unwrap().state, 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] fn call_context_fixes_membership_validators_descriptor_and_result_validation() { 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 .prepare_call(&session, "/item", "math", "calculate", args(7)) .unwrap(); @@ -723,7 +855,7 @@ fn older_retrieval_cannot_overwrite_success_validator() { client .complete( older_alias.id, - fetch_response( + observation_response( "/alias/item", &object("item", Some("shared-ref"), b"object-v1"), ), @@ -746,7 +878,7 @@ fn older_retrieval_cannot_overwrite_success_validator() { "/item", 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 .prepare_call(&session, "/item", "math", "calculate", args(8)) .unwrap(); @@ -769,7 +901,7 @@ fn older_retrieval_cannot_overwrite_success_validator() { client .complete( older_alias.id, - fetch_response( + observation_response( "/second/item", &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(); assert_eq!( - client.prepare_fetch(&session, "/other").unwrap_err().kind, + client + .prepare_observe(&session, "/other", 0) + .unwrap_err() + .kind, ClientErrorKind::Capacity ); let unknown = ProtocolError { @@ -980,57 +1115,55 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() { let session = client .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); - let tree_request = FetchTreeRequest { + let tree_request = ObserveRequest { path: "/".into(), depth: 1, - limit: 4, }; - let tree_value = FetchTreeResponse { - root: ObjectTreeNode { + let tree_value = TestObservationResponse { + root: TestObservation { object: object("", None, b"root"), - children: vec![ObjectTreeNode { + children: vec![TestObservation { object: object("child", None, b"tree-child"), children: vec![], }], }, - truncated: false, }; - let old_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let newer_fetch = client.prepare_fetch(&session, "/child").unwrap(); + let old_tree = client.prepare_observe(&session, "/", 1).unwrap(); + let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap(); client .complete( 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(); assert!(matches!( client.object(&session, "/child").unwrap().state, - ObservationState::Loading(id) if id == newer_fetch.id + ObservationState::Loading(id) if id == newer_observation.id )); client .complete( - newer_fetch.id, - fetch_response("/child", &object("child", None, b"newer-fetch")), + newer_observation.id, + observation_response("/child", &object("child", None, b"newer-observation")), ) .unwrap(); assert_eq!( 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 completed_newer_fetch = client.prepare_fetch(&session, "/child").unwrap(); + let older_tree = client.prepare_observe(&session, "/", 1).unwrap(); + let completed_newer_observation = client.prepare_observe(&session, "/child", 0).unwrap(); client .complete( - completed_newer_fetch.id, - fetch_response("/child", &object("child", None, b"completed-newer")), + completed_newer_observation.id, + observation_response("/child", &object("child", None, b"completed-newer")), ) .unwrap(); client .complete( 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(); assert_eq!( @@ -1038,25 +1171,24 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() { Some(b"completed-newer".to_vec()) ); - let older_omitting_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let newer_fetch = client.prepare_fetch(&session, "/child").unwrap(); + let older_omitting_tree = client.prepare_observe(&session, "/", 1).unwrap(); + let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap(); client .complete( - newer_fetch.id, - fetch_response("/child", &object("child", None, b"newer-than-omission")), + newer_observation.id, + observation_response("/child", &object("child", None, b"newer-than-omission")), ) .unwrap(); client .complete( older_omitting_tree.id, - encode_fetch_tree_response( + encode_test_observation_response( &tree_request, - &FetchTreeResponse { - root: ObjectTreeNode { + &TestObservationResponse { + root: TestObservation { object: object("", None, b"older-root"), children: vec![], }, - truncated: false, }, wire_limits(), ) @@ -1067,12 +1199,12 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() { assert_eq!(child.state, ObservationState::Fresh); assert_eq!(child.validator, Some(b"newer-than-omission".to_vec())); - let old_tree = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let newer_child_tree = client.prepare_tree(&session, "/child", 0, 1).unwrap(); + let old_tree = client.prepare_observe(&session, "/", 1).unwrap(); + let newer_child_tree = client.prepare_observe(&session, "/child", 1).unwrap(); client .complete( 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(); assert!(matches!( @@ -1088,22 +1220,20 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() { child_observation.validator, Some(b"newer-than-omission".to_vec()) ); - let child_request = FetchTreeRequest { + let child_request = ObserveRequest { path: "/child".into(), - depth: 0, - limit: 1, + depth: 1, }; - let child_value = FetchTreeResponse { - root: ObjectTreeNode { + let child_value = TestObservationResponse { + root: TestObservation { object: object("child", None, b"newer-tree"), children: vec![], }, - truncated: false, }; client .complete( 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(); assert_eq!( @@ -1111,19 +1241,18 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() { ObservationState::Fresh ); - let older_parent = client.prepare_tree(&session, "/", 1, 4).unwrap(); - let newer_child = client.prepare_tree(&session, "/child", 0, 1).unwrap(); + let older_parent = client.prepare_observe(&session, "/", 1).unwrap(); + let newer_child = client.prepare_observe(&session, "/child", 1).unwrap(); client .complete( newer_child.id, - encode_fetch_tree_response( + encode_test_observation_response( &child_request, - &FetchTreeResponse { - root: ObjectTreeNode { + &TestObservationResponse { + root: TestObservation { object: object("child", None, b"completed-newer-tree"), children: vec![], }, - truncated: false, }, wire_limits(), ) @@ -1133,7 +1262,7 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() { client .complete( 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(); assert_eq!( @@ -1152,8 +1281,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() { let session = client .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); - let old = client.prepare_fetch(&session, "/item").unwrap(); - let newer = client.prepare_fetch(&session, "/item").unwrap(); + let old = client.prepare_observe(&session, "/item", 0).unwrap(); + let newer = client.prepare_observe(&session, "/item", 0).unwrap(); assert_eq!( client .fail_transport(old.id, "old connection failed") @@ -1167,12 +1296,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() { client .complete( newer.id, - fetch_response("/item", &object("item", None, b"new")), + observation_response("/item", &object("item", None, b"new")), ) .unwrap(); - let old = client.prepare_tree(&session, "/", 0, 1).unwrap(); - let newer_tree = client.prepare_tree(&session, "/", 0, 1).unwrap(); + let old = client.prepare_observe(&session, "/", 1).unwrap(); + let newer_tree = client.prepare_observe(&session, "/", 1).unwrap(); assert_eq!( client .fail_transport(old.id, "old tree connection failed") @@ -1186,18 +1315,16 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() { client .complete( newer_tree.id, - encode_fetch_tree_response( - &FetchTreeRequest { + encode_test_observation_response( + &ObserveRequest { path: "/".into(), - depth: 0, - limit: 1, + depth: 1, }, - &FetchTreeResponse { - root: ObjectTreeNode { + &TestObservationResponse { + root: TestObservation { object: object("", None, b"root"), children: vec![], }, - truncated: false, }, wire_limits(), ) @@ -1283,9 +1410,12 @@ fn entry_eviction_never_discards_a_loading_tree_subject() { .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); 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!( - client.prepare_fetch(&session, "/other").unwrap_err().kind, + client + .prepare_observe(&session, "/other", 0) + .unwrap_err() + .kind, ClientErrorKind::Capacity ); assert!(matches!( @@ -1295,18 +1425,16 @@ fn entry_eviction_never_discards_a_loading_tree_subject() { client .complete( tree.id, - encode_fetch_tree_response( - &FetchTreeRequest { + encode_test_observation_response( + &ObserveRequest { path: "/".into(), - depth: 0, - limit: 1, + depth: 1, }, - &FetchTreeResponse { - root: ObjectTreeNode { + &TestObservationResponse { + root: TestObservation { object: object("", None, b"tree"), children: vec![], }, - truncated: false, }, wire_limits(), ) @@ -1326,7 +1454,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() { .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); 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!( client .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()); 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!( client .fail_transport(request.id, "tree failed") @@ -1359,16 +1487,19 @@ fn in_flight_and_history_are_bounded() { let session = client .open_session("https://example.test/wip", SecurityContext::new("subject")) .unwrap(); - let first = client.prepare_fetch(&session, "/one").unwrap(); - let error = client.prepare_fetch(&session, "/two").unwrap_err(); + let first = client.prepare_observe(&session, "/one", 0).unwrap(); + let error = client.prepare_observe(&session, "/two", 0).unwrap_err(); assert_eq!(error.kind, ClientErrorKind::Capacity); client - .complete(first.id, fetch_response("/one", &object("one", None, b"1"))) + .complete( + first.id, + observation_response("/one", &object("one", None, b"1")), + ) .unwrap(); // Diagnostic history evicts oldest entries at the configured bound. - let stale_old = client.prepare_fetch(&session, "/one").unwrap(); - let stale_new = client.prepare_fetch(&session, "/one").unwrap_err(); + let stale_old = client.prepare_observe(&session, "/one", 0).unwrap(); + let stale_new = client.prepare_observe(&session, "/one", 0).unwrap_err(); assert_eq!(stale_new.kind, ClientErrorKind::Capacity); client .complete( diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index 00f151a..894dad8 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -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 fields, never `ETag` or `If-Match` headers. -Fetch root: +Observe only the root Object: ```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' \ - --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 -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' \ - --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: ```console curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \ -H 'Content-Type: application/json' -H 'Accept: application/json' \ - --data '{"interface":""}' + --data '{"interface":""}' ``` 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 -standard `POST /wip/v1/batch` route. `call_operation` is never batchable. +`observe` and `fetch_interface` can also be combined through the standard +`POST /wip/v1/batch` route. `call_operation` is never batchable. ## 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 concurrency into active userspace service. Advisory file-lock acquisition uses 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 -requested entries. Once a limit or depth boundary is reached it reads only far -enough to -prove whether an additional visible child exists, so `truncated` is true only -when something was actually omitted. I/O failure and cancellation remain errors, -not clean exhaustion. +contended lock to stall a worker. Object observation recursively opens only the +requested depth. A successful response is complete through that depth; if the +configured projected-node budget would be exceeded, the interaction fails with +`ResourceLimitExceeded` instead of returning a partial observation. I/O failure +and cancellation remain errors. 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 diff --git a/crates/wip-fs-server/src/lib.rs b/crates/wip-fs-server/src/lib.rs index c76b93e..8f0244d 100644 --- a/crates/wip-fs-server/src/lib.rs +++ b/crates/wip-fs-server/src/lib.rs @@ -51,10 +51,10 @@ pub struct ResourceLimits { pub max_file_read_bytes: usize, /// Maximum bytes accepted by one file replacement. pub max_file_write_bytes: usize, - /// Maximum accepted `fetch_tree` depth. - pub max_tree_depth: u32, - /// Maximum accepted `fetch_tree` node count, including its root. - pub max_tree_nodes: u32, + /// Maximum accepted `observe` depth. + pub max_observation_depth: u32, + /// Maximum accepted `observe` node count, including its root. + pub max_observation_nodes: u32, /// Maximum simultaneously pinned projected filesystem handles. pub max_open_handles: usize, /// 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"), max_file_read_bytes: 1024 * 1024, max_file_write_bytes: 1024 * 1024, - max_tree_depth: 32, - max_tree_nodes: 1024, + max_observation_depth: 32, + max_observation_nodes: 1024, max_open_handles: 2048, max_http_header_bytes: 16 * 1024, max_concurrent_requests: 32, @@ -125,7 +125,7 @@ impl ServerConfig { } if self.limits.max_file_read_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_http_header_bytes == 0 || self.limits.max_concurrent_requests == 0 @@ -133,7 +133,7 @@ impl ServerConfig { { 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); } 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" } 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", }) } diff --git a/crates/wip-fs-server/src/projection.rs b/crates/wip-fs-server/src/projection.rs index 22dec17..adb498d 100644 --- a/crates/wip-fs-server/src/projection.rs +++ b/crates/wip-fs-server/src/projection.rs @@ -1,4 +1,3 @@ -use std::collections::VecDeque; use std::ffi::CStr; use std::mem::MaybeUninit; use std::os::fd::{AsRawFd, OwnedFd}; @@ -17,8 +16,8 @@ use wip_host::{ }; use wip_interface as wip; use wip_protocol::{ - FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, FetchTreeResponse, Object, - ObjectTreeNode, ProtocolError, ProtocolErrorCode, + FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest, + ObserveResponse, ProtocolError, ProtocolErrorCode, }; use crate::interfaces::{ @@ -223,37 +222,60 @@ impl Projection { .protocol_response() } - pub(crate) fn fetch_tree( + pub(crate) fn observe( &self, context: &ExecutionContext, - request: &FetchTreeRequest, - ) -> Result { + request: &ObserveRequest, + ) -> Result { request.validate().map_err(|_| ProtocolError { 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()); } let root = self .resolve(context, &request.path) .map_err(ProjectionError::protocol)?; - let root_object = self - .publication(root.clone(), context.clone()) - .protocol_object(); - let mut objects = vec![root_object]; - let mut children: Vec> = vec![Vec::new()]; - let mut queue = VecDeque::from([(0_usize, root, 0_u32)]); - let mut truncated = false; + let mut node_count = 0_u32; + let response = self.observe_entry(context, root, 0, request.depth, &mut node_count)?; + request + .validate_response(&response) + .map_err(|_| ProtocolError { + code: ProtocolErrorCode::Internal, + message: "Host produced an invalid observation response".into(), + })?; + Ok(response) + } - while let Some((parent_index, parent, depth)) = queue.pop_front() { - 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 { + 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)?; let mut buffer = [MaybeUninit::::uninit(); 8192]; let mut directory = RawDir::new(directory_fd, &mut buffer); @@ -271,50 +293,25 @@ impl Projection { if name.contains('/') || name.is_empty() || name == "." || name == ".." { continue; } - let child = match self.open_child(context, parent.fd(), name) { + let child = match self.open_child(context, entry.fd(), name) { Ok(child) => child, Err(ProjectionError::NotFound) => continue, Err(error) => return Err(error.protocol()), }; - if depth == request.depth || objects.len() == request.limit as usize { - truncated = true; - break; - } - let child_index = objects.len(); - objects.push( - 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; + children.push(self.observe_entry( + context, + child, + depth + 1, + maximum_depth, + node_count, + )?); } } - fn build(index: usize, objects: &[Object], children: &[Vec]) -> ObjectTreeNode { - ObjectTreeNode { - object: objects[index].clone(), - 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) + Ok(ObjectObservation { + object, + children: Some(children), + }) } fn publication( @@ -809,7 +806,7 @@ mod tests { use wip_host::{ CallErrorKind, CallRequest, ExecutionContext, InterfaceTarget, ObjectProvider, Target, }; - use wip_protocol::{FetchTreeRequest, ProtocolErrorCode, Value}; + use wip_protocol::{ObserveRequest, ProtocolErrorCode, Value}; use super::{FailureInjection, FlockOperation, Projection}; use crate::{AccessMode, ResourceLimits}; @@ -850,23 +847,29 @@ mod tests { .expect("create symlink"); 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.description.as_deref(), Some("Filesystem directory")); assert!(root.interfaces.is_empty()); 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!(file.validator.is_some()); 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()); #[cfg(unix)] { - let link = projection.fetch("/link").expect("fetch link itself"); + let link = projection + .observe_object("/link") + .expect("observe link itself"); assert_eq!( link.description.as_deref(), Some("Symbolic link (not followed)") @@ -874,7 +877,7 @@ mod tests { assert_eq!(link.interfaces.len(), 1); assert_eq!( projection - .fetch("/link/shadow") + .observe_object("/link/shadow") .expect_err("must not follow") .kind(), CallErrorKind::NotFound @@ -892,18 +895,21 @@ mod tests { } assert_eq!( - projection.fetch("/missing").expect_err("missing").kind(), + projection + .observe_object("/missing") + .expect_err("missing") + .kind(), CallErrorKind::NotFound ); } #[test] - fn reads_are_bounded_and_tree_truncation_is_exact() { + fn reads_are_bounded_and_observation_is_complete() { let temporary = tempdir().expect("temporary root"); fs::write(temporary.path().join("a"), b"abcdef").expect("write file"); fs::write(temporary.path().join("b"), b"b").expect("write file"); 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( &projection, "/a", @@ -919,30 +925,17 @@ mod tests { assert_eq!(result.result, Value::Bytes(b"cde".to_vec())); let complete = projection - .fetch_tree( + .observe( &ExecutionContext::new(), - &FetchTreeRequest { + &ObserveRequest { path: "/".into(), depth: 1, - limit: 3, }, ) - .expect("complete tree"); - assert!(!complete.truncated); - assert_eq!(complete.root.children.len(), 2); - - 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); + .expect("complete observation"); + let children = complete.children.as_ref().expect("root children observed"); + assert_eq!(children.len(), 2); + assert!(children.iter().all(|child| child.children.is_none())); } #[test] @@ -951,7 +944,7 @@ mod tests { let path = temporary.path().join("file"); fs::write(&path, b"old").expect("write file"); 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); let write_interface = observed.interfaces[1].clone(); @@ -981,7 +974,9 @@ mod tests { assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch); 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( &projection, "/file", @@ -1001,7 +996,7 @@ mod tests { let path = temporary.path().join("file"); fs::write(&path, b"old").expect("write file"); 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 = || { call( &projection, @@ -1029,25 +1024,23 @@ mod tests { } #[test] - fn large_directory_stops_after_proving_truncation() { + fn large_directory_fails_without_partial_observation() { let temporary = tempdir().expect("temporary root"); for index in 0..2_000 { fs::write(temporary.path().join(format!("entry-{index}")), b"x") .expect("write directory entry"); } let projection = projection(temporary.path(), AccessMode::ReadOnly); - let tree = projection - .fetch_tree( + let error = projection + .observe( &ExecutionContext::new(), - &FetchTreeRequest { + &ObserveRequest { path: "/".into(), depth: 1, - limit: 2, }, ) - .expect("bounded tree"); - assert!(tree.truncated); - assert_eq!(tree.root.children.len(), 1); + .expect_err("complete observation exceeds node budget"); + assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded); } #[cfg(unix)] @@ -1065,22 +1058,22 @@ mod tests { let projection = projection(temporary.path(), AccessMode::ReadOnly); let tree = projection - .fetch_tree( + .observe( &ExecutionContext::new(), - &FetchTreeRequest { + &ObserveRequest { path: "/".into(), depth: 1, - limit: 10, }, ) .expect("list root"); - assert_eq!(tree.root.children.len(), 1); - assert_eq!(tree.root.children[0].object.name, "socket"); + let children = tree.children.as_ref().expect("root children observed"); + assert_eq!(children.len(), 1); + assert_eq!(children[0].object.name, "socket"); assert_eq!( - tree.root.children[0].object.description.as_deref(), + children[0].object.description.as_deref(), Some("Unsupported filesystem entry") ); - assert!(tree.root.children[0].object.interfaces.is_empty()); + assert!(children[0].object.interfaces.is_empty()); } #[test] @@ -1091,12 +1084,11 @@ mod tests { let cancellation = wip_host::CancellationToken::new(); cancellation.cancel(); let error = projection - .fetch_tree( + .observe( &ExecutionContext::new().with_cancellation(cancellation), - &FetchTreeRequest { + &ObserveRequest { path: "/".into(), depth: 1, - limit: 2, }, ) .expect_err("cancelled traversal"); @@ -1108,7 +1100,7 @@ mod tests { let temporary = tempdir().expect("temporary root"); let projection = projection(temporary.path(), AccessMode::ReadOnly); let error = projection - .fetch("/bad\0name") + .observe_object("/bad\0name") .expect_err("NUL cannot name a backend entry"); assert_eq!(error.kind(), CallErrorKind::NotFound); } @@ -1144,7 +1136,7 @@ mod tests { }; let projection = Projection::open(temporary.path(), AccessMode::ReadOnly, limits) .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 error = call( &projection, diff --git a/crates/wip-fs-server/src/server.rs b/crates/wip-fs-server/src/server.rs index dbb581a..3e16188 100644 --- a/crates/wip-fs-server/src/server.rs +++ b/crates/wip-fs-server/src/server.rs @@ -10,12 +10,10 @@ use wip_host::{ExecutionContext, ObjectProvider}; use wip_https::{ BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError, Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request, - decode_call_operation_metadata, decode_fetch_interface_request, decode_fetch_request, - decode_fetch_tree_request, encode_batch_error_response, - encode_batch_resource_limit_error_response, encode_batch_response, - encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response, - encode_fetch_tree_response, encode_protocol_error_response, - encode_resource_limit_error_response, validate_http_request, + decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request, + encode_batch_error_response, encode_batch_resource_limit_error_response, encode_batch_response, + encode_call_operation_response, encode_fetch_interface_response, encode_observe_response, + encode_protocol_error_response, encode_resource_limit_error_response, validate_http_request, }; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; @@ -78,10 +76,9 @@ impl WipFsServer { } let context = ExecutionContext::new() .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 { - Route::Fetch => self.fetch(request.body(), &context), - Route::FetchTree => self.fetch_tree(request.body(), &context), + Route::Observe => self.observe(request.body(), &context), Route::FetchInterface => self.fetch_interface(request.body()), Route::CallOperation => self.call_operation(request.body(), &context), Route::Batch => self.batch(request.body(), &context), @@ -110,8 +107,7 @@ impl WipFsServer { fn route(&self, path: &str) -> Option { [ - Route::Fetch, - Route::FetchTree, + Route::Observe, Route::FetchInterface, Route::CallOperation, Route::Batch, @@ -120,33 +116,17 @@ impl WipFsServer { .find(|route| self.endpoint.route(*route).path() == path) } - fn fetch(&self, body: &[u8], context: &ExecutionContext) -> Response> { - let request = match decode_fetch_request(body, self.config.limits.wire) { + fn observe(&self, body: &[u8], context: &ExecutionContext) -> Response> { + let request = match decode_observe_request(body, self.config.limits.wire) { Ok(request) => request, - Err(error) => return self.codec_error(ProtocolInteraction::Fetch, error), + Err(error) => return self.codec_error(ProtocolInteraction::Observe, error), }; - match self - .projection - .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> { - 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) + match self.projection.observe(context, &request) { + Ok(tree) => encode_observe_response(&request, &tree, self.config.limits.wire) .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() .map(|item| { let outcome = match &item.request { - BatchRequestKind::Fetch(request) => self + BatchRequestKind::Observe(request) => self .projection - .fetch_with_context(context, &request.path) - .map(BatchSuccess::Fetch) - .map_err(|error| error.protocol_error()), - BatchRequestKind::FetchTree(request) => self - .projection - .fetch_tree(context, request) - .map(BatchSuccess::FetchTree), + .observe(context, request) + .map(BatchSuccess::Observe), BatchRequestKind::FetchInterface(request) => self .projection .fetch_interface(request) @@ -674,11 +649,10 @@ fn write_all_with_deadline( fn interaction(route: Route) -> ProtocolInteraction { match route { - Route::Fetch => ProtocolInteraction::Fetch, - Route::FetchTree => ProtocolInteraction::FetchTree, + Route::Observe => ProtocolInteraction::Observe, Route::FetchInterface => ProtocolInteraction::FetchInterface, Route::CallOperation => ProtocolInteraction::CallOperation, - Route::Batch => ProtocolInteraction::Fetch, + Route::Batch => ProtocolInteraction::Observe, } } diff --git a/crates/wip-fs-server/tests/end_to_end.rs b/crates/wip-fs-server/tests/end_to_end.rs index 3de1f6b..e4f2433 100644 --- a/crates/wip-fs-server/tests/end_to_end.rs +++ b/crates/wip-fs-server/tests/end_to_end.rs @@ -11,17 +11,17 @@ use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer}; use wip_https::{ BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse, Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response, - decode_call_operation_response, decode_fetch_interface_response, decode_fetch_response, - decode_fetch_tree_response, encode_batch_request, encode_call_operation_request, - encode_fetch_interface_request, encode_fetch_request, encode_fetch_tree_request, + decode_call_operation_response, decode_fetch_interface_response, decode_observe_response, + encode_batch_request, encode_call_operation_request, encode_fetch_interface_request, + encode_observe_request, }; use wip_protocol::{ - CallOperationRequest, FetchInterfaceRequest, FetchRequest, FetchTreeRequest, InterfaceTarget, + CallOperationRequest, FetchInterfaceRequest, InterfaceTarget, ObserveRequest, ProtocolErrorCode, Target, Value, }; #[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"); 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"); @@ -46,32 +46,39 @@ fn real_listener_serves_four_core_interactions_batch_and_write() { let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port())) .expect("client endpoint"); - let file_request = FetchRequest { + let file_request = ObserveRequest { path: "/hello.txt".into(), + depth: 0, }; let response = exchange( address, - encode_fetch_request(&endpoint, &file_request, limits).expect("encode fetch"), + encode_observe_request(&endpoint, &file_request, limits).expect("encode observation"), ); - let file = - success(decode_fetch_response(&file_request, &response, limits).expect("decode fetch")); + let file = success( + decode_observe_response(&file_request, &response, limits).expect("decode observation"), + ) + .object; assert_eq!(file.name, "hello.txt"); assert_eq!(file.interfaces.len(), 2); assert!(file.validator.is_some()); - let tree_request = FetchTreeRequest { + let tree_request = ObserveRequest { path: "/".into(), depth: 1, - limit: 10, }; let response = exchange( address, - encode_fetch_tree_request(&endpoint, &tree_request, limits).expect("encode tree"), + encode_observe_request(&endpoint, &tree_request, limits).expect("encode tree"), ); let tree = - success(decode_fetch_tree_response(&tree_request, &response, limits).expect("decode tree")); - assert!(!tree.truncated); - assert_eq!(tree.root.children.len(), 2); + success(decode_observe_response(&tree_request, &response, limits).expect("decode tree")); + assert_eq!( + tree.children + .as_ref() + .expect("root children observed") + .len(), + 2 + ); let read_interface_request = FetchInterfaceRequest { interface: file.interfaces[0].clone(), @@ -122,14 +129,16 @@ fn real_listener_serves_four_core_interactions_batch_and_write() { requests: vec![ BatchRequestItem { id: "root".into(), - request: BatchRequestKind::Fetch(FetchRequest { path: "/".into() }), + request: BatchRequestKind::Observe(ObserveRequest { + path: "/".into(), + depth: 0, + }), }, BatchRequestItem { id: "tree".into(), - request: BatchRequestKind::FetchTree(FetchTreeRequest { + request: BatchRequestKind::Observe(ObserveRequest { path: "/nested".into(), depth: 1, - limit: 4, }), }, BatchRequestItem { @@ -230,7 +239,7 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() { .expect("set stalled read timeout"); write!( 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"); 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())) .expect("client endpoint"); - let fetch = FetchRequest { path: "/".into() }; + let observe = ObserveRequest { + path: "/".into(), + depth: 0, + }; let started = Instant::now(); let response = exchange( 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!( started.elapsed() < Duration::from_secs(1), "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 server = WipFsServer::new(config).expect("build server"); 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"); assert!(matches!( logical.requests[1].request, @@ -296,7 +309,7 @@ fn batch_keeps_known_malformed_arguments_item_local() { ); assert!(matches!( batch.responses[0].outcome, - Ok(BatchSuccess::Fetch(_)) + Ok(BatchSuccess::Observe(_)) )); 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 server = WipFsServer::new(config).expect("build server"); let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); - let request = FetchRequest { + let request = ObserveRequest { path: "/bad\0name".into(), + depth: 0, }; - let response = server - .handle_http(encode_fetch_request(&endpoint, &request, limits).expect("encode fetch")); + let response = server.handle_http( + encode_observe_request(&endpoint, &request, limits).expect("encode observation"), + ); 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, .. } => { 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 endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); - let fetch_request = FetchRequest { + let observe_request = ObserveRequest { path: "/large".into(), + depth: 0, }; 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 = - success(decode_fetch_response(&fetch_request, &response, limits).expect("decode fetch")); + let file = success( + decode_observe_response(&observe_request, &response, limits).expect("decode observation"), + ) + .object; let interface_request = FetchInterfaceRequest { interface: file.interfaces[0].clone(), }; @@ -405,11 +423,11 @@ fn assert_batch_successes(batch: &BatchResponse) { assert_eq!(batch.responses.len(), 3); assert!(matches!( batch.responses[0].outcome, - Ok(BatchSuccess::Fetch(_)) + Ok(BatchSuccess::Observe(_)) )); assert!(matches!( batch.responses[1].outcome, - Ok(BatchSuccess::FetchTree(_)) + Ok(BatchSuccess::Observe(_)) )); assert!(matches!( batch.responses[2].outcome, diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index e440785..0fad2bc 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -352,7 +352,7 @@ impl InterfaceRegistry { /// Observes a registered interface by its host-side reference. #[must_use] - pub fn observe(&self, reference: &InterfaceRef) -> Option { + pub fn observe_object(&self, reference: &InterfaceRef) -> Option { self.artifacts.get(reference).map(Self::observation) } @@ -863,13 +863,13 @@ pub trait ObjectProvider { true } - /// Fetches the canonical object projection currently published at `path`. - fn fetch(&self, path: &str) -> Result { - self.fetch_with_context(&ExecutionContext::default(), path) + /// Observes the canonical object projection currently published at `path`. + fn observe_object(&self, path: &str) -> Result { + self.observe_object_with_context(&ExecutionContext::default(), path) } - /// Fetches an object while propagating runtime execution controls. - fn fetch_with_context( + /// Observes an object while propagating runtime execution controls. + fn observe_object_with_context( &self, context: &ExecutionContext, path: &str, @@ -882,7 +882,7 @@ pub trait ObjectProvider { .map_err(|_| { CallError::new( CallErrorKind::Internal, - "provider panicked while fetching an object", + "provider panicked while observing an object", ) })?; let object = match resolved { diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index 33bbb78..7917fa7 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -467,7 +467,7 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor() #[test] fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() { 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.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("/document")); - let empty = provider.fetch("/empty").unwrap(); + let empty = provider.observe_object("/empty").unwrap(); assert!(empty.interfaces.is_empty()); assert_eq!(empty.r#ref, 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() { let (_, provider, _) = registry_and_provider(); - let left = provider.fetch("/left/document").unwrap(); - let right = provider.fetch("/right/document").unwrap(); + let left = provider.observe_object("/left/document").unwrap(); + let right = provider.observe_object("/right/document").unwrap(); assert_eq!(left, right); assert_eq!(left.r#ref.as_deref(), Some("shared-ref")); - let first = provider.fetch("/switch").unwrap(); - let second = provider.fetch("/switch").unwrap(); + let first = provider.observe_object("/switch").unwrap(); + let second = provider.observe_object("/switch").unwrap(); assert_ne!(first.r#ref, second.r#ref); 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_failure( - provider.fetch("/invalid/").unwrap_err(), + provider.observe_object("/invalid/").unwrap_err(), CallErrorKind::InvalidRequest, ProtocolErrorCode::InvalidRequest, ); @@ -849,7 +849,7 @@ fn invalid_host_responses_and_uncertain_operation_failures_use_canonical_codes() let (_, provider, _) = registry_and_provider(); assert_failure( - provider.fetch("/invalid-object").unwrap_err(), + provider.observe_object("/invalid-object").unwrap_err(), CallErrorKind::Internal, ProtocolErrorCode::Internal, ); diff --git a/crates/wip-https/src/batch.rs b/crates/wip-https/src/batch.rs index f22d89c..c6f2c86 100644 --- a/crates/wip-https/src/batch.rs +++ b/crates/wip-https/src/batch.rs @@ -4,8 +4,8 @@ use http::header::{ACCEPT, CONTENT_TYPE}; use http::{Method, Request, Response, StatusCode}; use serde_json::{Map, Value as Json}; use wip_protocol::{ - FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, - FetchTreeResponse, INTERFACE_FORMAT_V1, ProtocolError, ProtocolErrorCode, ProtocolInteraction, + FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, ObserveRequest, + ObserveResponse, ProtocolError, ProtocolErrorCode, ProtocolInteraction, }; use crate::response::{self, DecodedResponse}; @@ -17,10 +17,8 @@ use crate::{ /// A retrieval method accepted by the standard v1 batch endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BatchMethod { - /// Fetch one object. - Fetch, - /// Fetch one bounded tree. - FetchTree, + /// Observe one object and its indexable descendants. + Observe, /// Fetch one interface descriptor. FetchInterface, } @@ -28,16 +26,14 @@ pub enum BatchMethod { impl BatchMethod { fn name(self) -> &'static str { match self { - Self::Fetch => "fetch", - Self::FetchTree => "fetch_tree", + Self::Observe => "observe", Self::FetchInterface => "fetch_interface", } } fn interaction(self) -> ProtocolInteraction { match self { - Self::Fetch => ProtocolInteraction::Fetch, - Self::FetchTree => ProtocolInteraction::FetchTree, + Self::Observe => ProtocolInteraction::Observe, Self::FetchInterface => ProtocolInteraction::FetchInterface, } } @@ -46,10 +42,8 @@ impl BatchMethod { /// One retrieval interaction accepted by the standard v1 batch endpoint. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BatchRequestKind { - /// Fetch one object. - Fetch(FetchRequest), - /// Fetch one bounded tree. - FetchTree(FetchTreeRequest), + /// Observe one object and its indexable descendants. + Observe(ObserveRequest), /// Fetch one interface descriptor. FetchInterface(FetchInterfaceRequest), /// A known method whose `arguments` are not a valid logical request. @@ -64,8 +58,7 @@ pub enum BatchRequestKind { impl BatchRequestKind { fn interaction(&self) -> ProtocolInteraction { match self { - Self::Fetch(_) => ProtocolInteraction::Fetch, - Self::FetchTree(_) => ProtocolInteraction::FetchTree, + Self::Observe(_) => ProtocolInteraction::Observe, Self::FetchInterface(_) => ProtocolInteraction::FetchInterface, Self::Invalid { method, .. } => method.interaction(), } @@ -91,10 +84,8 @@ pub struct BatchRequest { /// Successful result of one batch item. #[derive(Debug, Clone, PartialEq)] pub enum BatchSuccess { - /// Successful `fetch` result. - Fetch(FetchResponse), - /// Successful `fetch_tree` result. - FetchTree(FetchTreeResponse), + /// Successful `observe` result. + Observe(ObserveResponse), /// Successful `fetch_interface` result. FetchInterface(FetchInterfaceResponse), } @@ -135,10 +126,7 @@ fn validate_ids<'a>(ids: impl Iterator, maximum: usize) -> Resul fn request_item_to_json(item: &BatchRequestItem) -> Result { let (method, arguments) = match &item.request { - BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)), - BatchRequestKind::FetchTree(value) => { - ("fetch_tree", json::fetch_tree_request_to_json(value)) - } + BatchRequestKind::Observe(value) => ("observe", json::observe_request_to_json(value)), BatchRequestKind::FetchInterface(value) => ( "fetch_interface", json::fetch_interface_request_to_json(value), @@ -160,8 +148,7 @@ fn request_to_json(request: &BatchRequest, limits: Limits) -> Result value.validate()?, - BatchRequestKind::FetchTree(value) => value.validate()?, + BatchRequestKind::Observe(value) => value.validate()?, BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {} } } @@ -190,22 +177,18 @@ fn request_from_json(value: &Json, limits: Limits) -> Result BatchMethod::Fetch, - "fetch_tree" => BatchMethod::FetchTree, + "observe" => BatchMethod::Observe, "fetch_interface" => BatchMethod::FetchInterface, _ => { return Err(CodecError::InvalidField { 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 { - BatchMethod::Fetch => { - json::fetch_request_from_json(arguments).map(BatchRequestKind::Fetch) - } - BatchMethod::FetchTree => { - json::fetch_tree_request_from_json(arguments).map(BatchRequestKind::FetchTree) + BatchMethod::Observe => { + json::observe_request_from_json(arguments).map(BatchRequestKind::Observe) } BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments) .map(BatchRequestKind::FetchInterface), @@ -249,8 +232,7 @@ pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result Json { match success { - BatchSuccess::Fetch(value) => json::object_to_json(value), - BatchSuccess::FetchTree(value) => json::fetch_tree_response_to_json(value), + BatchSuccess::Observe(value) => json::observe_response_to_json(value), BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value), } } @@ -299,12 +281,9 @@ fn correlate( }); }; match (&item.outcome, *kind) { - (Ok(BatchSuccess::Fetch(value)), BatchRequestKind::Fetch(request)) => { + (Ok(BatchSuccess::Observe(value)), BatchRequestKind::Observe(request)) => { request.validate_response(value)?; } - (Ok(BatchSuccess::FetchTree(value)), BatchRequestKind::FetchTree(request)) => { - value.validate_for(request)?; - } ( Ok(BatchSuccess::FetchInterface(value)), BatchRequestKind::FetchInterface(request), @@ -459,15 +438,10 @@ fn response_from_json( Err(error) } (Some(result), None) => Ok(match kind { - BatchRequestKind::Fetch(request) => { - let value = json::object_from_json(result, "result")?; + BatchRequestKind::Observe(request) => { + let value = json::observe_response_from_json(result)?; request.validate_response(&value)?; - BatchSuccess::Fetch(value) - } - BatchRequestKind::FetchTree(request) => { - let value = json::fetch_tree_response_from_json(result)?; - value.validate_for(request)?; - BatchSuccess::FetchTree(value) + BatchSuccess::Observe(value) } BatchRequestKind::FetchInterface(request) => { let value = json::fetch_interface_response_from_json(result)?; @@ -509,7 +483,7 @@ pub fn decode_batch_response>( let decoded = response::decode( value.status(), value.body().as_ref(), - ProtocolInteraction::Fetch, + ProtocolInteraction::Observe, limits, |json| response_from_json(json, request, limits), ) diff --git a/crates/wip-https/src/codec.rs b/crates/wip-https/src/codec.rs index bf216f2..00ab63d 100644 --- a/crates/wip-https/src/codec.rs +++ b/crates/wip-https/src/codec.rs @@ -5,8 +5,8 @@ use http::header::{ACCEPT, CONTENT_TYPE}; use http::{HeaderMap, Method, Request, Response, StatusCode}; use wip_protocol::{ CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, - FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, - InterfaceDescriptor, InterfaceTarget, ProtocolError, ProtocolInteraction, Target, + INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, ObserveRequest, ObserveResponse, + ProtocolError, ProtocolInteraction, Target, }; use crate::response::{self, DecodedResponse}; @@ -264,49 +264,25 @@ pub fn validate_http_request( Ok(()) } -/// Encodes a `fetch` HTTP request. -pub fn encode_fetch_request( +/// Encodes an `observe` HTTP request. +pub fn encode_observe_request( endpoint: &Endpoint, - value: &FetchRequest, + value: &ObserveRequest, limits: Limits, ) -> Result>, EncodeHttpError> { value.validate().map_err(CodecError::from)?; let body = json::serialize( - &json::fetch_request_to_json(value), + &json::observe_request_to_json(value), BodyKind::Request, limits, )?; - Ok(request(endpoint, Route::Fetch, body)?) + Ok(request(endpoint, Route::Observe, body)?) } -/// Decodes a `fetch` request body. -pub fn decode_fetch_request(body: &[u8], limits: Limits) -> Result { +/// Decodes an `observe` request body. +pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result { let value = json::parse(body, BodyKind::Request, limits)?; - json::fetch_request_from_json(&value) -} - -/// Encodes a `fetch_tree` HTTP request. -pub fn encode_fetch_tree_request( - endpoint: &Endpoint, - value: &FetchTreeRequest, - limits: Limits, -) -> Result>, 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 { - let value = json::parse(body, BodyKind::Request, limits)?; - json::fetch_tree_request_from_json(&value) + json::observe_request_from_json(&value) } /// Encodes a `fetch_interface` HTTP request. @@ -407,72 +383,39 @@ pub fn decode_call_operation_request( json::call_request_from_json(&value, descriptor) } -/// Encodes a successful `fetch` response as a direct body with HTTP 200. -pub fn encode_fetch_response( - request_value: &FetchRequest, - value: &FetchResponse, +/// Encodes a successful `observe` response as a direct body with HTTP 200. +pub fn encode_observe_response( + request_value: &ObserveRequest, + value: &ObserveResponse, limits: Limits, ) -> Result>, EncodeHttpError> { request_value .validate_response(value) .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>( - request_value: &FetchRequest, - value: &Response, - limits: Limits, -) -> Result, 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>, EncodeHttpError> { - value - .validate_for(request_value) - .map_err(CodecError::from)?; let body = json::serialize( - &json::fetch_tree_response_to_json(value), + &json::observe_response_to_json(value), BodyKind::Response, limits, )?; Ok(response(StatusCode::OK, body)?) } -/// Decodes and classifies a `fetch_tree` client response. -pub fn decode_fetch_tree_response>( - request_value: &FetchTreeRequest, +/// Decodes and classifies an `observe` client response. +pub fn decode_observe_response>( + request_value: &ObserveRequest, value: &Response, limits: Limits, -) -> Result, ClientResponseError> { +) -> Result, ClientResponseError> { require_response_content_type(value)?; response::decode( value.status(), value.body().as_ref(), - ProtocolInteraction::FetchTree, + ProtocolInteraction::Observe, limits, |json| { - let tree = json::fetch_tree_response_from_json(json)?; - tree.validate_for(request_value)?; - Ok(tree) + let observation = json::observe_response_from_json(json)?; + request_value.validate_response(&observation)?; + Ok(observation) }, ) } diff --git a/crates/wip-https/src/endpoint.rs b/crates/wip-https/src/endpoint.rs index 57e2f2f..c7f70c6 100644 --- a/crates/wip-https/src/endpoint.rs +++ b/crates/wip-https/src/endpoint.rs @@ -9,10 +9,8 @@ pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; /// A fixed standard WIP over HTTPS v1 route. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Route { - /// Fetch one object. - Fetch, - /// Fetch a bounded object tree. - FetchTree, + /// Observe one object and its indexable descendants. + Observe, /// Fetch an interface descriptor. FetchInterface, /// Call one operation. @@ -26,8 +24,7 @@ impl Route { #[must_use] pub const fn relative_reference(self) -> &'static str { match self { - Self::Fetch => "v1/fetch", - Self::FetchTree => "v1/fetch_tree", + Self::Observe => "v1/observe", Self::FetchInterface => "v1/fetch_interface", Self::CallOperation => "v1/call_operation", Self::Batch => "v1/batch", @@ -87,9 +84,8 @@ impl Endpoint { /// are not route components and therefore never match. #[must_use] pub fn recognize(&self, candidate: &Url) -> Option { - const ROUTES: [Route; 5] = [ - Route::Fetch, - Route::FetchTree, + const ROUTES: [Route; 4] = [ + Route::Observe, Route::FetchInterface, Route::CallOperation, Route::Batch, diff --git a/crates/wip-https/src/json.rs b/crates/wip-https/src/json.rs index 3cce5b4..3dfa922 100644 --- a/crates/wip-https/src/json.rs +++ b/crates/wip-https/src/json.rs @@ -7,9 +7,9 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; use serde_json::{Map, Number, Value as Json}; use wip_protocol::{ CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, - FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, - INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, - Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, + FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, + InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest, + ObserveResponse, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value, }; @@ -271,29 +271,15 @@ fn insert_optional_bytes(map: &mut Map, name: &str, value: &Option } } -pub(crate) fn fetch_request_to_json(request: &FetchRequest) -> Json { - serde_json::json!({"path": request.path}) +pub(crate) fn observe_request_to_json(request: &ObserveRequest) -> Json { + serde_json::json!({"path": request.path, "depth": request.depth}) } -pub(crate) fn fetch_request_from_json(value: &Json) -> Result { - let fields = exact_object(value, "request", &["path"])?; - let request = FetchRequest { - 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 { - let fields = exact_object(value, "request", &["path", "depth", "limit"])?; - let request = FetchTreeRequest { +pub(crate) fn observe_request_from_json(value: &Json) -> Result { + let fields = exact_object(value, "request", &["path", "depth"])?; + let request = ObserveRequest { path: string(required(fields, "path")?, "path")?, depth: u32_number(required(fields, "depth")?, "depth")?, - limit: u32_number(required(fields, "limit")?, "limit")?, }; request.validate()?; Ok(request) @@ -360,35 +346,43 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result Json { - serde_json::json!({ - "object": object_to_json(&node.object), - "children": node.children.iter().map(tree_node_to_json).collect::>() - }) +fn observation_to_json(node: &ObjectObservation) -> Json { + let mut fields = Map::new(); + fields.insert("object".into(), object_to_json(&node.object)); + 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 { +fn observation_from_json(value: &Json, field: &str) -> Result { let fields = exact_object(value, field, &["object", "children"])?; - Ok(ObjectTreeNode { + let children = optional(fields, "children")? + .map(|value| { + array(value, &qualified(field, "children"))? + .iter() + .enumerate() + .map(|(index, child)| { + observation_from_json(child, &format!("{field}.children[{index}]")) + }) + .collect::, _>>() + }) + .transpose()?; + Ok(ObjectObservation { object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?, - children: array(required(fields, "children")?, &qualified(field, "children"))? - .iter() - .enumerate() - .map(|(index, child)| tree_node_from_json(child, &format!("{field}.children[{index}]"))) - .collect::, _>>()?, + children, }) } -pub(crate) fn fetch_tree_response_to_json(response: &FetchTreeResponse) -> Json { - serde_json::json!({"root": tree_node_to_json(&response.root), "truncated": response.truncated}) +pub(crate) fn observe_response_to_json(response: &ObserveResponse) -> Json { + observation_to_json(response) } -pub(crate) fn fetch_tree_response_from_json(value: &Json) -> Result { - let fields = exact_object(value, "response", &["root", "truncated"])?; - Ok(FetchTreeResponse { - root: tree_node_from_json(required(fields, "root")?, "root")?, - truncated: boolean(required(fields, "truncated")?, "truncated")?, - }) +pub(crate) fn observe_response_from_json(value: &Json) -> Result { + observation_from_json(value, "response") } fn documentation_to_json(documentation: &Documentation) -> Json { diff --git a/crates/wip-https/src/lib.rs b/crates/wip-https/src/lib.rs index d58771c..8a71abf 100644 --- a/crates/wip-https/src/lib.rs +++ b/crates/wip-https/src/lib.rs @@ -11,17 +11,17 @@ //! does not infer WIP validator semantics from `ETag` or `If-Match`. //! //! ``` -//! use wip_https::{Endpoint, Limits, encode_fetch_request}; -//! use wip_protocol::FetchRequest; +//! use wip_https::{Endpoint, Limits, encode_observe_request}; +//! use wip_protocol::ObserveRequest; //! //! let endpoint = Endpoint::parse("https://example.test/api/wip")?; //! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?; -//! let request = encode_fetch_request( +//! let request = encode_observe_request( //! &endpoint, -//! &FetchRequest { path: "/items/123".into() }, +//! &ObserveRequest { path: "/items/123".into(), depth: 0 }, //! 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); //! # Ok::<(), Box>(()) //! ``` @@ -44,12 +44,11 @@ pub use batch::{ pub use codec::{ CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request, - decode_fetch_interface_response, decode_fetch_request, decode_fetch_response, - decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request, - encode_call_operation_response, encode_fetch_interface_request, - encode_fetch_interface_response, encode_fetch_request, encode_fetch_response, - encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response, - encode_resource_limit_error_response, is_json_content_type, validate_http_request, + decode_fetch_interface_response, decode_observe_request, decode_observe_response, + encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request, + encode_fetch_interface_response, encode_observe_request, encode_observe_response, + encode_protocol_error_response, encode_resource_limit_error_response, is_json_content_type, + validate_http_request, }; pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route}; pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits, ResourceLimitKind}; diff --git a/crates/wip-https/tests/batch.rs b/crates/wip-https/tests/batch.rs index d6d88da..102ef51 100644 --- a/crates/wip-https/tests/batch.rs +++ b/crates/wip-https/tests/batch.rs @@ -1,7 +1,7 @@ use wip_https::http::StatusCode; use wip_https::wip_protocol::{ - FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, - InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, + FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, + Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode, }; use wip_https::{ BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, @@ -15,13 +15,27 @@ fn limits() -> Limits { Limits::new(100_000, 100_000, 64, 4).unwrap() } +fn observed_item(validator: Option>) -> ObjectObservation { + ObjectObservation { + object: Object { + name: "item".into(), + description: None, + interfaces: vec![], + r#ref: None, + validator, + }, + children: None, + } +} + fn request() -> BatchRequest { BatchRequest { requests: vec![ BatchRequestItem { id: "object".into(), - request: BatchRequestKind::Fetch(FetchRequest { + request: BatchRequestKind::Observe(ObserveRequest { path: "/item".into(), + depth: 0, }), }, BatchRequestItem { @@ -57,13 +71,9 @@ fn batch_round_trips_by_unique_id_with_item_failures_and_validators() { }, BatchResponseItem { id: "object".into(), - outcome: Ok(BatchSuccess::Fetch(Object { - name: "item".into(), - description: None, - interfaces: vec![], - r#ref: None, - validator: Some(vec![0, 1, 2, 255]), - })), + outcome: Ok(BatchSuccess::Observe(observed_item(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() { let body = br#"{ "requests": [ - {"id":"valid","method":"fetch","arguments":{"path":"/item"}}, - {"id":"invalid","method":"fetch_tree","arguments":{"path":"/","depth":"deep","limit":1}} + {"id":"valid","method":"observe","arguments":{"path":"/item","depth":0}}, + {"id":"invalid","method":"observe","arguments":{"path":"/","depth":"deep"}} ] }"#; let decoded = decode_batch_request(body, limits()).unwrap(); assert!(matches!( decoded.requests[0].request, - BatchRequestKind::Fetch(_) + BatchRequestKind::Observe(_) )); let invalid = match &decoded.requests[1].request { BatchRequestKind::Invalid { method, error } => { - assert_eq!(*method, BatchMethod::FetchTree); + assert_eq!(*method, BatchMethod::Observe); assert_eq!(error.code, ProtocolErrorCode::InvalidRequest); error.clone() } @@ -143,13 +153,7 @@ fn malformed_known_method_arguments_are_item_local() { responses: vec![ BatchResponseItem { id: "valid".into(), - outcome: Ok(BatchSuccess::Fetch(Object { - name: "item".into(), - description: None, - interfaces: vec![], - r#ref: None, - validator: None, - })), + outcome: Ok(BatchSuccess::Observe(observed_item(None))), }, BatchResponseItem { id: "invalid".into(), @@ -171,15 +175,15 @@ fn invalid_batch_envelopes_are_rejected_at_top_level() { let malformed_item = br#"{ "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()); let duplicate = br#"{ "requests": [ - {"id":"same","method":"fetch","arguments":{"path":"/a"}}, - {"id":"same","method":"fetch","arguments":{"path":"/b"}} + {"id":"same","method":"observe","arguments":{"path":"/a","depth":0}}, + {"id":"same","method":"observe","arguments":{"path":"/b","depth":0}} ] }"#; assert!(decode_batch_request(duplicate, limits()).is_err()); @@ -228,13 +232,7 @@ fn batch_success_body_requires_exactly_http_200() { responses: vec![ BatchResponseItem { id: "object".into(), - outcome: Ok(BatchSuccess::Fetch(Object { - name: "item".into(), - description: None, - interfaces: vec![], - r#ref: None, - validator: None, - })), + outcome: Ok(BatchSuccess::Observe(observed_item(None))), }, BatchResponseItem { id: "interface".into(), @@ -340,13 +338,7 @@ fn batch_response_must_have_exactly_the_requested_ids() { let missing = BatchResponse { responses: vec![BatchResponseItem { id: "object".into(), - outcome: Ok(BatchSuccess::Fetch(Object { - name: "item".into(), - description: None, - interfaces: vec![], - r#ref: None, - validator: None, - })), + outcome: Ok(BatchSuccess::Observe(observed_item(None))), }], }; assert!(encode_batch_response(&request, &missing, limits()).is_err()); diff --git a/crates/wip-https/tests/codecs.rs b/crates/wip-https/tests/codecs.rs index 0d4929d..d9c1f44 100644 --- a/crates/wip-https/tests/codecs.rs +++ b/crates/wip-https/tests/codecs.rs @@ -3,20 +3,18 @@ use std::collections::BTreeMap; use wip_https::http::StatusCode; use wip_https::wip_protocol::{ CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, - FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, - INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, Object, - ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, - TypeDeclaration, TypeExpr, UnionCase, Value, + FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, + InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest, + OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration, + TypeExpr, UnionCase, Value, }; use wip_https::{ ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request, - decode_fetch_interface_response, decode_fetch_request, decode_fetch_response, - decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request, - encode_call_operation_response, encode_fetch_interface_request, - encode_fetch_interface_response, encode_fetch_request, encode_fetch_response, - encode_fetch_tree_request, encode_fetch_tree_response, + decode_fetch_interface_response, decode_observe_request, decode_observe_response, + encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request, + encode_fetch_interface_response, encode_observe_request, encode_observe_response, }; fn limits() -> Limits { @@ -196,14 +194,11 @@ fn call_request() -> CallOperationRequest { #[test] fn every_single_interaction_request_accepts_json() { - let fetch = - encode_fetch_request(&endpoint(), &FetchRequest { path: "/".into() }, limits()).unwrap(); - let fetch_tree = encode_fetch_tree_request( + let observe = encode_observe_request( &endpoint(), - &FetchTreeRequest { + &ObserveRequest { path: "/".into(), - depth: 1, - limit: 1, + depth: 0, }, limits(), ) @@ -219,59 +214,75 @@ fn every_single_interaction_request_accepts_json() { let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits()) .unwrap(); - for request in [fetch, fetch_tree, fetch_interface, call] { + for request in [observe, fetch_interface, call] { assert_eq!(request.headers()["accept"], "application/json"); } } #[test] -fn fetch_round_trips_direct_body_and_body_validator() { - let request = FetchRequest { +fn depth_zero_observe_round_trips_direct_body_and_validator() { + let request = ObserveRequest { path: "/items/123".into(), + depth: 0, }; - let encoded_request = encode_fetch_request(&endpoint(), &request, limits()).unwrap(); - assert_eq!(encoded_request.uri().path(), "/wip/v1/fetch"); + let encoded_request = encode_observe_request(&endpoint(), &request, limits()).unwrap(); + assert_eq!(encoded_request.uri().path(), "/wip/v1/observe"); assert_eq!( - decode_fetch_request(encoded_request.body(), limits()).unwrap(), + decode_observe_request(encoded_request.body(), limits()).unwrap(), request ); - let expected = object("123", &[0, 1, 2, 255]); - let encoded = encode_fetch_response(&request, &expected, limits()).unwrap(); + let expected = ObjectObservation { + 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!( + !std::str::from_utf8(encoded.body()) + .unwrap() + .contains("children") + ); assert_eq!( - decode_fetch_response(&request, &encoded, limits()).unwrap(), + decode_observe_response(&request, &encoded, limits()).unwrap(), DecodedResponse::Success(expected) ); assert!(encoded.headers().get("etag").is_none()); } #[test] -fn tree_and_interface_round_trip_nested_logical_models() { - let tree_request = FetchTreeRequest { +fn observe_and_interface_round_trip_nested_logical_models() { + let observe_request = ObserveRequest { path: "/items".into(), 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!( - decode_fetch_tree_request(encoded_request.body(), limits()).unwrap(), - tree_request + decode_observe_request(encoded_request.body(), limits()).unwrap(), + observe_request ); - let tree = FetchTreeResponse { - root: ObjectTreeNode { - object: object("items", b"root-validator"), - children: vec![ObjectTreeNode { - object: object("123", b"child-validator"), - children: vec![], - }], - }, - truncated: false, + let observation = ObjectObservation { + object: object("items", b"root-validator"), + children: Some(vec![ObjectObservation { + object: object("123", b"child-validator"), + children: None, + }]), }; - let encoded = encode_fetch_tree_response(&tree_request, &tree, limits()).unwrap(); + let encoded = encode_observe_response(&observe_request, &observation, limits()).unwrap(); assert_eq!( - decode_fetch_tree_response(&tree_request, &encoded, limits()).unwrap(), - DecodedResponse::Success(tree) + decode_observe_response(&observe_request, &encoded, limits()).unwrap(), + 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 { @@ -363,8 +374,10 @@ fn descriptor_guided_values_and_validators_round_trip_both_directions() { #[test] fn malformed_unknown_null_and_noncanonical_base64_are_rejected() { let limits = limits(); - assert!(decode_fetch_request(br#"{"path":"/","extra":true}"#, limits).is_err()); - assert!(decode_fetch_request(br#"{"path":"relative","path":"/"}"#, limits).is_err()); + assert!(decode_observe_request(br#"{"path":"/","depth":0,"extra":true}"#, limits).is_err()); + assert!( + decode_observe_request(br#"{"path":"relative","path":"/","depth":0}"#, limits).is_err() + ); let request = call_request(); let descriptor = descriptor(); @@ -393,16 +406,16 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() { #[test] fn caller_limits_bound_bytes_and_nesting() { 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_eq!( too_large.resource_limit_kind(), Some(ResourceLimitKind::RepresentationBytes) ); - let nested = br#"{"path":[[["/"]]]}"#; + let nested = br#"{"path":[[["/"]]],"depth":0}"#; 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_eq!( too_deep.resource_limit_kind(), @@ -420,11 +433,15 @@ fn caller_limits_bound_bytes_and_nesting() { .is_err() ); assert!( - encode_fetch_response( - &FetchRequest { + encode_observe_response( + &ObserveRequest { path: "/item".into(), + depth: 0, + }, + &ObjectObservation { + object: object("item", b"validator"), + children: None, }, - &object("item", b"validator"), outbound_limited, ) .is_err() @@ -488,11 +505,12 @@ fn invalid_success_is_classified_as_invalid_response() { let response = wip_https::http::Response::builder() .status(StatusCode::OK) .header("content-type", "application/json; charset=utf-8") - .body(br#"{"name":"wrong","interfaces":[]}"#.to_vec()) + .body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec()) .unwrap(); - let error = decode_fetch_response( - &FetchRequest { + let error = decode_observe_response( + &ObserveRequest { path: "/expected".into(), + depth: 0, }, &response, limits(), diff --git a/crates/wip-https/tests/responses.rs b/crates/wip-https/tests/responses.rs index 1ee4ad1..e26bbbb 100644 --- a/crates/wip-https/tests/responses.rs +++ b/crates/wip-https/tests/responses.rs @@ -1,10 +1,10 @@ use wip_https::http::{Response, StatusCode}; use wip_https::wip_protocol::{ - FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction, + ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction, }; use wip_https::{ 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, status_for_resource_limit, }; @@ -13,8 +13,11 @@ fn limits() -> Limits { Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap() } -fn request() -> FetchRequest { - FetchRequest { path: "/".into() } +fn request() -> ObserveRequest { + ObserveRequest { + path: "/".into(), + depth: 0, + } } fn response(status: StatusCode, body: impl Into>) -> Response> { @@ -132,7 +135,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() { for (kind, status) in cases { assert_eq!(status_for_resource_limit(kind), status); let encoded = encode_resource_limit_error_response( - ProtocolInteraction::Fetch, + ProtocolInteraction::Observe, &error, kind, limits(), @@ -140,7 +143,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() { .unwrap(); assert_eq!(encoded.status(), status); assert_eq!( - decode_fetch_response(&request(), &encoded, limits()).unwrap(), + decode_observe_response(&request(), &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: error.clone(), status_mismatch: None, @@ -149,7 +152,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() { } 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_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()) .unwrap(); assert!(matches!( - decode_fetch_response(&request(), &response, limits()).unwrap_err(), + decode_observe_response(&request(), &response, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.status == StatusCode::TOO_MANY_REQUESTS && failure.kind == TransportFailureKind::HttpStatus @@ -170,10 +173,10 @@ fn rate_limit_status_without_protocol_envelope_stays_transport_failure() { #[test] 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] { assert!(matches!( - decode_fetch_response(&request(), &response(status, success_body), limits()) + decode_observe_response(&request(), &response(status, success_body), limits()) .unwrap_err(), ClientResponseError::InvalidResponse { kind: InvalidResponseKind::NonCanonicalSuccessStatus, @@ -187,9 +190,11 @@ fn valid_success_body_requires_exactly_http_200() { fn server_encoder_rejects_codes_disallowed_for_the_interaction() { let error = ProtocolError { 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] @@ -199,11 +204,11 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() { message: "missing".into(), }; 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; assert_eq!( - decode_fetch_response(&request(), &encoded, limits()).unwrap(), + decode_observe_response(&request(), &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error, 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() { let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#; 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(); assert!(matches!( on_success, @@ -228,8 +233,8 @@ fn envelope_and_status_contradictions_are_invalid_responses() { } )); - let success_body = br#"{"name":"","interfaces":[]}"#; - let on_error = decode_fetch_response( + let success_body = br#"{"object":{"name":"","interfaces":[]}}"#; + let on_error = decode_observe_response( &request(), &response(StatusCode::BAD_GATEWAY, success_body), limits(), @@ -247,8 +252,9 @@ fn envelope_and_status_contradictions_are_invalid_responses() { #[test] fn disallowed_protocol_code_is_invalid_instead_of_reclassified() { let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#; - let error = decode_fetch_response(&request(), &response(StatusCode::NOT_FOUND, body), limits()) - .unwrap_err(); + let error = + decode_observe_response(&request(), &response(StatusCode::NOT_FOUND, body), limits()) + .unwrap_err(); assert!(matches!( error, ClientResponseError::InvalidResponse { @@ -282,7 +288,7 @@ fn proxy_authentication_and_endpoint_failures_stay_transport_failures() { .status(status) .body(body.to_vec()) .unwrap(); - let error = decode_fetch_response(&request(), &response, limits()).unwrap_err(); + let error = decode_observe_response(&request(), &response, limits()).unwrap_err(); assert!(matches!( error, ClientResponseError::TransportBinding(failure) @@ -298,7 +304,7 @@ fn malformed_non_success_envelope_is_transport_binding_failure() { br#"{"error":{"code":"not_found","message":null}}"#, ); assert!(matches!( - decode_fetch_response(&request(), &malformed, limits()).unwrap_err(), + decode_observe_response(&request(), &malformed, limits()).unwrap_err(), ClientResponseError::TransportBinding(_) )); } diff --git a/crates/wip-https/tests/routes.rs b/crates/wip-https/tests/routes.rs index f13be47..d594ade 100644 --- a/crates/wip-https/tests/routes.rs +++ b/crates/wip-https/tests/routes.rs @@ -7,10 +7,9 @@ fn endpoint_is_https_absolute_queryless_and_canonical() { assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/"); let expected = [ - (Route::Fetch, "https://example.test/root/prefix/v1/fetch"), ( - Route::FetchTree, - "https://example.test/root/prefix/v1/fetch_tree", + Route::Observe, + "https://example.test/root/prefix/v1/observe", ), ( Route::FetchInterface, @@ -31,7 +30,7 @@ fn endpoint_is_https_absolute_queryless_and_canonical() { } assert_eq!( 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() ), None @@ -41,8 +40,8 @@ fn endpoint_is_https_absolute_queryless_and_canonical() { #[test] 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 route = endpoint.route(Route::Fetch); - assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/fetch"); + let route = endpoint.route(Route::Observe); + assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/observe"); 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 valid = Request::builder() .method(Method::POST) - .uri("/wip/v1/fetch") + .uri("/wip/v1/observe") .header("content-type", "application/json; charset=utf-8") .body(Vec::::new()) .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() .method(Method::GET) - .uri("/wip/v1/fetch") + .uri("/wip/v1/observe") .header("content-type", "application/json") .body(Vec::::new()) .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() .method(Method::POST) - .uri("/wip/v1/fetch_tree") + .uri("/wip/v1/fetch_interface") .header("content-type", "application/json") .body(Vec::::new()) .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 [ "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() .method(Method::POST) - .uri("/wip/v1/fetch") + .uri("/wip/v1/observe") .header("content-type", "application/json") .header("accept", accept) .body(Vec::::new()) .unwrap(); 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_eq!(error.status(), StatusCode::NOT_ACCEPTABLE); } @@ -97,23 +96,23 @@ fn inbound_http_metadata_enforces_post_route_and_json() { ] { let compatible_accept = Request::builder() .method(Method::POST) - .uri("/wip/v1/fetch") + .uri("/wip/v1/observe") .header("content-type", "application/json") .header("accept", accept) .body(Vec::::new()) .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() .method(Method::POST) - .uri("/wip/v1/fetch") + .uri("/wip/v1/observe") .header("content-type", "text/plain") .header("accept", "application/json") .body(Vec::::new()) .unwrap(); 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_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); } diff --git a/crates/wip-protocol/src/error.rs b/crates/wip-protocol/src/error.rs index a28d9aa..92ee9fe 100644 --- a/crates/wip-protocol/src/error.rs +++ b/crates/wip-protocol/src/error.rs @@ -62,7 +62,7 @@ pub enum PathSegment { Return, /// Object at a Worldspace path. Object(String), - /// Child object in a tree response. + /// Child object in an observation response. Child(String), } @@ -163,17 +163,15 @@ pub enum ValidationErrorKind { /// Shared opaque object reference. reference: String, }, - /// A `fetch_tree` request used a zero limit. - ZeroTreeLimit, - /// A tree response exceeds the requested maximum depth. - TreeDepthExceeded { - /// Requested maximum depth. - maximum: u32, + /// An observation omitted children before reaching the requested depth. + ChildrenMissingBeforeDepth { + /// Node depth where children were required. + depth: u32, }, - /// A tree response exceeds the requested maximum node count. - TreeLimitExceeded { - /// Requested maximum node count. - limit: u32, + /// An observation included children at the requested depth boundary. + ChildrenAtDepthBoundary { + /// Boundary depth where children must be unobserved. + depth: u32, }, /// A response refers to a different interface than its request. InterfaceResponseMismatch { @@ -226,10 +224,8 @@ impl Error for ValidationError {} /// One core interaction whose failure codes form a closed set. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProtocolInteraction { - /// Fetch one object by its Worldspace path. - Fetch, - /// Fetch a bounded tree of objects. - FetchTree, + /// Observe one object and its indexable descendants to a requested depth. + Observe, /// Fetch one interface descriptor by its opaque reference. FetchInterface, /// Invoke one operation on a path-targeted object. @@ -315,11 +311,11 @@ impl ProtocolErrorCode { OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded, ValidatorMismatch, ValidatorRequired, }; - use ProtocolInteraction::{CallOperation, Fetch, FetchInterface, FetchTree}; + use ProtocolInteraction::{CallOperation, FetchInterface, Observe}; match (interaction, self) { (_, InvalidRequest | ResourceLimitExceeded | Internal) => true, - (Fetch | FetchTree, NotFound) => true, + (Observe, NotFound) => true, (FetchInterface, InterfaceNotFound) => true, ( CallOperation, diff --git a/crates/wip-protocol/src/lib.rs b/crates/wip-protocol/src/lib.rs index 6c8171f..de8b313 100644 --- a/crates/wip-protocol/src/lib.rs +++ b/crates/wip-protocol/src/lib.rs @@ -11,8 +11,7 @@ //! | Interface Descriptor | [`InterfaceDescriptor`] | //! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] | //! | Schema-neutral primitive/composite value | [`Value`] | -//! | `fetch` | [`FetchRequest`] / [`FetchResponse`] | -//! | `fetch_tree` | [`FetchTreeRequest`] / [`FetchTreeResponse`] | +//! | `observe` | [`ObserveRequest`] / [`ObserveResponse`] | //! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] | //! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] | //! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] | @@ -53,10 +52,9 @@ pub use error::{ }; pub use model::{ CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, - FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, - FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, ObjectTreeNode, - OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target, - TypeDeclaration, TypeExpr, UnionCase, Value, + FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, + ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration, + ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value, }; /// Validates a canonical absolute Worldspace path. diff --git a/crates/wip-protocol/src/model.rs b/crates/wip-protocol/src/model.rs index eeeb633..d96603a 100644 --- a/crates/wip-protocol/src/model.rs +++ b/crates/wip-protocol/src/model.rs @@ -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)] -pub struct FetchRequest { - /// Canonical absolute Worldspace path. +pub struct ObserveRequest { + /// Canonical absolute path of the observation root. pub path: String, -} - -/// 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. + /// Maximum observed depth, where the requested object has depth zero. 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)] -pub struct ObjectTreeNode { - /// Object observed at this tree position. +pub struct ObjectObservation { + /// Object observed at this Worldspace position. pub object: Object, - /// Direct children in host-defined traversal order. - pub children: Vec, + /// Complete direct children when observed, or `None` at the requested depth boundary. + pub children: Option>, } -/// Successful `fetch_tree` response. -#[derive(Debug, Clone, PartialEq, Eq)] -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, -} +/// Successful `observe` response. +pub type ObserveResponse = ObjectObservation; /// Request for the current descriptor of one opaque interface reference. #[derive(Debug, Clone, PartialEq, Eq)] @@ -372,35 +354,18 @@ impl InterfaceDescriptor { } } -impl FetchRequest { +impl ObserveRequest { /// Validates the request path. pub fn validate(&self) -> Result<(), ValidationError> { 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 /// Host-returned [`crate::ProtocolErrorCode`]. - pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> { - response.validate_at_path(&self.path) - } -} - -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) + pub fn validate_response(&self, response: &ObserveResponse) -> Result<(), ValidationError> { + crate::validation::validate_observe_response(response, self) } } diff --git a/crates/wip-protocol/src/validation.rs b/crates/wip-protocol/src/validation.rs index e48c956..39615f0 100644 --- a/crates/wip-protocol/src/validation.rs +++ b/crates/wip-protocol/src/validation.rs @@ -1,10 +1,10 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use crate::{ - CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, - FetchTreeResponse, InterfaceDescriptor, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, - Object, ObjectTreeNode, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, - UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind, + CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, + MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest, + PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, ValidationError, + ValidationErrorKind, Value, ValueKind, }; pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> { @@ -604,60 +604,38 @@ fn find_operation<'a>( }) } -pub(crate) fn validate_fetch_tree_request( - request: &FetchTreeRequest, -) -> Result<(), ValidationError> { - 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, +pub(crate) fn validate_observe_response( + response: &ObjectObservation, + request: &ObserveRequest, ) -> Result<(), ValidationError> { request.validate()?; - let mut node_count = 0_u32; - validate_tree_node( - &response.root, - &request.path, - 0, - request.depth, - request.limit, - &mut node_count, - ) + validate_observation_node(response, &request.path, 0, request.depth) } -fn validate_tree_node( - node: &ObjectTreeNode, +fn validate_observation_node( + node: &ObjectObservation, path: &str, depth: u32, maximum_depth: u32, - limit: u32, - node_count: &mut u32, ) -> Result<(), ValidationError> { - if depth > maximum_depth { - return Err( - ValidationError::new(ValidationErrorKind::TreeDepthExceeded { - maximum: maximum_depth, - }) - .prepend(PathSegment::Object(path.to_owned())), - ); - } - *node_count = node_count.saturating_add(1); - if *node_count > limit { - return Err( - ValidationError::new(ValidationErrorKind::TreeLimitExceeded { limit }) - .prepend(PathSegment::Object(path.to_owned())), - ); + node.object.validate_at_path(path)?; + + if depth == maximum_depth { + if node.children.is_some() { + return Err( + ValidationError::new(ValidationErrorKind::ChildrenAtDepthBoundary { depth }) + .prepend(PathSegment::Object(path.to_owned())), + ); + } + return Ok(()); } - 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(); - for child in &node.children { + for child in children { validate_object_name(&child.object.name, false) .map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?; if !names.insert(child.object.name.as_str()) { @@ -673,14 +651,7 @@ fn validate_tree_node( } else { format!("{path}/{}", child.object.name) }; - validate_tree_node( - child, - &child_path, - depth.saturating_add(1), - maximum_depth, - limit, - node_count, - )?; + validate_observation_node(child, &child_path, depth + 1, maximum_depth)?; } Ok(()) } diff --git a/crates/wip-protocol/tests/validation.rs b/crates/wip-protocol/tests/validation.rs index 750dea3..e1d3f9b 100644 --- a/crates/wip-protocol/tests/validation.rs +++ b/crates/wip-protocol/tests/validation.rs @@ -2,11 +2,11 @@ use std::collections::BTreeMap; use wip_protocol::{ CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest, - FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, - INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, - Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, - ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, - TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind, + FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, + InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest, + OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, ProtocolErrorCode, + ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, + UnionCase, ValidationErrorKind, Value, ValueKind, }; fn record(entries: impl IntoIterator) -> Value { @@ -617,13 +617,26 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() { } #[test] -fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() { - let fetch = FetchRequest { +fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() { + let observe = ObserveRequest { path: "/articles/one".into(), + depth: 0, }; - fetch.validate().unwrap(); - fetch.validate_response(&object("one")).unwrap(); - assert!(fetch.validate_response(&object("two")).is_err()); + observe.validate().unwrap(); + observe + .validate_response(&ObjectObservation { + object: object("one"), + children: None, + }) + .unwrap(); + assert!( + observe + .validate_response(&ObjectObservation { + object: object("two"), + children: None, + }) + .is_err() + ); for invalid in [ "", @@ -634,8 +647,9 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() { "/a/../b", ] { assert!(matches!( - FetchRequest { - path: invalid.into() + ObserveRequest { + path: invalid.into(), + depth: 0, } .validate() .unwrap_err() @@ -666,61 +680,51 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() { } #[test] -fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() { - let request = FetchTreeRequest { +fn observe_response_validates_complete_children_and_depth_boundary() { + let request = ObserveRequest { path: "/articles".into(), depth: 1, - limit: 3, }; - let leaf = |name: &str| ObjectTreeNode { + let leaf = |name: &str| ObjectObservation { object: object(name), - children: vec![], + children: None, }; - let response = FetchTreeResponse { - root: ObjectTreeNode { - object: object("articles"), - children: vec![leaf("one"), leaf("two")], - }, - truncated: false, + let response = ObjectObservation { + object: object("articles"), + children: Some(vec![leaf("one"), leaf("two")]), }; - response.validate_for(&request).unwrap(); + request.validate_response(&response).unwrap(); - let zero = FetchTreeRequest { - limit: 0, - ..request.clone() + let empty = ObjectObservation { + object: object("articles"), + children: Some(vec![]), }; - assert_eq!( - zero.validate().unwrap_err().kind, - ValidationErrorKind::ZeroTreeLimit - ); + request.validate_response(&empty).unwrap(); - 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, ..request.clone() }; assert!(matches!( - response.validate_for(&shallow).unwrap_err().kind, - ValidationErrorKind::TreeDepthExceeded { .. } + boundary.validate_response(&response).unwrap_err().kind, + ValidationErrorKind::ChildrenAtDepthBoundary { depth: 0 } )); - let small = FetchTreeRequest { - limit: 2, - ..request.clone() + let duplicate = ObjectObservation { + object: object("articles"), + children: Some(vec![leaf("one"), leaf("one")]), }; assert!(matches!( - response.validate_for(&small).unwrap_err().kind, - ValidationErrorKind::TreeLimitExceeded { .. } - )); - - let duplicate = FetchTreeResponse { - root: ObjectTreeNode { - object: object("articles"), - children: vec![leaf("one"), leaf("one")], - }, - truncated: false, - }; - assert!(matches!( - duplicate.validate_for(&request).unwrap_err().kind, + request.validate_response(&duplicate).unwrap_err().kind, ValidationErrorKind::DuplicateName { namespace: NameNamespace::ChildObject, .. @@ -778,22 +782,9 @@ fn assert_allowed_codes(interaction: ProtocolInteraction, expected: &[ProtocolEr } #[test] -fn fetch_error_codes_are_exhaustive() { +fn observe_error_codes_are_exhaustive() { assert_allowed_codes( - ProtocolInteraction::Fetch, - &[ - ProtocolErrorCode::InvalidRequest, - ProtocolErrorCode::NotFound, - ProtocolErrorCode::ResourceLimitExceeded, - ProtocolErrorCode::Internal, - ], - ); -} - -#[test] -fn fetch_tree_error_codes_are_exhaustive() { - assert_allowed_codes( - ProtocolInteraction::FetchTree, + ProtocolInteraction::Observe, &[ ProtocolErrorCode::InvalidRequest, ProtocolErrorCode::NotFound, @@ -840,11 +831,18 @@ fn call_operation_error_codes_are_exhaustive() { #[test] fn invalid_success_payloads_remain_client_local_validation_failures() { - let fetch = FetchRequest { + let observe = ObserveRequest { path: "/articles/one".into(), + depth: 0, }; assert!(matches!( - fetch.validate_response(&object("other")).unwrap_err().kind, + observe + .validate_response(&ObjectObservation { + object: object("other"), + children: None, + }) + .unwrap_err() + .kind, ValidationErrorKind::ObjectNameMismatch { .. } )); diff --git a/crates/wip-public-api-example/src/lib.rs b/crates/wip-public-api-example/src/lib.rs index 504469d..649522d 100644 --- a/crates/wip-public-api-example/src/lib.rs +++ b/crates/wip-public-api-example/src/lib.rs @@ -190,7 +190,7 @@ pub fn run() -> Result> { audit: audit.clone(), }; - let object = provider.fetch(OBJECT_PATH)?; + let object = provider.observe_object(OBJECT_PATH)?; let catalog_response = registry .fetch_interface( object diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md index 571dba8..4d47764 100644 --- a/crates/wip-tui/README.md +++ b/crates/wip-tui/README.md @@ -80,7 +80,6 @@ All required actions are keyboard-accessible. | `←`/`h` | Collapse a node or move to its parent | | `Tab` | Cycle Tree → Selected Entry → Operations → History | | `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 | | `R` | Reconnect after connection failure | | `?` | Open help | @@ -94,9 +93,9 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret ## Screen model - **Worldspace tree** starts at `/` and materializes direct children only when a - node is expanded. Text labels distinguish unloaded, loading, empty, stale, - failed, and truncated state; color is supplemental. A truncated node is never - presented as a complete tree. + node is expanded. Text labels distinguish unloaded, loading, empty, stale, and + failed state; color is supplemental. Every successful expansion is complete for + its requested depth. - **Selected Entry** shows only public WIP path, object name, description, ordered interfaces, and freshness. Validators, credentials, Host internals, and backend paths are deliberately hidden. If current metadata disappears, old text is not diff --git a/crates/wip-tui/src/app.rs b/crates/wip-tui/src/app.rs index ebf008c..c703016 100644 --- a/crates/wip-tui/src/app.rs +++ b/crates/wip-tui/src/app.rs @@ -31,8 +31,6 @@ pub enum InputKey { Backspace, /// Refresh the selected Entry. Refresh, - /// Retry a truncated tree with a larger limit. - IncreaseLimit, /// Cancel the newest cancel-safe retrieval. CancelRead, /// Show key help. @@ -123,14 +121,10 @@ pub struct EntryView { pub tree_state: ViewState, /// Whether the user expanded this row. pub expanded: bool, - /// Whether the observed child list is incomplete. - pub truncated: bool, - /// Current bounded expansion limit. - pub limit: u32, } impl EntryView { - fn placeholder(path: impl Into, default_limit: u32) -> Self { + fn placeholder(path: impl Into) -> Self { Self { path: path.into(), object: None, @@ -138,8 +132,6 @@ impl EntryView { children: Vec::new(), tree_state: ViewState::Unloaded, expanded: false, - truncated: false, - limit: default_limit, } } } @@ -242,15 +234,11 @@ pub enum Action { Expand { /// Tree root path. path: String, - /// Maximum returned nodes. - limit: u32, }, /// Reobserve an Entry and its direct children. Refresh { /// Selected path. path: String, - /// Current tree limit. - limit: u32, }, /// Retrieve one interface descriptor. FetchInterface(String), @@ -267,7 +255,7 @@ pub enum Action { }, /// Cancel the newest safe retrieval. CancelRead, - /// Reconnect and fetch the root. + /// Reconnect and observe the root. Reconnect, /// Navigate to a descriptor-declared Entry result. NavigateEntry(String), @@ -302,8 +290,6 @@ pub struct App { pub history: Vec, /// Maximum retained presentation history. pub history_limit: usize, - /// Default node limit for lazy expansion. - pub default_tree_limit: u32, /// One-line status for local action feedback. pub status: String, } @@ -312,9 +298,8 @@ impl App { /// Creates a disconnected model with `/` selected. #[must_use] pub fn new(endpoint: impl Into) -> Self { - let default_tree_limit = 64; let mut entries = BTreeMap::new(); - entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit)); + entries.insert("/".into(), EntryView::placeholder("/")); Self { endpoint: endpoint.into(), connection: ConnectionState::Disconnected, @@ -328,7 +313,6 @@ impl App { in_flight: BTreeMap::new(), history: Vec::new(), history_limit: 64, - default_tree_limit, status: "press ? for help".into(), } } @@ -441,14 +425,11 @@ impl App { let node = self .entries .entry(self.selected_path.clone()) - .or_insert_with(|| { - EntryView::placeholder(&self.selected_path, self.default_tree_limit) - }); + .or_insert_with(|| EntryView::placeholder(&self.selected_path)); node.expanded = true; - if !matches!(node.tree_state, ViewState::Fresh) || node.truncated { + if !matches!(node.tree_state, ViewState::Fresh) { return vec![Action::Expand { path: node.path.clone(), - limit: node.limit, }]; } } @@ -464,18 +445,6 @@ impl App { } } 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; @@ -537,13 +506,8 @@ impl App { } fn refresh_action(&self) -> Action { - let limit = self - .entries - .get(&self.selected_path) - .map_or(self.default_tree_limit, |entry| entry.limit); Action::Refresh { path: self.selected_path.clone(), - limit, } } @@ -591,7 +555,6 @@ impl App { let children = observation.children.clone(); let entry = self.ensure_entry(&observation.path); entry.children = children.clone(); - entry.truncated = observation.truncated; entry.tree_state = ViewState::from(&observation.state); entry.expanded = true; for path in children { @@ -725,7 +688,7 @@ impl App { } self.entries .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 { @@ -799,23 +762,20 @@ mod tests { } #[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"); app.apply_tree(TreeObservation { path: "/".into(), children: vec!["/a".into(), "/b".into()], - truncated: true, revision: request_id(), state: ObservationState::Fresh, }); assert_eq!(app.visible_paths(), ["/", "/a", "/b"]); app.reduce(InputKey::Down); assert_eq!(app.selected_path, "/a"); - app.reduce(InputKey::Left); - assert_eq!(app.selected_path, "/"); assert!(matches!( - app.reduce(InputKey::IncreaseLimit).as_slice(), - [Action::Expand { path, limit: 128 }] if path == "/" + app.reduce(InputKey::Right).as_slice(), + [Action::Expand { path }] if path == "/a" )); } @@ -916,6 +876,6 @@ mod tests { wip_client::SecurityContext::new("test"), ) .unwrap(); - client.prepare_fetch(&session, "/").unwrap().id + client.prepare_observe(&session, "/", 0).unwrap().id } } diff --git a/crates/wip-tui/src/render.rs b/crates/wip-tui/src/render.rs index 5408324..06cd4bd 100644 --- a/crates/wip-tui/src/render.rs +++ b/crates/wip-tui/src/render.rs @@ -113,13 +113,8 @@ fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) { ViewState::Fresh if node.children.is_empty() => " [empty]", _ => "", }; - let incomplete = if node.truncated { - " [truncated: + to increase limit]" - } else { - "" - }; let object = state_suffix(&node.state); - format!("{object}{tree}{incomplete}") + format!("{object}{tree}") }, ); 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).", )); } - 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( Paragraph::new(lines) @@ -355,7 +342,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) { frame.render_widget(Clear, area); frame.render_widget( 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 ")) .wrap(Wrap { trim: false }), @@ -625,7 +612,6 @@ mod tests { fn snapshot_contains_non_color_state_markers_and_hides_validators() { let mut app = App::new("https://example.test/wip/"); app.connection = ConnectionState::Connected; - app.entries.get_mut("/").unwrap().truncated = true; app.entries.get_mut("/").unwrap().state = ViewState::Stale; app.push_history(HistoryItem { kind: HistoryKind::OutcomeUnknown, @@ -634,7 +620,6 @@ mod tests { entry_target: None, }); let snapshot = rendered(&app, 110, 30); - assert!(snapshot.contains("truncated")); assert!(snapshot.contains("stale")); assert!(snapshot.contains("OUTCOME UNKNOWN")); assert!(!snapshot.contains("validator")); @@ -740,8 +725,6 @@ mod tests { children: vec![], tree_state: ViewState::Unloaded, expanded: false, - truncated: false, - limit: 1, }, ); } @@ -832,8 +815,6 @@ mod tests { children: vec![], tree_state: ViewState::Unloaded, expanded: false, - truncated: false, - limit: 1, }; assert_eq!(entry.path, "/"); } diff --git a/crates/wip-tui/src/runtime.rs b/crates/wip-tui/src/runtime.rs index c392a48..2119306 100644 --- a/crates/wip-tui/src/runtime.rs +++ b/crates/wip-tui/src/runtime.rs @@ -304,8 +304,7 @@ impl Runtime { /// Starts or restarts observation at Worldspace `/`. pub fn connect(&mut self) { self.app.connection = ConnectionState::Connecting; - let limit = self.app.default_tree_limit; - match self.client.prepare_tree(&self.session, "/", 1, limit) { + match self.client.prepare_observe(&self.session, "/", 1) { Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false), Err(error) => self.local_error("connect", error), } @@ -348,20 +347,12 @@ impl Runtime { fn perform(&mut self, action: Action) -> bool { match action { - Action::Expand { path, limit } => { - match self.client.prepare_tree(&self.session, &path, 1, limit) { - Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), - Err(error) => self.local_error("tree expansion", error), - } - } - Action::Refresh { path, limit } => { - 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) { + Action::Expand { path } => match self.client.prepare_observe(&self.session, &path, 1) { + Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), + Err(error) => self.local_error("tree expansion", error), + }, + Action::Refresh { path } => { + match self.client.prepare_observe(&self.session, &path, 1) { Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Err(error) => self.local_error("subtree refresh", error), } @@ -402,7 +393,7 @@ impl Runtime { Action::Reconnect => self.connect(), Action::NavigateEntry(path) => { 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), Err(error) => self.local_error("Entry navigation", error), } @@ -580,13 +571,17 @@ impl Runtime { fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) { match completion { - Completion::Object(observation) => self.app.apply_object(observation), - Completion::Tree(observation) => { - let root = observation.path.clone(); - self.app.apply_tree(observation); - self.sync_all_objects(); - if root == "/" { - self.app.connection = ConnectionState::Connected; + Completion::Object(observation) => { + let path = 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.sync_all_objects(); + if path == "/" { + self.app.connection = ConnectionState::Connected; + } } } Completion::Interface(observation) => self.app.apply_interface(observation), @@ -708,7 +703,7 @@ impl Runtime { fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) { 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::Interface(reference)) => format!("interface {reference}"), Some(InFlightKind::Operation { operation, .. }) => operation.clone(), @@ -1029,7 +1024,7 @@ mod tests { let transport = HttpTransport::new(&config, 1024).unwrap(); let off_origin = Request::builder() .method("POST") - .uri("https://attacker.test/wip/v1/fetch") + .uri("https://attacker.test/wip/v1/observe") .body(Vec::new()) .unwrap(); 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 valid = Request::builder() .method("POST") - .uri(endpoint.route(wip_https::Route::Fetch).as_str()) + .uri(endpoint.route(wip_https::Route::Observe).as_str()) .body(Vec::new()) .unwrap(); let prepared = transport.prepare(valid).unwrap(); diff --git a/crates/wip-tui/src/terminal.rs b/crates/wip-tui/src/terminal.rs index 00d9171..8d92a92 100644 --- a/crates/wip-tui/src/terminal.rs +++ b/crates/wip-tui/src/terminal.rs @@ -97,7 +97,6 @@ pub fn translate_key(event: KeyEvent, editing: bool) -> Option { KeyCode::Char('l') => InputKey::Right, KeyCode::Char('r') => InputKey::Refresh, KeyCode::Char('R') => InputKey::Reconnect, - KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit, KeyCode::Char('c') => InputKey::CancelRead, KeyCode::Char('?') => InputKey::Help, KeyCode::Char('q') => InputKey::Quit, diff --git a/crates/wip-tui/tests/runtime_async.rs b/crates/wip-tui/tests/runtime_async.rs index b762aed..e0ebfba 100644 --- a/crates/wip-tui/tests/runtime_async.rs +++ b/crates/wip-tui/tests/runtime_async.rs @@ -50,7 +50,7 @@ impl Transport for ScriptedTransport { 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 { kind: TransportErrorKind::Disconnect, detail: "scripted tree conversion failure".into(), @@ -61,7 +61,7 @@ impl Transport for ScriptedTransport { fn execute(&self, request: Self::Prepared) -> Result>, TransportError> { 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 .root_tree_protocol_failures .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { @@ -70,7 +70,7 @@ impl Transport for ScriptedTransport { .is_ok() { return Ok(encode_protocol_error_response( - ProtocolInteraction::FetchTree, + ProtocolInteraction::Observe, &ProtocolError { code: ProtocolErrorCode::Internal, message: "scripted startup failure".into(), @@ -80,9 +80,9 @@ impl Transport for ScriptedTransport { .expect("encode tree failure")); } if self.delayed_after_startup && sequence > 0 { - // Each refresh starts object then tree. Make the first pair lose to - // the second pair to exercise exact RequestId ownership. - let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 }; + // Delay the first refresh so the second wins and exercises exact + // RequestId ownership. + let delay = if sequence == 1 { 80 } else { 5 }; thread::sleep(Duration::from_millis(delay)); } if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {