feat: unify object observation API

This commit is contained in:
2026-09-30 18:19:12 +09:00
parent 2e4196a1ec
commit ea7b0f00ea
32 changed files with 1035 additions and 1251 deletions
+13 -17
View File
@@ -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,
+4 -6
View File
@@ -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.
+15 -50
View File
@@ -218,44 +218,26 @@ impl Value {
}
}
/// Request for the object currently published at one path.
/// Request for the object currently published at one path and its indexable descendants.
#[derive(Debug, Clone, PartialEq, Eq)]
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<ObjectTreeNode>,
/// Complete direct children when observed, or `None` at the requested depth boundary.
pub children: Option<Vec<ObjectObservation>>,
}
/// 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)
}
}
+26 -55
View File
@@ -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(())
}
+65 -67
View File
@@ -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<Item = (&'static str, Value)>) -> 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 { .. }
));