Rename transport binding to WIP over HTTP

This commit is contained in:
2026-10-02 02:22:17 +09:00
parent 511e9fef9f
commit 55c784c22d
31 changed files with 250 additions and 396 deletions
+345
View File
@@ -0,0 +1,345 @@
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, ResourceLimitKind, decode_batch_request, decode_batch_response,
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
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 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::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());
}
+526
View File
@@ -0,0 +1,526 @@
use std::collections::BTreeMap;
use wip_http::http::StatusCode;
use wip_http::wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
TypeExpr, UnionCase, Value,
};
use wip_http::{
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_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 {
Limits::new(1_000_000, 1_000_000, 100, 100).unwrap()
}
fn endpoint() -> Endpoint {
Endpoint::parse("https://example.test/wip").unwrap()
}
fn object(name: &str, validator: &[u8]) -> Object {
Object {
name: name.into(),
description: Some("description".into()),
interfaces: vec!["interface-v1".into()],
r#ref: Some(format!("object:{name}")),
validator: Some(validator.to_vec()),
}
}
fn descriptor() -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: Some(Documentation {
summary: "Codec fixture".into(),
details: None,
}),
types: vec![TypeDeclaration {
name: "Pair".into(),
documentation: None,
definition: TypeExpr::Record {
fields: vec![
FieldDeclaration {
name: "required".into(),
required: true,
documentation: None,
r#type: TypeExpr::String,
},
FieldDeclaration {
name: "optional".into(),
required: false,
documentation: None,
r#type: TypeExpr::String,
},
FieldDeclaration {
name: "optional_json".into(),
required: false,
documentation: None,
r#type: TypeExpr::Json,
},
],
},
}],
operations: vec![OperationDeclaration {
name: "everything".into(),
documentation: None,
parameters: vec![
parameter("unit", TypeExpr::Unit),
parameter("boolean", TypeExpr::Boolean),
parameter("integer", TypeExpr::Integer),
parameter("number", TypeExpr::Number),
parameter("string", TypeExpr::String),
parameter("bytes", TypeExpr::Bytes),
parameter("entry", TypeExpr::Entry),
parameter(
"list",
TypeExpr::List {
items: Box::new(TypeExpr::Integer),
},
),
parameter(
"record",
TypeExpr::Named {
name: "Pair".into(),
},
),
parameter(
"enum",
TypeExpr::Enum {
cases: vec![EnumCase {
name: "ready".into(),
documentation: None,
}],
},
),
parameter(
"union",
TypeExpr::Union {
cases: vec![
UnionCase {
name: "empty".into(),
documentation: None,
payload: None,
},
UnionCase {
name: "found".into(),
documentation: None,
payload: Some(TypeExpr::Entry),
},
],
},
),
parameter("json", TypeExpr::Json),
],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Bytes,
},
}],
}
}
fn parameter(name: &str, r#type: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: name.into(),
required: true,
documentation: None,
r#type,
}
}
fn record(fields: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
Value::Record(
fields
.into_iter()
.map(|(name, value)| (name.into(), value))
.collect(),
)
}
fn call_request() -> CallOperationRequest {
CallOperationRequest {
target: Target {
path: "/items/123".into(),
validator: Some(vec![0, 1, 2, 255]),
},
interface: InterfaceTarget {
reference: "interface-v1".into(),
validator: Some(vec![9, 8, 7]),
},
operation: "everything".into(),
arguments: BTreeMap::from([
("unit".into(), Value::Unit),
("boolean".into(), Value::Boolean(true)),
("integer".into(), Value::Integer(MAX_SAFE_INTEGER)),
("number".into(), Value::Number(1.5)),
("string".into(), Value::String("text".into())),
("bytes".into(), Value::Bytes(vec![0, 1, 2, 255])),
("entry".into(), Value::String("/items/123".into())),
(
"list".into(),
Value::List(vec![Value::Integer(1), Value::Integer(2)]),
),
(
"record".into(),
record([("required", Value::String("present".into()))]),
),
("enum".into(), Value::String("ready".into())),
(
"union".into(),
record([
("$case", Value::String("found".into())),
("value", Value::String("/items/123".into())),
]),
),
(
"json".into(),
record([
("null", Value::Unit),
("number", Value::Integer(2)),
("path_like", Value::String("/not-an-entry".into())),
]),
),
]),
}
}
#[test]
fn every_single_interaction_request_accepts_json() {
let observe = encode_observe_request(
&endpoint(),
&ObserveRequest {
path: "/".into(),
depth: 0,
},
limits(),
)
.unwrap();
let fetch_interface = encode_fetch_interface_request(
&endpoint(),
&FetchInterfaceRequest {
interface: "interface-v1".into(),
},
limits(),
)
.unwrap();
let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits())
.unwrap();
for request in [observe, fetch_interface, call] {
assert_eq!(request.headers()["accept"], "application/json");
}
}
#[test]
fn depth_zero_observe_round_trips_direct_body_and_validator() {
let request = ObserveRequest {
path: "/items/123".into(),
depth: 0,
};
let encoded_request = encode_observe_request(&endpoint(), &request, limits()).unwrap();
assert_eq!(encoded_request.uri().path(), "/wip/v1/observe");
assert_eq!(
decode_observe_request(encoded_request.body(), limits()).unwrap(),
request
);
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_observe_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(expected)
);
assert!(encoded.headers().get("etag").is_none());
}
#[test]
fn observe_and_interface_round_trip_nested_logical_models() {
let observe_request = ObserveRequest {
path: "/items".into(),
depth: 1,
};
let encoded_request = encode_observe_request(&endpoint(), &observe_request, limits()).unwrap();
assert_eq!(
decode_observe_request(encoded_request.body(), limits()).unwrap(),
observe_request
);
let observation = ObjectObservation {
object: object("items", b"root-validator"),
children: Some(vec![ObjectObservation {
object: object("123", b"child-validator"),
children: None,
}]),
};
let encoded = encode_observe_response(&observe_request, &observation, limits()).unwrap();
assert_eq!(
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 {
interface: "interface-v1".into(),
};
let encoded_request =
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
assert_eq!(
decode_fetch_interface_request(encoded_request.body(), limits()).unwrap(),
interface_request
);
let interface = FetchInterfaceResponse {
interface: interface_request.interface.clone(),
descriptor: descriptor(),
validator: Some(vec![4, 5, 6, 255]),
};
let encoded =
encode_fetch_interface_response(&interface_request, &interface, limits()).unwrap();
assert_eq!(
decode_fetch_interface_response(&interface_request, &encoded, limits()).unwrap(),
DecodedResponse::Success(interface)
);
}
#[test]
fn descriptor_guided_values_and_validators_round_trip_both_directions() {
let descriptor = descriptor();
let request = call_request();
let encoded =
encode_call_operation_request(&endpoint(), &request, &descriptor, limits()).unwrap();
let body = std::str::from_utf8(encoded.body()).unwrap();
assert!(body.contains("AAEC/w=="));
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
assert_eq!(metadata.target, request.target);
assert_eq!(metadata.interface, request.interface);
assert_eq!(metadata.operation, request.operation);
assert_eq!(
metadata
.decode_request(encoded.body(), &descriptor, limits())
.unwrap(),
request
);
let mut changed_route = request.clone();
changed_route.target.path = "/items/other".into();
let changed =
encode_call_operation_request(&endpoint(), &changed_route, &descriptor, limits()).unwrap();
assert!(
metadata
.decode_request(changed.body(), &descriptor, limits())
.is_err()
);
let mut null_json_request = request.clone();
null_json_request
.arguments
.insert("json".into(), Value::Unit);
null_json_request.arguments.insert(
"record".into(),
record([
("required", Value::String("present".into())),
("optional_json", Value::Unit),
]),
);
let null_json =
encode_call_operation_request(&endpoint(), &null_json_request, &descriptor, limits())
.unwrap();
assert_eq!(
decode_call_operation_request(null_json.body(), &descriptor, limits()).unwrap(),
null_json_request
);
let expected = CallOperationResponse {
result: Value::Bytes(vec![255, 0, 1]),
validator: Some(vec![3, 2, 1, 0]),
};
let encoded =
encode_call_operation_response(&request, &descriptor, &expected, limits()).unwrap();
assert!(
std::str::from_utf8(encoded.body())
.unwrap()
.contains("/wAB")
);
assert_eq!(
decode_call_operation_response(&request, &descriptor, &encoded, limits()).unwrap(),
DecodedResponse::Success(expected)
);
}
#[test]
fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
let limits = limits();
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();
let body = serde_json::to_vec(&serde_json::json!({
"target": {"path": "/items/123", "validator": null},
"interface": {"reference": "interface-v1"},
"operation": "everything",
"arguments": {}
}))
.unwrap();
assert!(decode_call_operation_request(&body, &descriptor, limits).is_err());
let mut json: serde_json::Value = serde_json::from_slice(
encode_call_operation_request(&endpoint(), &request, &descriptor, limits)
.unwrap()
.body(),
)
.unwrap();
json["arguments"]["bytes"] = serde_json::Value::String("AAEC_w==".into());
assert!(
decode_call_operation_request(&serde_json::to_vec(&json).unwrap(), &descriptor, limits)
.is_err()
);
}
#[test]
fn caller_limits_bound_bytes_and_nesting() {
let tiny = Limits::new(8, 8, 2, 1).unwrap();
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":[[["/"]]],"depth":0}"#;
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err();
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
assert_eq!(
too_deep.resource_limit_kind(),
Some(ResourceLimitKind::Logical)
);
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap();
assert!(
encode_call_operation_request(
&endpoint(),
&call_request(),
&descriptor(),
outbound_limited,
)
.is_err()
);
assert!(
encode_observe_response(
&ObserveRequest {
path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object("item", b"validator"),
children: None,
},
outbound_limited,
)
.is_err()
);
}
#[test]
fn unsupported_descriptor_format_is_client_local() {
let request = FetchInterfaceRequest {
interface: "interface-v1".into(),
};
let mut interface = FetchInterfaceResponse {
interface: request.interface.clone(),
descriptor: descriptor(),
validator: None,
};
interface.descriptor.format = "future-format".into();
let response = encode_fetch_interface_response(&request, &interface, limits()).unwrap();
let error = decode_fetch_interface_response(&request, &response, limits()).unwrap_err();
assert!(matches!(
error,
ClientResponseError::UnsupportedDescriptorFormat { .. }
));
let future_shape = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future_shape":true}}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_fetch_interface_response(&request, &future_shape, limits()).unwrap_err(),
ClientResponseError::UnsupportedDescriptorFormat { format }
if format == "wip-interface/2"
));
for malformed in [
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
.as_slice(),
] {
let response = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(malformed.to_vec())
.unwrap();
assert!(matches!(
decode_fetch_interface_response(&request, &response, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidSuccessBody,
..
}
));
}
}
#[test]
fn invalid_success_is_classified_as_invalid_response() {
let response = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec())
.unwrap();
let error = decode_observe_response(
&ObserveRequest {
path: "/expected".into(),
depth: 0,
},
&response,
limits(),
)
.unwrap_err();
assert!(matches!(
error,
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidSuccessBody,
..
}
));
}
+310
View File
@@ -0,0 +1,310 @@
use wip_http::http::{Response, StatusCode};
use wip_http::wip_protocol::{
ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
};
use wip_http::{
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
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,
};
fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap()
}
fn request() -> ObserveRequest {
ObserveRequest {
path: "/".into(),
depth: 0,
}
}
fn response(status: StatusCode, body: impl Into<Vec<u8>>) -> Response<Vec<u8>> {
Response::builder()
.status(status)
.header("content-type", "application/json; charset=utf-8")
.body(body.into())
.unwrap()
}
#[test]
fn content_type_accepts_only_utf8_json_syntax() {
assert!(is_json_content_type("application/json"));
assert!(is_json_content_type("Application/JSON; Charset=\"UTF-8\""));
assert!(!is_json_content_type("application/json; charset=utf-16"));
assert!(!is_json_content_type("application/json; charset=\"utf-8"));
assert!(!is_json_content_type("application/json; charset=utf-8\""));
assert!(!is_json_content_type("application/json; profile=other"));
}
#[test]
fn every_protocol_code_has_stable_name_and_status() {
let expected = [
(
ProtocolErrorCode::InvalidRequest,
"invalid_request",
StatusCode::BAD_REQUEST,
),
(
ProtocolErrorCode::NotFound,
"not_found",
StatusCode::NOT_FOUND,
),
(
ProtocolErrorCode::InterfaceNotFound,
"interface_not_found",
StatusCode::NOT_FOUND,
),
(
ProtocolErrorCode::InterfaceMismatch,
"interface_mismatch",
StatusCode::CONFLICT,
),
(
ProtocolErrorCode::OperationNotFound,
"operation_not_found",
StatusCode::NOT_FOUND,
),
(
ProtocolErrorCode::InvalidArguments,
"invalid_arguments",
StatusCode::UNPROCESSABLE_ENTITY,
),
(
ProtocolErrorCode::ValidatorRequired,
"validator_required",
StatusCode::PRECONDITION_REQUIRED,
),
(
ProtocolErrorCode::ValidatorMismatch,
"validator_mismatch",
StatusCode::PRECONDITION_FAILED,
),
(
ProtocolErrorCode::InterfaceValidatorRequired,
"interface_validator_required",
StatusCode::PRECONDITION_REQUIRED,
),
(
ProtocolErrorCode::InterfaceValidatorMismatch,
"interface_validator_mismatch",
StatusCode::PRECONDITION_FAILED,
),
(
ProtocolErrorCode::PermissionDenied,
"permission_denied",
StatusCode::FORBIDDEN,
),
(
ProtocolErrorCode::ResourceLimitExceeded,
"resource_limit_exceeded",
StatusCode::UNPROCESSABLE_ENTITY,
),
(
ProtocolErrorCode::Internal,
"internal",
StatusCode::INTERNAL_SERVER_ERROR,
),
(
ProtocolErrorCode::OperationOutcomeUnknown,
"operation_outcome_unknown",
StatusCode::INTERNAL_SERVER_ERROR,
),
];
assert_eq!(expected.len(), ProtocolErrorCode::ALL.len());
for (code, name, status) in expected {
assert_eq!(error_code_name(code), name);
assert_eq!(status_for_error(code), status);
}
}
#[test]
fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
let error = ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded,
message: "bounded".into(),
};
let cases = [
(
ResourceLimitKind::RepresentationBytes,
StatusCode::PAYLOAD_TOO_LARGE,
),
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
];
for (kind, status) in cases {
assert_eq!(status_for_resource_limit(kind), status);
let encoded = encode_resource_limit_error_response(
ProtocolInteraction::Observe,
&error,
kind,
limits(),
)
.unwrap();
assert_eq!(encoded.status(), status);
assert_eq!(
decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error: error.clone(),
status_mismatch: None,
}
);
}
let default =
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
let response = Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.body(b"rate limited".to_vec())
.unwrap();
assert!(matches!(
decode_observe_response(&request(), &response, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::TOO_MANY_REQUESTS
&& failure.kind == TransportFailureKind::HttpStatus
));
}
#[test]
fn valid_success_body_requires_exactly_http_200() {
let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
assert!(matches!(
decode_observe_response(&request(), &response(status, success_body), limits())
.unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::NonCanonicalSuccessStatus,
..
}
));
}
}
#[test]
fn server_encoder_rejects_codes_disallowed_for_the_interaction() {
let error = ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "not valid for observe".into(),
};
assert!(
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).is_err()
);
}
#[test]
fn valid_envelope_code_is_authoritative_when_status_mismatches() {
let error = ProtocolError {
code: ProtocolErrorCode::NotFound,
message: "missing".into(),
};
let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!(
decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error,
status_mismatch: Some(wip_http::StatusMismatch {
actual: StatusCode::IM_A_TEAPOT,
expected: StatusCode::NOT_FOUND,
}),
}
);
}
#[test]
fn envelope_and_status_contradictions_are_invalid_responses() {
let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#;
let on_success =
decode_observe_response(&request(), &response(StatusCode::OK, error_body), limits())
.unwrap_err();
assert!(matches!(
on_success,
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::ErrorEnvelopeOnSuccessStatus,
..
}
));
let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
let on_error = decode_observe_response(
&request(),
&response(StatusCode::BAD_GATEWAY, success_body),
limits(),
)
.unwrap_err();
assert!(matches!(
on_error,
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::SuccessBodyOnErrorStatus,
..
}
));
}
#[test]
fn disallowed_protocol_code_is_invalid_instead_of_reclassified() {
let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#;
let error =
decode_observe_response(&request(), &response(StatusCode::NOT_FOUND, body), limits())
.unwrap_err();
assert!(matches!(
error,
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::DisallowedProtocolCode,
..
}
));
}
#[test]
fn proxy_authentication_and_endpoint_failures_stay_transport_failures() {
let cases = [
(
StatusCode::BAD_GATEWAY,
TransportFailureKind::HttpStatus,
b"proxy failure".as_slice(),
),
(
StatusCode::UNAUTHORIZED,
TransportFailureKind::AuthenticationRequired,
b"login required".as_slice(),
),
(
StatusCode::FORBIDDEN,
TransportFailureKind::EndpointForbidden,
b"denied".as_slice(),
),
];
for (status, expected_kind, body) in cases {
let response = Response::builder()
.status(status)
.body(body.to_vec())
.unwrap();
let error = decode_observe_response(&request(), &response, limits()).unwrap_err();
assert!(matches!(
error,
ClientResponseError::TransportBinding(failure)
if failure.status == status && failure.kind == expected_kind
));
}
}
#[test]
fn malformed_non_success_envelope_is_transport_binding_failure() {
let malformed = response(
StatusCode::BAD_GATEWAY,
br#"{"error":{"code":"not_found","message":null}}"#,
);
assert!(matches!(
decode_observe_response(&request(), &malformed, limits()).unwrap_err(),
ClientResponseError::TransportBinding(_)
));
}
+142
View File
@@ -0,0 +1,142 @@
use wip_http::http::{Method, Request, StatusCode};
use wip_http::{Endpoint, RequestMetadataError, Route, validate_http_request};
#[test]
fn endpoint_preserves_http_and_https_when_resolving_canonical_routes() {
for scheme in ["http", "https"] {
let endpoint = Endpoint::parse(&format!("{scheme}://example.test/root/prefix")).unwrap();
assert_eq!(
endpoint.to_string(),
format!("{scheme}://example.test/root/prefix/")
);
let expected = [
(Route::Observe, "v1/observe"),
(Route::FetchInterface, "v1/fetch_interface"),
(Route::CallOperation, "v1/call_operation"),
(Route::Batch, "v1/batch"),
];
for (route, suffix) in expected {
let url = format!("{scheme}://example.test/root/prefix/{suffix}");
assert_eq!(endpoint.route(route).as_str(), url);
assert_eq!(
endpoint.recognize(&wip_http::url::Url::parse(&url).unwrap()),
Some(route)
);
}
assert_eq!(
endpoint.recognize(
&wip_http::url::Url::parse(&format!(
"{scheme}://example.test/root/prefix/v1/observe?path=/items"
))
.unwrap()
),
None
);
}
}
#[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::Observe);
assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/observe");
assert!(!route.as_str().contains("items"));
}
#[test]
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/observe")
.header("content-type", "application/json; charset=utf-8")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &valid).is_ok());
let wrong_method = Request::builder()
.method(Method::GET)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &wrong_method).is_err());
let wrong_route = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch_interface")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &wrong_route).is_err());
for accept in [
"text/plain, */*;q=1, application/json;q=0",
"application/json; charset=utf-16",
"application/json; profile=future",
] {
let incompatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Observe, &incompatible_accept).unwrap_err();
assert!(matches!(&error, RequestMetadataError::NotAcceptable));
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
}
for accept in [
"application/json; charset=UTF-8",
"application/json; q=1; profile=accept-extension",
] {
let compatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &compatible_accept).is_ok());
}
let unsupported_media_type = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "text/plain")
.header("accept", "application/json")
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Observe, &unsupported_media_type).unwrap_err();
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[test]
fn invalid_endpoint_components_and_non_routes_are_rejected() {
assert!(Endpoint::parse("/relative").is_err());
assert!(Endpoint::parse("file:///wip").is_err());
assert!(Endpoint::parse("https://example.test/wip?q=1").is_err());
assert!(Endpoint::parse("https://example.test/wip#fragment").is_err());
let deployment_endpoint = Endpoint::parse("http://user:secret@example.test/wip").unwrap();
assert_eq!(deployment_endpoint.as_url().scheme(), "http");
assert_eq!(deployment_endpoint.as_url().username(), "user");
let endpoint = Endpoint::parse("http://example.test/wip").unwrap();
for outside in [
"http://example.test/wip/v1/unknown",
"http://example.test/wip/v1/observe/extra",
"https://example.test/wip/v1/observe",
"http://other.test/wip/v1/observe",
] {
assert_eq!(
endpoint.recognize(&wip_http::url::Url::parse(outside).unwrap()),
None
);
}
}