fix(https): align canonical binding semantics

This commit is contained in:
2026-09-30 07:50:51 +09:00
parent beeffae9c5
commit 625f4c148d
9 changed files with 669 additions and 109 deletions
+123 -3
View File
@@ -4,9 +4,10 @@ use wip_https::wip_protocol::{
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
};
use wip_https::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchResponseItem,
BatchSuccess, ClientResponseError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
decode_batch_request, decode_batch_response, encode_batch_error_response, encode_batch_request,
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response,
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
encode_batch_response,
};
@@ -39,6 +40,7 @@ fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
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
@@ -115,8 +117,65 @@ fn batch_interface_validator_round_trips_in_item_body() {
);
}
#[test]
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}}
]
}"#;
let decoded = decode_batch_request(body, limits()).unwrap();
assert!(matches!(
decoded.requests[0].request,
BatchRequestKind::Fetch(_)
));
let invalid = match &decoded.requests[1].request {
BatchRequestKind::Invalid { method, error } => {
assert_eq!(*method, BatchMethod::FetchTree);
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::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: 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":"fetch","arguments":{"path":"/"},"extra":true}
]
}"#;
assert!(decode_batch_request(malformed_item, limits()).is_err());
let duplicate = br#"{
"requests": [
{"id":"same","method":"fetch","arguments":{"path":"/a"}},
@@ -136,6 +195,67 @@ fn invalid_batch_envelopes_are_rejected_at_top_level() {
assert!(decode_batch_request(duplicate, too_many).is_err());
}
#[test]
fn batch_resource_limits_use_413_or_422_and_never_429() {
let error = ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded,
message: "bounded".into(),
};
for (kind, status) in [
(
ResourceLimitKind::RepresentationBytes,
StatusCode::PAYLOAD_TOO_LARGE,
),
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
] {
let encoded = encode_batch_resource_limit_error_response(&error, kind, limits()).unwrap();
assert_eq!(encoded.status(), status);
assert_ne!(encoded.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
decode_batch_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error: error.clone(),
status_mismatch: None,
}
);
}
}
#[test]
fn batch_success_body_requires_exactly_http_200() {
let request = request();
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: 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 {