T-20: identify interfaces by exact scope/name pairs; add optional opaque scope_ref metadata and separate shape validation from path-dependent scope compatibility. Preserve call error precedence, strict JSON, validators and transport boundaries. Mechanically migrate consumers and document API/wire compatibility and deferred lifecycle semantics. Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test -p wip-protocol -p wip-http; cargo test --workspace (221 passing tests including doctests); cargo test --workspace --all-targets; nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps. All pass. cargo/rustc 1.95.0; development-shell Clippy/rustc 1.97.1.
367 lines
11 KiB
Rust
367 lines
11 KiB
Rust
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
|
wip_protocol::InterfaceReference {
|
|
scope: "/".into(),
|
|
name: name.into(),
|
|
}
|
|
}
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use wip_client::{
|
|
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
|
|
ObservationState, OutcomeUnknownReason, SecurityContext,
|
|
};
|
|
use wip_http::http::{Response, StatusCode};
|
|
use wip_http::{
|
|
Limits, encode_fetch_interface_response, encode_observe_response,
|
|
encode_protocol_error_response,
|
|
};
|
|
use wip_protocol::{
|
|
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
|
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
|
|
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
|
};
|
|
|
|
fn limits() -> Limits {
|
|
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
|
}
|
|
|
|
fn setup() -> (Client, wip_client::SessionId) {
|
|
let mut client = Client::new(ClientLimits::new(2, 16, 16, 16, 16).unwrap(), limits());
|
|
let session = client
|
|
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
|
.unwrap();
|
|
(client, session)
|
|
}
|
|
|
|
fn response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
|
|
Response::builder()
|
|
.status(status)
|
|
.header("content-type", "application/json; charset=utf-8")
|
|
.body(body.to_vec())
|
|
.unwrap()
|
|
}
|
|
|
|
fn object() -> Object {
|
|
Object {
|
|
name: "item".into(),
|
|
description: None,
|
|
interfaces: vec![root_reference("math")],
|
|
r#ref: None,
|
|
validator: Some(b"object-v1".to_vec()),
|
|
}
|
|
}
|
|
|
|
fn descriptor(format: &str) -> InterfaceDescriptor {
|
|
InterfaceDescriptor {
|
|
format: format.into(),
|
|
documentation: None,
|
|
types: vec![],
|
|
operations: vec![OperationDeclaration {
|
|
name: "calculate".into(),
|
|
documentation: None,
|
|
parameters: vec![ParameterDeclaration {
|
|
name: "value".into(),
|
|
required: true,
|
|
documentation: None,
|
|
r#type: TypeExpr::Integer,
|
|
}],
|
|
returns: ReturnDeclaration {
|
|
documentation: None,
|
|
r#type: TypeExpr::Integer,
|
|
},
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
|
|
let cases: &[&[u8]] = &[
|
|
b"{not json",
|
|
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
|
br#"{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/","name":"math"}]}"#,
|
|
br#"{"name":"wrong","interfaces":[]}"#,
|
|
];
|
|
for body in cases {
|
|
let (mut client, session) = setup();
|
|
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
let error = client
|
|
.complete(request.id, response(StatusCode::OK, body))
|
|
.unwrap_err();
|
|
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
|
assert!(matches!(
|
|
client.object(&session, "/item").unwrap().state,
|
|
ObservationState::Error(_)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
|
|
let cases = [
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
br#"{"error":{"code":"interface_not_found","message":"wrong"}}"#.as_slice(),
|
|
),
|
|
(
|
|
StatusCode::OK,
|
|
br#"{"error":{"code":"not_found","message":"wrong status"}}"#.as_slice(),
|
|
),
|
|
(
|
|
StatusCode::CREATED,
|
|
br#"{"name":"item","interfaces":[]}"#.as_slice(),
|
|
),
|
|
];
|
|
for (status, body) in cases {
|
|
let (mut client, session) = setup();
|
|
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
assert_eq!(
|
|
client
|
|
.complete(request.id, response(status, body))
|
|
.unwrap_err()
|
|
.kind,
|
|
ClientErrorKind::InvalidResponse
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
|
|
let (mut client, session) = setup();
|
|
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
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!(
|
|
client.complete(request.id, encoded).unwrap(),
|
|
Completion::ProtocolFailure(error)
|
|
);
|
|
assert_eq!(
|
|
client.object(&session, "/item").unwrap().state,
|
|
ObservationState::Stale
|
|
);
|
|
assert_eq!(
|
|
client.diagnostics(&session).unwrap().back().unwrap().kind,
|
|
DiagnosticKind::HttpStatusMismatch
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resource_envelope_on_413_uses_code_and_records_canonical_422_mismatch() {
|
|
let (mut client, session) = setup();
|
|
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
let error = ProtocolError {
|
|
code: ProtocolErrorCode::ResourceLimitExceeded,
|
|
message: "logical observation limit exceeded".into(),
|
|
};
|
|
let mut encoded =
|
|
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
|
|
*encoded.status_mut() = StatusCode::PAYLOAD_TOO_LARGE;
|
|
|
|
assert_eq!(
|
|
client.complete(request.id, encoded).unwrap(),
|
|
Completion::ProtocolFailure(error)
|
|
);
|
|
assert_eq!(
|
|
client.diagnostics(&session).unwrap().back().unwrap().kind,
|
|
DiagnosticKind::HttpStatusMismatch
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_success_without_valid_envelope_remains_transport_failure() {
|
|
for status in [StatusCode::BAD_GATEWAY, StatusCode::PAYLOAD_TOO_LARGE] {
|
|
let (mut client, session) = setup();
|
|
let request = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
let error = client
|
|
.complete(request.id, response(status, b"infrastructure failure"))
|
|
.unwrap_err();
|
|
assert_eq!(error.kind, ClientErrorKind::Transport);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
|
let (mut client, session) = setup();
|
|
let request = client
|
|
.prepare_interface(&session, root_reference("math"))
|
|
.unwrap();
|
|
let logical = FetchInterfaceRequest {
|
|
interface: root_reference("math"),
|
|
};
|
|
let value = FetchInterfaceResponse {
|
|
scope_ref: None,
|
|
interface: root_reference("math"),
|
|
descriptor: descriptor("future-format/2"),
|
|
validator: Some(b"v2".to_vec()),
|
|
};
|
|
let encoded = encode_fetch_interface_response(&logical, &value, limits()).unwrap();
|
|
let error = client.complete(request.id, encoded).unwrap_err();
|
|
assert_eq!(error.kind, ClientErrorKind::UnsupportedDescriptorFormat);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
|
|
let (mut client, session) = setup();
|
|
let observation = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
client
|
|
.complete(
|
|
observation.id,
|
|
encode_observe_response(
|
|
&ObserveRequest {
|
|
path: "/item".into(),
|
|
depth: 0,
|
|
},
|
|
&ObjectObservation {
|
|
object: object(),
|
|
children: None,
|
|
},
|
|
limits(),
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let interface = client
|
|
.prepare_interface(&session, root_reference("math"))
|
|
.unwrap();
|
|
client
|
|
.complete(
|
|
interface.id,
|
|
encode_fetch_interface_response(
|
|
&FetchInterfaceRequest {
|
|
interface: root_reference("math"),
|
|
},
|
|
&FetchInterfaceResponse {
|
|
scope_ref: None,
|
|
interface: root_reference("math"),
|
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
|
validator: None,
|
|
},
|
|
limits(),
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let call = client
|
|
.prepare_call(
|
|
&session,
|
|
"/item",
|
|
&root_reference("math"),
|
|
"calculate",
|
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
|
)
|
|
.unwrap();
|
|
client.mark_dispatched(call.id).unwrap();
|
|
|
|
let completion = client
|
|
.complete(
|
|
call.id,
|
|
response(
|
|
StatusCode::OK,
|
|
br#"{"result":"not an integer","unknown":true}"#,
|
|
),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
completion,
|
|
Completion::Call(record)
|
|
if matches!(
|
|
&record.outcome,
|
|
CallOutcome::Unknown {
|
|
reason: OutcomeUnknownReason::ResponseDecode,
|
|
failure: Some(failure),
|
|
} if failure.kind == ClientErrorKind::InvalidResponse
|
|
)
|
|
));
|
|
assert_eq!(
|
|
client.object(&session, "/item").unwrap().state,
|
|
ObservationState::Stale
|
|
);
|
|
assert_eq!(client.in_flight_len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
|
let (mut client, session) = setup();
|
|
let observation = client.refresh_observed(&session, "/item", 0).unwrap();
|
|
client
|
|
.complete(
|
|
observation.id,
|
|
encode_observe_response(
|
|
&ObserveRequest {
|
|
path: "/item".into(),
|
|
depth: 0,
|
|
},
|
|
&ObjectObservation {
|
|
object: object(),
|
|
children: None,
|
|
},
|
|
limits(),
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let interface = client
|
|
.prepare_interface(&session, root_reference("math"))
|
|
.unwrap();
|
|
client
|
|
.complete(
|
|
interface.id,
|
|
encode_fetch_interface_response(
|
|
&FetchInterfaceRequest {
|
|
interface: root_reference("math"),
|
|
},
|
|
&FetchInterfaceResponse {
|
|
scope_ref: None,
|
|
interface: root_reference("math"),
|
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
|
validator: None,
|
|
},
|
|
limits(),
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let call = client
|
|
.prepare_call(
|
|
&session,
|
|
"/item",
|
|
&root_reference("math"),
|
|
"calculate",
|
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
|
)
|
|
.unwrap();
|
|
client.mark_dispatched(call.id).unwrap();
|
|
|
|
let completion = client
|
|
.complete(
|
|
call.id,
|
|
Response::builder()
|
|
.status(StatusCode::PAYLOAD_TOO_LARGE)
|
|
.body(Vec::<u8>::new())
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
completion,
|
|
Completion::Call(record)
|
|
if matches!(
|
|
&record.outcome,
|
|
CallOutcome::Unknown {
|
|
reason: OutcomeUnknownReason::ResponseDecode,
|
|
failure: Some(failure),
|
|
} if failure.kind == ClientErrorKind::Transport
|
|
)
|
|
));
|
|
assert_eq!(
|
|
client.object(&session, "/item").unwrap().state,
|
|
ObservationState::Stale
|
|
);
|
|
assert_eq!(client.in_flight_len(), 0);
|
|
}
|