feat: remove retrieval batch over HTTP

This commit is contained in:
2026-10-02 15:00:50 +09:00
parent 3c4dbf3ad0
commit dc9a7f0dbf
18 changed files with 107 additions and 1232 deletions
+4 -4
View File
@@ -19,13 +19,13 @@ wip-tui (interactive Worldspace explorer and operation editor)
└── wip-protocol descriptors and logical values └── wip-protocol descriptors and logical values
wip-fs-server (request-time filesystem projection and loopback listener) wip-fs-server (request-time filesystem projection and loopback listener)
├── wip-http standard routes/codecs/envelopes/batching ├── wip-http standard routes/codecs/envelopes
├── wip-host request-scoped publication and dispatch ├── wip-host request-scoped publication and dispatch
└── wip-interface macro-generated filesystem capabilities └── wip-interface macro-generated filesystem capabilities
wip-public-api-example (workspace-only executable, integration tests, doctest) wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation ├── wip-protocol logical declarations, values, calls, and validation
├── wip-http standard HTTP v1 routes, JSON codecs, envelopes, and batching ├── wip-http standard HTTP v1 routes, JSON codecs, and envelopes
│ └── wip-protocol │ └── wip-protocol
├── wip-client stateful Known Space, lazy tree, observation, and call runtime ├── wip-client stateful Known Space, lazy tree, observation, and call runtime
│ ├── wip-http │ ├── wip-http
@@ -41,13 +41,13 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
| Crate | Responsibility | Does not own | | Crate | Responsibility | Does not own |
| --- | --- | --- | | --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. | | `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. | | `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. | | `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. | | `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. | | `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch/batch handling, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. | | `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. | | `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a The boundary is intentional: protocol types describe what can cross a
+1 -1
View File
@@ -16,7 +16,7 @@ use wip_protocol::{
}; };
fn limits() -> Limits { fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap() Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
} }
fn setup() -> (Client, wip_client::SessionId) { fn setup() -> (Client, wip_client::SessionId) {
+62 -1
View File
@@ -17,7 +17,7 @@ use wip_protocol::{
}; };
fn wire_limits() -> Limits { fn wire_limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap() Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
} }
#[derive(Clone)] #[derive(Clone)]
@@ -399,6 +399,67 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
)); ));
} }
#[test]
fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let first = client.refresh_observed(&session, "/one", 0).unwrap();
let second = client.refresh_observed(&session, "/two", 0).unwrap();
let interface = client.prepare_interface(&session, "math").unwrap();
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
assert_eq!(interface.request.uri().path(), "/wip/v1/fetch_interface");
assert_eq!(client.in_flight_len(), 3);
client
.complete(
second.id,
observation_response("/two", &object("two", None, b"two")),
)
.unwrap();
client
.complete(
interface.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: "math".into(),
},
&FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: Some(b"math-v1".to_vec()),
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
client
.complete(
first.id,
observation_response("/one", &object("one", None, b"one")),
)
.unwrap();
assert_eq!(
client.object(&session, "/one").unwrap().state,
ObservationState::Fresh
);
assert_eq!(
client.object(&session, "/two").unwrap().state,
ObservationState::Fresh
);
assert_eq!(
client.interface(&session, "math").unwrap().state,
ObservationState::Fresh
);
assert_eq!(client.in_flight_len(), 0);
}
#[test] #[test]
fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() { fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
let mut client = client(8, 8, 8, 8); let mut client = client(8, 8, 8, 8);
+3 -6
View File
@@ -4,7 +4,7 @@
Worldspace `/` at request time. It does not register the filesystem contents or Worldspace `/` at request time. It does not register the filesystem contents or
keep one object instance per entry. It uses `wip-host` for publication and typed keep one object instance per entry. It uses `wip-host` for publication and typed
operation dispatch and uses the standard `wip-http` routes, JSON codecs, operation dispatch and uses the standard `wip-http` routes, JSON codecs,
envelopes, batching, and HTTP status mapping. envelopes, and HTTP status mapping.
## Security model ## Security model
@@ -118,14 +118,11 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
}' }'
``` ```
`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 ## Resource and failure behavior
`ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON `ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON
nesting, batch items, file read/write bytes, tree depth/node count, pinned handles, nesting, file read/write bytes, tree depth/node count, pinned handles, and active
and active connections/requests. One absolute request deadline covers header and connections/requests. One absolute request deadline covers header and
body intake, dispatch, cooperative filesystem work, and response transmission; body intake, dispatch, cooperative filesystem work, and response transmission;
stalled clients are closed and cannot occupy a worker beyond that bound. Workers stalled clients are closed and cannot occupy a worker beyond that bound. Workers
accept connections directly, so the listener never moves more than the configured accept connections directly, so the listener never moves more than the configured
+3 -3
View File
@@ -4,7 +4,7 @@
//! //!
//! - [`wip_protocol`] owns logical requests and responses; //! - [`wip_protocol`] owns logical requests and responses;
//! - [`wip_host`] owns object publication, validators, and typed dispatch; //! - [`wip_host`] owns object publication, validators, and typed dispatch;
//! - [`wip_http`] owns routes, JSON, envelopes, batching, and status mapping; //! - [`wip_http`] owns routes, JSON, envelopes, and status mapping;
//! - this crate owns the filesystem capability boundary and the real HTTP listener. //! - this crate owns the filesystem capability boundary and the real HTTP listener.
//! //!
//! The built-in HTTP listener is intentionally loopback-only. TLS termination, //! The built-in HTTP listener is intentionally loopback-only. TLS termination,
@@ -36,7 +36,7 @@ pub enum AccessMode {
/// Bounded resource policy applied by the HTTP adapter and filesystem provider. /// Bounded resource policy applied by the HTTP adapter and filesystem provider.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct ResourceLimits { pub struct ResourceLimits {
/// Standard JSON, body-size, nesting, and batch limits. /// Standard JSON, body-size, and nesting limits.
pub wire: wip_http::Limits, pub wire: wip_http::Limits,
/// Maximum bytes returned by one file read. /// Maximum bytes returned by one file read.
pub max_file_read_bytes: usize, pub max_file_read_bytes: usize,
@@ -59,7 +59,7 @@ pub struct ResourceLimits {
impl Default for ResourceLimits { impl Default for ResourceLimits {
fn default() -> Self { fn default() -> Self {
Self { Self {
wire: wip_http::Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64) wire: wip_http::Limits::new(256 * 1024, 2 * 1024 * 1024, 64)
.expect("default wire limits are nonzero"), .expect("default wire limits are nonzero"),
max_file_read_bytes: 1024 * 1024, max_file_read_bytes: 1024 * 1024,
max_file_write_bytes: 1024 * 1024, max_file_write_bytes: 1024 * 1024,
+6 -74
View File
@@ -9,11 +9,10 @@ use http::header::ALLOW;
use http::{Request, Response, StatusCode}; use http::{Request, Response, StatusCode};
use wip_host::{ExecutionContext, ObjectProvider}; use wip_host::{ExecutionContext, ObjectProvider};
use wip_http::{ use wip_http::{
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError, CodecError, EncodeHttpError, Endpoint, RequestMetadataError, Route,
Endpoint, RequestMetadataError, Route, decode_batch_request, decode_call_operation_metadata, decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
decode_fetch_interface_request, decode_observe_request, encode_batch_error_response, encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
encode_batch_response, encode_call_operation_response, encode_fetch_interface_response, encode_protocol_error_response, validate_http_request,
encode_observe_response, encode_protocol_error_response, validate_http_request,
}; };
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
@@ -81,7 +80,6 @@ impl WipFsServer {
Route::Observe => self.observe(request.body(), &context), Route::Observe => self.observe(request.body(), &context),
Route::FetchInterface => self.fetch_interface(request.body()), Route::FetchInterface => self.fetch_interface(request.body()),
Route::CallOperation => self.call_operation(request.body(), &context), Route::CallOperation => self.call_operation(request.body(), &context),
Route::Batch => self.batch(request.body(), &context),
}; };
if Instant::now() >= deadline { if Instant::now() >= deadline {
empty_response(StatusCode::GATEWAY_TIMEOUT) empty_response(StatusCode::GATEWAY_TIMEOUT)
@@ -115,12 +113,7 @@ impl WipFsServer {
if uri.query().is_some() { if uri.query().is_some() {
return None; return None;
} }
[ [Route::Observe, Route::FetchInterface, Route::CallOperation]
Route::Observe,
Route::FetchInterface,
Route::CallOperation,
Route::Batch,
]
.into_iter() .into_iter()
.find(|route| self.endpoint.route(*route).path() == uri.path()) .find(|route| self.endpoint.route(*route).path() == uri.path())
} }
@@ -221,41 +214,6 @@ impl WipFsServer {
} }
} }
fn batch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
let request = match decode_batch_request(body, self.config.limits.wire) {
Ok(request) => request,
Err(error) => return self.batch_codec_error(error),
};
let responses = request
.requests
.iter()
.map(|item| {
let outcome = match &item.request {
BatchRequestKind::Observe(request) => self
.projection
.observe(context, request)
.map(BatchSuccess::Observe),
BatchRequestKind::FetchInterface(request) => self
.projection
.fetch_interface(request)
.map(BatchSuccess::FetchInterface),
BatchRequestKind::Invalid { error, .. } => Err(error.clone()),
};
BatchResponseItem {
id: item.id.clone(),
outcome,
}
})
.collect();
let response = BatchResponse { responses };
encode_batch_response(&request, &response, self.config.limits.wire).unwrap_or_else(
|error| match error {
EncodeHttpError::Codec(codec) => self.batch_encode_error(&codec),
EncodeHttpError::Http(_) => self.batch_top_error(&internal_error()),
},
)
}
fn metadata_error(&self, error: RequestMetadataError) -> Response<Vec<u8>> { fn metadata_error(&self, error: RequestMetadataError) -> Response<Vec<u8>> {
match error { match error {
RequestMetadataError::NotFound => empty_response(StatusCode::NOT_FOUND), RequestMetadataError::NotFound => empty_response(StatusCode::NOT_FOUND),
@@ -294,17 +252,6 @@ impl WipFsServer {
} }
} }
fn batch_codec_error(&self, error: CodecError) -> Response<Vec<u8>> {
if is_transport_limit(&error) {
empty_response(StatusCode::PAYLOAD_TOO_LARGE)
} else {
self.batch_top_error(&ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: "malformed WIP batch request".into(),
})
}
}
fn read_encode_error( fn read_encode_error(
&self, &self,
interaction: ProtocolInteraction, interaction: ProtocolInteraction,
@@ -318,14 +265,6 @@ impl WipFsServer {
} }
} }
fn batch_encode_error(&self, error: &CodecError) -> Response<Vec<u8>> {
if is_transport_limit(error) {
empty_response(StatusCode::INTERNAL_SERVER_ERROR)
} else {
self.batch_top_error(&internal_error())
}
}
fn protocol_error( fn protocol_error(
&self, &self,
interaction: ProtocolInteraction, interaction: ProtocolInteraction,
@@ -334,11 +273,6 @@ impl WipFsServer {
encode_protocol_error_response(interaction, error, self.config.limits.wire) encode_protocol_error_response(interaction, error, self.config.limits.wire)
.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR)) .unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
} }
fn batch_top_error(&self, error: &ProtocolError) -> Response<Vec<u8>> {
encode_batch_error_response(error, self.config.limits.wire)
.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
}
} }
/// Bound real HTTP listener for a [`WipFsServer`]. /// Bound real HTTP listener for a [`WipFsServer`].
@@ -629,9 +563,7 @@ fn write_all_with_deadline(
fn is_transport_limit(error: &CodecError) -> bool { fn is_transport_limit(error: &CodecError) -> bool {
matches!( matches!(
error, error,
CodecError::BodyTooLarge { .. } CodecError::BodyTooLarge { .. } | CodecError::NestingTooDeep { .. }
| CodecError::NestingTooDeep { .. }
| CodecError::TooManyBatchItems { .. }
) )
} }
+9 -92
View File
@@ -9,11 +9,9 @@ use std::time::{Duration, Instant};
use tempfile::tempdir; use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer}; use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_http::{ use wip_http::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess,
ClientResponseError, DecodedResponse, Endpoint, JSON_CONTENT_TYPE, Limits, Route, ClientResponseError, DecodedResponse, Endpoint, JSON_CONTENT_TYPE, Limits, Route,
TransportFailureKind, decode_batch_request, decode_batch_response, TransportFailureKind, decode_call_operation_response, decode_fetch_interface_response,
decode_call_operation_response, decode_fetch_interface_response, decode_observe_response, decode_observe_response, encode_call_operation_request, encode_fetch_interface_request,
encode_batch_request, encode_call_operation_request, encode_fetch_interface_request,
encode_observe_request, encode_observe_request,
}; };
use wip_protocol::{ use wip_protocol::{
@@ -22,7 +20,7 @@ use wip_protocol::{
}; };
#[test] #[test]
fn real_listener_serves_three_core_interactions_batch_and_write() { fn real_listener_serves_three_core_interactions_and_write() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file"); std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file");
std::fs::create_dir(temporary.path().join("nested")).expect("create directory"); std::fs::create_dir(temporary.path().join("nested")).expect("create directory");
@@ -125,36 +123,6 @@ fn real_listener_serves_three_core_interactions_batch_and_write() {
); );
assert_eq!(read.result, Value::Bytes(b"world".to_vec())); assert_eq!(read.result, Value::Bytes(b"world".to_vec()));
let batch_request = BatchRequest {
requests: vec![
BatchRequestItem {
id: "root".into(),
request: BatchRequestKind::Observe(ObserveRequest {
path: "/".into(),
depth: 0,
}),
},
BatchRequestItem {
id: "tree".into(),
request: BatchRequestKind::Observe(ObserveRequest {
path: "/nested".into(),
depth: 1,
}),
},
BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(read_interface_request.clone()),
},
],
};
let response = exchange(
address,
encode_batch_request(&endpoint, &batch_request, limits).expect("encode batch"),
);
let batch =
success(decode_batch_response(&batch_request, &response, limits).expect("decode batch"));
assert_batch_successes(&batch);
let write_interface_request = FetchInterfaceRequest { let write_interface_request = FetchInterfaceRequest {
interface: file.interfaces[1].clone(), interface: file.interfaces[1].clone(),
}; };
@@ -215,7 +183,7 @@ fn real_listener_serves_three_core_interactions_batch_and_write() {
fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() { fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.limits.wire = Limits::new(64, 4_096, 64, 4).expect("wire limits"); config.limits.wire = Limits::new(64, 4_096, 64).expect("wire limits");
let bound = Arc::new( let bound = Arc::new(
WipFsServer::new(config) WipFsServer::new(config)
.expect("build server") .expect("build server")
@@ -232,6 +200,7 @@ fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() {
for uri in [ for uri in [
"/wip/v1/unknown", "/wip/v1/unknown",
"/wip/v1/batch",
"/wip/v1/observe?unexpected=true", "/wip/v1/observe?unexpected=true",
"/wip/v1/observe/extra", "/wip/v1/observe/extra",
] { ] {
@@ -239,6 +208,7 @@ fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() {
assert_eq!(response.status(), http::StatusCode::NOT_FOUND); assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
assert!(response.body().is_empty()); assert!(response.body().is_empty());
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE)); assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
assert!(!response.headers().contains_key(http::header::ALLOW));
} }
let wrong_method = exchange( let wrong_method = exchange(
@@ -326,10 +296,10 @@ fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() {
} }
#[test] #[test]
fn raw_decoder_and_batch_packaging_limits_are_transport_failures() { fn raw_decoder_limits_are_transport_failures() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.limits.wire = Limits::new(4_096, 4_096, 8, 1).expect("wire limits"); config.limits.wire = Limits::new(4_096, 4_096, 8).expect("wire limits");
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let nested = br#"{"path":[[[[[[[[["/"]]]]]]]]],"depth":0}"#; let nested = br#"{"path":[[[[[[[[["/"]]]]]]]]],"depth":0}"#;
@@ -337,12 +307,6 @@ fn raw_decoder_and_batch_packaging_limits_are_transport_failures() {
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
assert!(response.body().is_empty()); assert!(response.body().is_empty());
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE)); assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
let batch = br#"{"requests":[{"id":"one","method":"observe","arguments":{"path":"/","depth":0}},{"id":"two","method":"observe","arguments":{"path":"/","depth":0}}]}"#;
let response = server.handle_http(json_request(http::Method::POST, "/wip/v1/batch", batch));
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
assert!(response.body().is_empty());
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
} }
#[test] #[test]
@@ -442,37 +406,6 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
.expect("listener exits cleanly"); .expect("listener exits cleanly");
} }
#[test]
fn batch_keeps_known_malformed_arguments_item_local() {
let temporary = tempdir().expect("temporary root");
let config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
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,
BatchRequestKind::Invalid { .. }
));
let request = http::Request::builder()
.method(http::Method::POST)
.uri(endpoint.route(Route::Batch).as_str())
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
.header(http::header::ACCEPT, "application/json")
.body(body.to_vec())
.expect("build request");
let response = server.handle_http(request);
let batch = success(
decode_batch_response(&logical, &response, limits).expect("decode mixed batch response"),
);
assert!(matches!(
batch.responses[0].outcome,
Ok(BatchSuccess::Observe(_))
));
assert!(batch.responses[1].outcome.is_err());
}
#[test] #[test]
fn call_argument_decoding_does_not_mask_missing_target() { fn call_argument_decoding_does_not_mask_missing_target() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
@@ -524,7 +457,7 @@ fn read_response_size_failure_is_transport_local() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
std::fs::write(temporary.path().join("large"), vec![b'x'; 2_000]).expect("write file"); std::fs::write(temporary.path().join("large"), vec![b'x'; 2_000]).expect("write file");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits"); config.limits.wire = Limits::new(4_096, 1_024, 64).expect("wire limits");
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
@@ -579,22 +512,6 @@ fn read_response_size_failure_is_transport_local() {
)); ));
} }
fn assert_batch_successes(batch: &BatchResponse) {
assert_eq!(batch.responses.len(), 3);
assert!(matches!(
batch.responses[0].outcome,
Ok(BatchSuccess::Observe(_))
));
assert!(matches!(
batch.responses[1].outcome,
Ok(BatchSuccess::Observe(_))
));
assert!(matches!(
batch.responses[2].outcome,
Ok(BatchSuccess::FetchInterface(_))
));
}
fn success<T>(response: DecodedResponse<T>) -> T { fn success<T>(response: DecodedResponse<T>) -> T {
match response { match response {
DecodedResponse::Success(value) => value, DecodedResponse::Success(value) => value,
-548
View File
@@ -1,548 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use http::header::{ACCEPT, CONTENT_TYPE};
use http::{Method, Request, Response, StatusCode};
use serde_json::{Map, Value as Json};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, ObserveRequest,
ObserveResponse, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
};
use crate::response::{self, DecodedResponse};
use crate::{
BodyKind, ClientResponseError, CodecError, EncodeHttpError, Endpoint, InvalidResponseKind,
JSON_CONTENT_TYPE, Limits, Route, codec::has_supported_content_coding, is_json_content_type,
json,
};
/// A retrieval method accepted by the standard v1 batch endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchMethod {
/// Observe one object and its indexable descendants.
Observe,
/// Fetch one interface descriptor.
FetchInterface,
}
impl BatchMethod {
fn name(self) -> &'static str {
match self {
Self::Observe => "observe",
Self::FetchInterface => "fetch_interface",
}
}
fn interaction(self) -> ProtocolInteraction {
match self {
Self::Observe => ProtocolInteraction::Observe,
Self::FetchInterface => ProtocolInteraction::FetchInterface,
}
}
}
/// One retrieval interaction accepted by the standard v1 batch endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchRequestKind {
/// 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.
Invalid {
/// Method that was successfully identified from the item envelope.
method: BatchMethod,
/// Item-local `invalid_request` to return without rejecting other items.
error: ProtocolError,
},
}
impl BatchRequestKind {
fn interaction(&self) -> ProtocolInteraction {
match self {
Self::Observe(_) => ProtocolInteraction::Observe,
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
Self::Invalid { method, .. } => method.interaction(),
}
}
}
/// One uniquely identified batch subrequest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchRequestItem {
/// Identifier used only to correlate the corresponding subresponse.
pub id: String,
/// Retrieval interaction and arguments.
pub request: BatchRequestKind,
}
/// A standard v1 batch request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchRequest {
/// Independent subrequests. Identifiers must be unique.
pub requests: Vec<BatchRequestItem>,
}
/// Successful result of one batch item.
#[derive(Debug, Clone, PartialEq)]
pub enum BatchSuccess {
/// Successful `observe` result.
Observe(ObserveResponse),
/// Successful `fetch_interface` result.
FetchInterface(FetchInterfaceResponse),
}
/// One batch subresponse, correlated by `id` rather than array position.
#[derive(Debug, Clone, PartialEq)]
pub struct BatchResponseItem {
/// Identifier copied from its subrequest.
pub id: String,
/// Direct interaction result or canonical item-local protocol failure.
pub outcome: Result<BatchSuccess, ProtocolError>,
}
/// A valid standard v1 batch response.
#[derive(Debug, Clone, PartialEq)]
pub struct BatchResponse {
/// Exactly one response for each requested identifier, in any order.
pub responses: Vec<BatchResponseItem>,
}
fn validate_ids<'a>(ids: impl Iterator<Item = &'a str>, maximum: usize) -> Result<(), CodecError> {
let mut seen = BTreeSet::new();
let mut count = 0;
for id in ids {
count += 1;
if !seen.insert(id) {
return Err(CodecError::DuplicateBatchId { id: id.into() });
}
}
if count > maximum {
return Err(CodecError::TooManyBatchItems {
actual: count,
maximum,
});
}
Ok(())
}
fn request_item_to_json(item: &BatchRequestItem) -> Result<Json, CodecError> {
let (method, arguments) = match &item.request {
BatchRequestKind::Observe(value) => ("observe", json::observe_request_to_json(value)),
BatchRequestKind::FetchInterface(value) => (
"fetch_interface",
json::fetch_interface_request_to_json(value),
),
BatchRequestKind::Invalid { .. } => {
return Err(CodecError::InvalidField {
field: "requests".into(),
reason: "item-local invalid requests cannot be encoded as client requests".into(),
});
}
};
Ok(serde_json::json!({"id": item.id, "method": method, "arguments": arguments}))
}
fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, CodecError> {
validate_ids(
request.requests.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
for item in &request.requests {
match &item.request {
BatchRequestKind::Observe(value) => value.validate()?,
BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {}
}
}
let requests = request
.requests
.iter()
.map(request_item_to_json)
.collect::<Result<Vec<_>, _>>()?;
Ok(serde_json::json!({"requests": requests}))
}
fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, CodecError> {
let fields = exact_object(value, "batch", &["requests"])?;
let values = array(required(fields, "requests")?, "requests")?;
if values.len() > limits.max_batch_items {
return Err(CodecError::TooManyBatchItems {
actual: values.len(),
maximum: limits.max_batch_items,
});
}
let mut requests = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let field = format!("requests[{index}]");
let fields = exact_object(value, &field, &["id", "method", "arguments"])?;
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
let method_name = string(required(fields, "method")?, &format!("{field}.method"))?;
let arguments = required(fields, "arguments")?;
let method = match method_name.as_str() {
"observe" => BatchMethod::Observe,
"fetch_interface" => BatchMethod::FetchInterface,
_ => {
return Err(CodecError::InvalidField {
field: format!("{field}.method"),
reason: "batch supports only observe and fetch_interface".into(),
});
}
};
let decoded = match method {
BatchMethod::Observe => {
json::observe_request_from_json(arguments).map(BatchRequestKind::Observe)
}
BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments)
.map(BatchRequestKind::FetchInterface),
};
let request = decoded.unwrap_or_else(|error| BatchRequestKind::Invalid {
method,
error: ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: format!("invalid {} arguments: {error}", method.name()),
},
});
requests.push(BatchRequestItem { id, request });
}
validate_ids(
requests.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
Ok(BatchRequest { requests })
}
/// Encodes a batch request using `POST v1/batch`.
pub fn encode_batch_request(
endpoint: &Endpoint,
value: &BatchRequest,
limits: Limits,
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
let body = json::serialize(&request_to_json(value, limits)?, BodyKind::Request, limits)?;
Ok(Request::builder()
.method(Method::POST)
.uri(endpoint.route(Route::Batch).as_str())
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.header(ACCEPT, "application/json")
.body(body)?)
}
/// Decodes and validates a batch request body.
pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result<BatchRequest, CodecError> {
let value = json::parse(body, BodyKind::Request, limits)?;
request_from_json(&value, limits)
}
fn success_to_json(success: &BatchSuccess) -> Json {
match success {
BatchSuccess::Observe(value) => json::observe_response_to_json(value),
BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value),
}
}
fn response_to_json(
request: &BatchRequest,
response: &BatchResponse,
limits: Limits,
) -> Result<Json, CodecError> {
correlate(request, response, limits)?;
Ok(serde_json::json!({
"responses": response.responses.iter().map(|item| {
let mut fields = Map::new();
fields.insert("id".into(), Json::String(item.id.clone()));
match &item.outcome {
Ok(success) => { fields.insert("result".into(), success_to_json(success)); }
Err(error) => { fields.insert("error".into(), response::item_error_to_json(error)); }
}
Json::Object(fields)
}).collect::<Vec<_>>()
}))
}
fn correlate(
request: &BatchRequest,
response: &BatchResponse,
limits: Limits,
) -> Result<(), CodecError> {
validate_ids(
request.requests.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
validate_ids(
response.responses.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
let requested: BTreeMap<&str, &BatchRequestKind> = request
.requests
.iter()
.map(|item| (item.id.as_str(), &item.request))
.collect();
for item in &response.responses {
let Some(kind) = requested.get(item.id.as_str()) else {
return Err(CodecError::UnexpectedBatchId {
id: item.id.clone(),
});
};
match (&item.outcome, *kind) {
(Ok(BatchSuccess::Observe(value)), BatchRequestKind::Observe(request)) => {
request.validate_response(value)?;
}
(
Ok(BatchSuccess::FetchInterface(value)),
BatchRequestKind::FetchInterface(request),
) => {
value.validate_for(request)?;
}
(
Err(error),
BatchRequestKind::Invalid {
error: expected, ..
},
) if error == expected => {}
(Err(error), kind)
if !matches!(kind, BatchRequestKind::Invalid { .. })
&& error.code.is_allowed_for(kind.interaction()) => {}
_ => {
return Err(CodecError::BatchInteractionMismatch {
id: item.id.clone(),
});
}
}
}
for item in &request.requests {
if !response.responses.iter().any(|other| other.id == item.id) {
return Err(CodecError::MissingBatchId {
id: item.id.clone(),
});
}
}
Ok(())
}
/// Encodes a valid batch response. Item failures remain inside HTTP 200.
pub fn encode_batch_response(
request: &BatchRequest,
value: &BatchResponse,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
let body = json::serialize(
&response_to_json(request, value, limits)?,
BodyKind::Response,
limits,
)?;
Ok(Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.body(body)?)
}
/// Encodes a top-level batch-envelope failure.
///
/// Item-local failures belong in [`BatchResponseItem`]. Only `invalid_request`
/// and `internal` may reject the complete batch as protocol failures; packaging
/// and representation limits remain transport-local.
pub fn encode_batch_error_response(
error: &ProtocolError,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
if !matches!(
error.code,
ProtocolErrorCode::InvalidRequest | ProtocolErrorCode::Internal
) {
return Err(CodecError::InvalidField {
field: "error.code".into(),
reason: "code is not valid for a top-level batch failure".into(),
}
.into());
}
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
Ok(Response::builder()
.status(response::status_for_error(error.code))
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.body(body)?)
}
fn response_from_json(
value: &Json,
request: &BatchRequest,
limits: Limits,
) -> Result<BatchResponse, CodecError> {
let fields = exact_object(value, "batch", &["responses"])?;
let values = array(required(fields, "responses")?, "responses")?;
if values.len() > limits.max_batch_items {
return Err(CodecError::TooManyBatchItems {
actual: values.len(),
maximum: limits.max_batch_items,
});
}
let requested: BTreeMap<&str, &BatchRequestKind> = request
.requests
.iter()
.map(|item| (item.id.as_str(), &item.request))
.collect();
let mut responses = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let field = format!("responses[{index}]");
let fields = object(value, &field)?;
for name in fields.keys() {
if !["id", "result", "error"].contains(&name.as_str()) {
return Err(CodecError::UnknownField {
field: format!("{field}.{name}"),
});
}
}
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
let Some(kind) = requested.get(id.as_str()) else {
return Err(CodecError::UnexpectedBatchId { id });
};
let outcome = match (fields.get("result"), fields.get("error")) {
(Some(_), Some(_)) | (None, None) => {
return Err(CodecError::InvalidField {
field,
reason: "batch item must contain exactly one of result or error".into(),
});
}
(None, Some(error)) => {
let error = response::item_error_from_json(error)?;
match kind {
BatchRequestKind::Invalid {
error: expected, ..
} if error == *expected => {}
BatchRequestKind::Invalid { .. } => {
return Err(CodecError::BatchInteractionMismatch { id });
}
kind if error.code.is_allowed_for(kind.interaction()) => {}
_ => return Err(CodecError::BatchInteractionMismatch { id }),
}
Err(error)
}
(Some(result), None) => Ok(match kind {
BatchRequestKind::Observe(request) => {
let value = json::observe_response_from_json(result)?;
request.validate_response(&value)?;
BatchSuccess::Observe(value)
}
BatchRequestKind::FetchInterface(request) => {
let value = json::fetch_interface_response_from_json(result)?;
value.validate_for(request)?;
BatchSuccess::FetchInterface(value)
}
BatchRequestKind::Invalid { .. } => {
return Err(CodecError::BatchInteractionMismatch { id });
}
}),
};
responses.push(BatchResponseItem { id, outcome });
}
let response = BatchResponse { responses };
correlate(request, &response, limits)?;
Ok(response)
}
/// Decodes a batch response, including item-local failures within HTTP 200.
pub fn decode_batch_response<B: AsRef<[u8]>>(
request: &BatchRequest,
value: &Response<B>,
limits: Limits,
) -> Result<DecodedResponse<BatchResponse>, ClientResponseError> {
if !has_supported_content_coding(value.headers()) {
return Err(response::unsupported_content_coding(value.status()));
}
let valid_content_type = value
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(is_json_content_type);
if !valid_content_type {
if value.status().is_success() {
return Err(ClientResponseError::invalid(
InvalidResponseKind::InvalidBatch,
"missing or non-UTF-8 application/json Content-Type",
));
}
return Err(response::transport_failure(value.status()));
}
let decoded = response::decode(
value.status(),
value.body().as_ref(),
ProtocolInteraction::Observe,
limits,
|json| response_from_json(json, request, limits),
)
.map_err(|error| match error {
ClientResponseError::InvalidResponse { detail, .. } => {
ClientResponseError::invalid(InvalidResponseKind::InvalidBatch, detail)
}
other => other,
})?;
if let DecodedResponse::ProtocolFailure { error, .. } = &decoded
&& !matches!(
error.code,
ProtocolErrorCode::InvalidRequest | ProtocolErrorCode::Internal
)
{
return Err(ClientResponseError::invalid(
InvalidResponseKind::InvalidBatch,
"protocol code is not valid for a top-level batch failure",
));
}
if let DecodedResponse::Success(success) = &decoded {
for item in &success.responses {
if let Ok(BatchSuccess::FetchInterface(interface)) = &item.outcome
&& interface.descriptor.format != INTERFACE_FORMAT_V1
{
return Err(ClientResponseError::UnsupportedDescriptorFormat {
format: interface.descriptor.format.clone(),
});
}
}
}
Ok(decoded)
}
fn object<'a>(value: &'a Json, field: &str) -> Result<&'a Map<String, Json>, CodecError> {
value.as_object().ok_or_else(|| CodecError::InvalidField {
field: field.into(),
reason: "expected object".into(),
})
}
fn exact_object<'a>(
value: &'a Json,
field: &str,
allowed: &[&str],
) -> Result<&'a Map<String, Json>, CodecError> {
let fields = object(value, field)?;
for name in fields.keys() {
if !allowed.contains(&name.as_str()) {
return Err(CodecError::UnknownField {
field: format!("{field}.{name}"),
});
}
}
Ok(fields)
}
fn required<'a>(fields: &'a Map<String, Json>, name: &str) -> Result<&'a Json, CodecError> {
fields
.get(name)
.ok_or_else(|| CodecError::MissingField { field: name.into() })
}
fn string(value: &Json, field: &str) -> Result<String, CodecError> {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| CodecError::InvalidField {
field: field.into(),
reason: "expected string".into(),
})
}
fn array<'a>(value: &'a Json, field: &str) -> Result<&'a [Json], CodecError> {
value
.as_array()
.map(Vec::as_slice)
.ok_or_else(|| CodecError::InvalidField {
field: field.into(),
reason: "expected array".into(),
})
}
+1 -9
View File
@@ -15,8 +15,6 @@ pub enum Route {
FetchInterface, FetchInterface,
/// Call one operation. /// Call one operation.
CallOperation, CallOperation,
/// Package independent retrieval interactions.
Batch,
} }
impl Route { impl Route {
@@ -27,7 +25,6 @@ impl Route {
Self::Observe => "v1/observe", Self::Observe => "v1/observe",
Self::FetchInterface => "v1/fetch_interface", Self::FetchInterface => "v1/fetch_interface",
Self::CallOperation => "v1/call_operation", Self::CallOperation => "v1/call_operation",
Self::Batch => "v1/batch",
} }
} }
} }
@@ -82,12 +79,7 @@ impl Endpoint {
/// are not route components and therefore never match. /// are not route components and therefore never match.
#[must_use] #[must_use]
pub fn recognize(&self, candidate: &Url) -> Option<Route> { pub fn recognize(&self, candidate: &Url) -> Option<Route> {
const ROUTES: [Route; 4] = [ const ROUTES: [Route; 3] = [Route::Observe, Route::FetchInterface, Route::CallOperation];
Route::Observe,
Route::FetchInterface,
Route::CallOperation,
Route::Batch,
];
if candidate.query().is_some() || candidate.fragment().is_some() { if candidate.query().is_some() || candidate.fragment().is_some() {
return None; return None;
} }
-52
View File
@@ -10,8 +10,6 @@ pub struct Limits {
pub(crate) max_response_bytes: usize, pub(crate) max_response_bytes: usize,
/// Largest accepted JSON container nesting depth. /// Largest accepted JSON container nesting depth.
pub(crate) max_nesting: usize, pub(crate) max_nesting: usize,
/// Largest accepted number of batch items.
pub(crate) max_batch_items: usize,
} }
impl Limits { impl Limits {
@@ -20,7 +18,6 @@ impl Limits {
max_request_bytes: usize, max_request_bytes: usize,
max_response_bytes: usize, max_response_bytes: usize,
max_nesting: usize, max_nesting: usize,
max_batch_items: usize,
) -> Result<Self, LimitConfigurationError> { ) -> Result<Self, LimitConfigurationError> {
if max_request_bytes == 0 { if max_request_bytes == 0 {
return Err(LimitConfigurationError::ZeroRequestBytes); return Err(LimitConfigurationError::ZeroRequestBytes);
@@ -31,14 +28,10 @@ impl Limits {
if max_nesting == 0 { if max_nesting == 0 {
return Err(LimitConfigurationError::ZeroNesting); return Err(LimitConfigurationError::ZeroNesting);
} }
if max_batch_items == 0 {
return Err(LimitConfigurationError::ZeroBatchItems);
}
Ok(Self { Ok(Self {
max_request_bytes, max_request_bytes,
max_response_bytes, max_response_bytes,
max_nesting, max_nesting,
max_batch_items,
}) })
} }
@@ -59,12 +52,6 @@ impl Limits {
pub const fn max_nesting(self) -> usize { pub const fn max_nesting(self) -> usize {
self.max_nesting self.max_nesting
} }
/// Returns the batch item count limit.
#[must_use]
pub const fn max_batch_items(self) -> usize {
self.max_batch_items
}
} }
/// Why a [`Limits`] configuration is not bounded. /// Why a [`Limits`] configuration is not bounded.
@@ -76,8 +63,6 @@ pub enum LimitConfigurationError {
ZeroResponseBytes, ZeroResponseBytes,
/// Nesting limit was zero. /// Nesting limit was zero.
ZeroNesting, ZeroNesting,
/// Batch-item limit was zero.
ZeroBatchItems,
} }
impl Display for LimitConfigurationError { impl Display for LimitConfigurationError {
@@ -86,7 +71,6 @@ impl Display for LimitConfigurationError {
Self::ZeroRequestBytes => "request byte limit must be nonzero", Self::ZeroRequestBytes => "request byte limit must be nonzero",
Self::ZeroResponseBytes => "response byte limit must be nonzero", Self::ZeroResponseBytes => "response byte limit must be nonzero",
Self::ZeroNesting => "nesting limit must be nonzero", Self::ZeroNesting => "nesting limit must be nonzero",
Self::ZeroBatchItems => "batch item limit must be nonzero",
}) })
} }
} }
@@ -148,33 +132,6 @@ pub enum CodecError {
}, },
/// Logical protocol validation rejected the decoded representation. /// Logical protocol validation rejected the decoded representation.
ProtocolValidation(wip_protocol::ValidationError), ProtocolValidation(wip_protocol::ValidationError),
/// The batch exceeds its configured item count.
TooManyBatchItems {
/// Actual item count.
actual: usize,
/// Configured maximum.
maximum: usize,
},
/// Batch item identifiers are not unique.
DuplicateBatchId {
/// Repeated identifier.
id: String,
},
/// A response contains a batch identifier that was not requested.
UnexpectedBatchId {
/// Unexpected identifier.
id: String,
},
/// A response omitted a requested batch identifier.
MissingBatchId {
/// Missing identifier.
id: String,
},
/// A batch item result does not match its requested interaction.
BatchInteractionMismatch {
/// Item identifier.
id: String,
},
} }
impl Display for CodecError { impl Display for CodecError {
@@ -202,15 +159,6 @@ impl Display for CodecError {
write!(formatter, "field `{field}` is not canonical base64") write!(formatter, "field `{field}` is not canonical base64")
} }
Self::ProtocolValidation(error) => error.fmt(formatter), Self::ProtocolValidation(error) => error.fmt(formatter),
Self::TooManyBatchItems { actual, maximum } => {
write!(formatter, "batch has {actual} items; maximum is {maximum}")
}
Self::DuplicateBatchId { id } => write!(formatter, "duplicate batch id `{id}`"),
Self::UnexpectedBatchId { id } => write!(formatter, "unexpected batch id `{id}`"),
Self::MissingBatchId { id } => write!(formatter, "missing batch id `{id}`"),
Self::BatchInteractionMismatch { id } => {
write!(formatter, "batch result does not match request `{id}`")
}
} }
} }
} }
+6 -12
View File
@@ -2,11 +2,11 @@
//! //!
//! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical //! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical
//! success and error response handling, HTTP status mapping, descriptor-guided //! success and error response handling, HTTP status mapping, descriptor-guided
//! operation-value conversion, and retrieval batching. It deliberately performs //! operation-value conversion. It deliberately performs no I/O and depends on no
//! no I/O and depends on no server framework, TLS implementation, filesystem //! server framework, TLS implementation, filesystem projection, client cache, or
//! projection, client cache, or presentation layer. URL schemes, TLS, //! presentation layer. URL schemes, TLS, certificates, authentication mechanisms,
//! certificates, authentication mechanisms, proxies, and caches are selected by //! proxies, and caches are selected by the HTTP implementation and deployment
//! the HTTP implementation and deployment rather than by WIP conformance. //! rather than by WIP conformance.
//! //!
//! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are //! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate //! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
@@ -17,7 +17,7 @@
//! use wip_protocol::ObserveRequest; //! use wip_protocol::ObserveRequest;
//! //!
//! let endpoint = Endpoint::parse("http://example.test/api/wip")?; //! let endpoint = Endpoint::parse("http://example.test/api/wip")?;
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?; //! let limits = Limits::new(64 * 1024, 1024 * 1024, 64)?;
//! let request = encode_observe_request( //! let request = encode_observe_request(
//! &endpoint, //! &endpoint,
//! &ObserveRequest { path: "/items/123".into(), depth: 0 }, //! &ObserveRequest { path: "/items/123".into(), depth: 0 },
@@ -30,18 +30,12 @@
#![deny(missing_docs)] #![deny(missing_docs)]
mod batch;
mod codec; mod codec;
mod endpoint; mod endpoint;
mod error; mod error;
mod json; mod json;
mod response; mod response;
pub use batch::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, decode_batch_request, decode_batch_response,
encode_batch_error_response, encode_batch_request, encode_batch_response,
};
pub use codec::{ pub use codec::{
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata, CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request, decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
+2 -11
View File
@@ -65,8 +65,6 @@ pub enum InvalidResponseKind {
NonCanonicalSuccessStatus, NonCanonicalSuccessStatus,
/// A 2xx body was malformed or semantically invalid. /// A 2xx body was malformed or semantically invalid.
InvalidSuccessBody, InvalidSuccessBody,
/// A batch response did not correlate exactly with its request.
InvalidBatch,
} }
/// A local failure while classifying or validating a peer response. /// A local failure while classifying or validating a peer response.
@@ -197,14 +195,7 @@ pub(crate) fn error_to_json(error: &ProtocolError) -> Json {
}) })
} }
pub(crate) fn item_error_to_json(error: &ProtocolError) -> Json { fn protocol_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
serde_json::json!({
"code": error_code_name(error.code),
"message": error.message,
})
}
pub(crate) fn item_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
let fields = value let fields = value
.as_object() .as_object()
.ok_or_else(|| invalid_field("error", "expected object"))?; .ok_or_else(|| invalid_field("error", "expected object"))?;
@@ -223,7 +214,7 @@ fn envelope_error_from_json(value: &Json) -> Result<Option<ProtocolError>, Codec
if fields.len() != 1 || !fields.contains_key("error") { if fields.len() != 1 || !fields.contains_key("error") {
return Ok(None); return Ok(None);
} }
item_error_from_json(&fields["error"]).map(Some) protocol_error_from_json(&fields["error"]).map(Some)
} }
fn exact(fields: &Map<String, Json>, allowed: &[&str], parent: &str) -> Result<(), CodecError> { fn exact(fields: &Map<String, Json>, allowed: &[&str], parent: &str) -> Result<(), CodecError> {
-408
View File
@@ -1,408 +0,0 @@
use wip_http::http::StatusCode;
use wip_http::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode,
};
use wip_http::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
InvalidResponseKind, Limits, TransportFailureKind, decode_batch_request, decode_batch_response,
encode_batch_error_response, encode_batch_request, encode_batch_response,
};
fn limits() -> Limits {
Limits::new(100_000, 100_000, 64, 4).unwrap()
}
fn observed_item(validator: Option<Vec<u8>>) -> ObjectObservation {
ObjectObservation {
object: Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator,
},
children: None,
}
}
fn request() -> BatchRequest {
BatchRequest {
requests: vec![
BatchRequestItem {
id: "object".into(),
request: BatchRequestKind::Observe(ObserveRequest {
path: "/item".into(),
depth: 0,
}),
},
BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
},
],
}
}
#[test]
fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
let request = request();
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap();
assert_eq!(encoded_request.uri().path(), "/wip/v1/batch");
assert_eq!(encoded_request.headers()["accept"], "application/json");
assert_eq!(
decode_batch_request(encoded_request.body(), limits()).unwrap(),
request
);
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "interface".into(),
outcome: Err(ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "gone".into(),
}),
},
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Observe(observed_item(Some(vec![
0, 1, 2, 255,
])))),
},
],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK);
assert!(
std::str::from_utf8(encoded.body())
.unwrap()
.contains("AAEC/w==")
);
assert_eq!(
decode_batch_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
}
#[test]
fn batch_interface_validator_round_trips_in_item_body() {
let request = BatchRequest {
requests: vec![BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
}],
};
let response = BatchResponse {
responses: vec![BatchResponseItem {
id: "interface".into(),
outcome: Ok(BatchSuccess::FetchInterface(FetchInterfaceResponse {
interface: "interface-v1".into(),
descriptor: InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![],
},
validator: Some(vec![255, 254, 253]),
})),
}],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
assert!(
std::str::from_utf8(encoded.body())
.unwrap()
.contains("//79")
);
assert_eq!(
decode_batch_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
}
#[test]
fn malformed_known_method_arguments_are_item_local() {
let body = br#"{
"requests": [
{"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::Observe(_)
));
let invalid = match &decoded.requests[1].request {
BatchRequestKind::Invalid { method, error } => {
assert_eq!(*method, BatchMethod::Observe);
assert_eq!(error.code, ProtocolErrorCode::InvalidRequest);
error.clone()
}
other => panic!("expected item-local invalid request, got {other:?}"),
};
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "valid".into(),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
},
BatchResponseItem {
id: "invalid".into(),
outcome: Err(invalid),
},
],
};
let encoded = encode_batch_response(&decoded, &response, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK);
assert_eq!(
decode_batch_response(&decoded, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
}
#[test]
fn invalid_batch_envelopes_are_rejected_at_top_level() {
assert!(decode_batch_request(br#"{}"#, limits()).is_err());
let malformed_item = br#"{
"requests": [
{"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":"observe","arguments":{"path":"/a","depth":0}},
{"id":"same","method":"observe","arguments":{"path":"/b","depth":0}}
]
}"#;
assert!(decode_batch_request(duplicate, limits()).is_err());
let operation = br#"{
"requests": [
{"id":"call","method":"call_operation","arguments":{}}
]
}"#;
assert!(decode_batch_request(operation, limits()).is_err());
let too_many = Limits::new(100_000, 100_000, 64, 1).unwrap();
assert!(decode_batch_request(duplicate, too_many).is_err());
}
#[test]
fn resource_limit_is_item_local_and_forbidden_as_a_top_level_batch_error() {
let error = ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded,
message: "logical observation limit exceeded".into(),
};
assert!(encode_batch_error_response(&error, limits()).is_err());
let request = request();
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "object".into(),
outcome: Err(error.clone()),
},
BatchResponseItem {
id: "interface".into(),
outcome: Err(ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "gone".into(),
}),
},
],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK);
assert_eq!(
decode_batch_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
let top_level = wip_http::http::Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header("content-type", "application/json; charset=utf-8")
.body(br#"{"error":{"code":"resource_limit_exceeded","message":"not allowed"}}"#.to_vec())
.unwrap();
assert!(matches!(
decode_batch_response(&request, &top_level, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
}
#[test]
fn batch_transport_failures_do_not_become_protocol_errors() {
let request = request();
let bare_413 = wip_http::http::Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.body(Vec::<u8>::new())
.unwrap();
assert!(matches!(
decode_batch_response(&request, &bare_413, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::PAYLOAD_TOO_LARGE
&& failure.kind == TransportFailureKind::HttpStatus
));
let mut encoded = encode_batch_response(
&request,
&BatchResponse {
responses: vec![
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
},
BatchResponseItem {
id: "interface".into(),
outcome: Err(ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "gone".into(),
}),
},
],
},
limits(),
)
.unwrap();
encoded
.headers_mut()
.insert("content-encoding", "gzip".parse().unwrap());
assert!(matches!(
decode_batch_response(&request, &encoded, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::OK
&& failure.kind == TransportFailureKind::UnsupportedContentCoding
));
}
#[test]
fn batch_success_body_requires_exactly_http_200() {
let request = request();
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
},
BatchResponseItem {
id: "interface".into(),
outcome: Err(ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "gone".into(),
}),
},
],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
let mut noncanonical = encoded.clone();
*noncanonical.status_mut() = status;
assert!(matches!(
decode_batch_response(&request, &noncanonical, limits()).unwrap_err(),
ClientResponseError::InvalidResponse { .. }
));
}
}
#[test]
fn top_level_batch_errors_use_the_batch_allowed_code_set() {
let invalid = ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: "bad envelope".into(),
};
let encoded = encode_batch_error_response(&invalid, limits()).unwrap();
assert_eq!(
decode_batch_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error: invalid,
status_mismatch: None,
}
);
let not_found = wip_http::http::Response::builder()
.status(StatusCode::NOT_FOUND)
.header("content-type", "application/json; charset=utf-8")
.body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec())
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &not_found, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
}
#[test]
fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
let duplicate = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future":true}}},{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &duplicate, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
let missing = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &missing, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
let request = BatchRequest {
requests: vec![BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
}],
};
assert!(matches!(
decode_batch_response(&request, &missing, limits()).unwrap_err(),
ClientResponseError::UnsupportedDescriptorFormat { format }
if format == "wip-interface/2"
));
}
#[test]
fn batch_response_must_have_exactly_the_requested_ids() {
let request = request();
let missing = BatchResponse {
responses: vec![BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
}],
};
assert!(encode_batch_response(&request, &missing, limits()).is_err());
}
+4 -4
View File
@@ -18,7 +18,7 @@ use wip_http::{
}; };
fn limits() -> Limits { fn limits() -> Limits {
Limits::new(1_000_000, 1_000_000, 100, 100).unwrap() Limits::new(1_000_000, 1_000_000, 100).unwrap()
} }
fn endpoint() -> Endpoint { fn endpoint() -> Endpoint {
@@ -405,16 +405,16 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
#[test] #[test]
fn caller_limits_remain_local_codec_failures() { fn caller_limits_remain_local_codec_failures() {
let tiny = Limits::new(8, 8, 2, 1).unwrap(); let tiny = Limits::new(8, 8, 2).unwrap();
let too_large = decode_observe_request(br#"{"path":"/long","depth":0}"#, tiny).unwrap_err(); let too_large = decode_observe_request(br#"{"path":"/long","depth":0}"#, tiny).unwrap_err();
assert!(matches!(&too_large, CodecError::BodyTooLarge { .. })); assert!(matches!(&too_large, CodecError::BodyTooLarge { .. }));
let nested = br#"{"path":[[["/"]]],"depth":0}"#; let nested = br#"{"path":[[["/"]]],"depth":0}"#;
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap(); let nesting_limited = Limits::new(1024, 1024, 2).unwrap();
let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err(); let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err();
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. })); assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap(); let outbound_limited = Limits::new(1_000_000, 1_000_000, 1).unwrap();
assert!( assert!(
encode_call_operation_request( encode_call_operation_request(
&endpoint(), &endpoint(),
+1 -1
View File
@@ -9,7 +9,7 @@ use wip_http::{
}; };
fn limits() -> Limits { fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap() Limits::new(64 * 1024, 64 * 1024, 32).unwrap()
} }
fn request() -> ObserveRequest { fn request() -> ObserveRequest {
+1 -1
View File
@@ -14,7 +14,6 @@ fn endpoint_preserves_http_and_https_when_resolving_canonical_routes() {
(Route::Observe, "v1/observe"), (Route::Observe, "v1/observe"),
(Route::FetchInterface, "v1/fetch_interface"), (Route::FetchInterface, "v1/fetch_interface"),
(Route::CallOperation, "v1/call_operation"), (Route::CallOperation, "v1/call_operation"),
(Route::Batch, "v1/batch"),
]; ];
for (route, suffix) in expected { for (route, suffix) in expected {
let url = format!("{scheme}://example.test/root/prefix/{suffix}"); let url = format!("{scheme}://example.test/root/prefix/{suffix}");
@@ -170,6 +169,7 @@ fn invalid_endpoint_components_and_non_routes_are_rejected() {
let endpoint = Endpoint::parse("http://example.test/wip").unwrap(); let endpoint = Endpoint::parse("http://example.test/wip").unwrap();
for outside in [ for outside in [
"http://example.test/wip/v1/unknown", "http://example.test/wip/v1/unknown",
"http://example.test/wip/v1/batch",
"http://example.test/wip/v1/observe/extra", "http://example.test/wip/v1/observe/extra",
"https://example.test/wip/v1/observe", "https://example.test/wip/v1/observe",
"http://other.test/wip/v1/observe", "http://other.test/wip/v1/observe",
+1 -1
View File
@@ -939,7 +939,7 @@ mod tests {
// A request identity has no public constructor; derive one through the runtime. // A request identity has no public constructor; derive one through the runtime.
let mut client = wip_client::Client::new( let mut client = wip_client::Client::new(
wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(), wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(),
wip_http::Limits::new(1024, 1024, 8, 8).unwrap(), wip_http::Limits::new(1024, 1024, 8).unwrap(),
); );
let session = client let session = client
.open_session( .open_session(
+1 -2
View File
@@ -862,8 +862,7 @@ fn collect_entries_at(
/// Standard bounded wire policy used by the executable and integration tests. /// Standard bounded wire policy used by the executable and integration tests.
#[must_use] #[must_use]
pub fn default_wire_limits() -> Limits { pub fn default_wire_limits() -> Limits {
Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64) Limits::new(256 * 1024, 2 * 1024 * 1024, 64).expect("static runtime wire limits are nonzero")
.expect("static runtime wire limits are nonzero")
} }
/// Runs the interactive executable using process configuration. /// Runs the interactive executable using process configuration.