diff --git a/design/followups.md b/design/followups.md index 8c9d56f..b3a1040 100644 --- a/design/followups.md +++ b/design/followups.md @@ -46,7 +46,11 @@ move to `## Resolved` with a date + commit hash. **Resolves when:** F19-F26 are all closed and the four DoD bullets above pass. -**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 landed in this commit: +**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 landed in this commit: +- F25 step 3: response decoder foundation. New `mxaccess-asb::contracts::ItemStatus` ports `AsbContracts.cs:639-722` — Item (ItemIdentity) + Status (AsbStatus, F24) + ErrorCode u16 + ErrorCodeSpecified bool, in the .NET-WriteToStream order (Item / Status / ErrorCode / ErrorCodeSpecified — NOT the DataMember declaration order). `encode_item_status_array` / `decode_item_status_array` follow the same int32-count + per-element pattern. New `mxaccess-asb::operations::collect_asbidata_payloads(tokens, field_name)` walks an NBFX token stream and pulls out the `<{field_name}>{Bytes}` payload bytes — handles multiple payloads (e.g. ReadResponse has both Status and Values). New `decode_register_items_response` / `decode_unregister_items_response` parse SOAP bodies into typed responses. New `build_read_request_body` adds the simplest unary IASBIDataV2 request shape. Plus a typed `OperationError` for response-decode failures (missing fields, codec errors). 9 new tests cover ItemStatus round-trip + array round-trip, RegisterItems response with status array, RegisterItems response detecting ItemCapabilities presence, UnregisterItems response, multi-payload extraction (`ReadResponse`-style with Status + Values), Read request body shape (no RegisterItems-only fields), and graceful MissingField error when Status is absent. + +**Earlier slices:** +- F25 step 2 (commit `a2b8989`): - F25 step 2: per-operation request-body builders + `IAsbCustomSerializableType` binary fast-path. F21 NBFX gains `Bytes8/16/32` text records (used by `XmlDictionaryWriter.WriteBase64` for the `` content). New `mxaccess-asb::contracts::ItemIdentity` ports the binary `WriteToStream` shape from `AsbContracts.cs:594-611`: u16 kind + u16 reference_type + `AsbBinary.WriteUnicodeString` Name + ContextName + u64 Id + u8 IdSpecified. Plus `encode_item_identity_array` / `decode_item_identity_array` mirroring `WriteArrayToStream` (4-byte int32 count + items). New `mxaccess-asb::operations` builds the SOAP body NBFX token streams: `build_register_items_request_body(items, require_id, register_only)` and `build_unregister_items_request_body(items)`. The `` element is wrapped with raw NBFX `Bytes` records (the binary form of WCF's `WriteBase64`). 14 new tests cover ItemIdentity round-trip (default, with id, unicode), ItemIdentity array round-trip, AsbBinary unicode-string null/empty/value semantics, byte-layout pinning (21-byte minimum for default ItemIdentity, le-int32 array count), and the full RegisterItems → SoapEnvelope → encode → decode → recover-ItemIdentity-array round-trip through the entire stack. **Earlier slices:** diff --git a/rust/crates/mxaccess-asb/src/contracts.rs b/rust/crates/mxaccess-asb/src/contracts.rs index bb73b50..2394002 100644 --- a/rust/crates/mxaccess-asb/src/contracts.rs +++ b/rust/crates/mxaccess-asb/src/contracts.rs @@ -21,7 +21,7 @@ //! round-trip — so the per-type cost is small once the //! [`ItemIdentity`] reference establishes it. -use mxaccess_codec::CodecError; +use mxaccess_codec::{AsbStatus, CodecError}; /// `ItemIdentity` per `AsbContracts.cs:533-633`. Wire layout: /// @@ -121,6 +121,104 @@ impl ItemIdentity { } } +/// `ItemStatus` per `AsbContracts.cs:639-722`. Wire layout (from the +/// `WriteToStream` method at `cs:682-688`): +/// +/// | Field | Codec | +/// |----------------|-----------------------------| +/// | `Item` | [`ItemIdentity`] binary form | +/// | `Status` | [`AsbStatus`] binary form | +/// | `ErrorCode` | u16 | +/// | `ErrorCodeSpecified` | u8 (bool) | +/// +/// Note the field order on the wire (`Item` then `Status`) is **NOT** +/// the `[DataMember(Order = …)]` declared order — `WriteToStream` +/// hand-picks Item-first, Status-second, then the trailing pair. +/// We mirror that exactly. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ItemStatus { + pub item: ItemIdentity, + pub status: AsbStatus, + pub error_code: u16, + pub error_code_specified: bool, +} + +impl ItemStatus { + pub fn encode_into(&self, out: &mut Vec) { + self.item.encode_into(out); + self.status.encode_into(out); + out.extend_from_slice(&self.error_code.to_le_bytes()); + out.push(if self.error_code_specified { 1 } else { 0 }); + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.encode_into(&mut out); + out + } + + pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> { + let (item, item_consumed) = ItemIdentity::decode(input)?; + let mut cursor = item_consumed; + let status_tail = input.get(cursor..).ok_or(CodecError::ShortRead { + expected: 5, + actual: 0, + })?; + let (status, status_consumed) = AsbStatus::decode(status_tail)?; + cursor += status_consumed; + let error_code = read_u16_le(input, &mut cursor)?; + let error_code_specified = read_u8(input, &mut cursor)? != 0; + Ok(( + Self { + item, + status, + error_code, + error_code_specified, + }, + cursor, + )) + } +} + +/// Decode an array of `ItemStatus`es from the WCF custom-serializer +/// binary form (4-byte int32 count + each item's `WriteToStream` +/// output). Mirrors `ItemStatus.InitializeArrayFromStream` +/// (`cs:702-711`). +pub fn decode_item_status_array(input: &[u8]) -> Result, CodecError> { + let mut cursor = 0usize; + let count = read_i32_le(input, &mut cursor)?; + if count < 0 { + return Err(CodecError::Decode { + offset: 0, + reason: "negative item-status array count", + buffer_len: input.len(), + }); + } + let mut out = Vec::with_capacity(count as usize); + for _ in 0..count { + let tail = input.get(cursor..).ok_or(CodecError::ShortRead { + expected: 1, + actual: 0, + })?; + let (item, consumed) = ItemStatus::decode(tail)?; + cursor += consumed; + out.push(item); + } + Ok(out) +} + +/// Encode an array of `ItemStatus`es. Mirrors `ItemStatus.WriteArrayToStream` +/// (`cs:713-721`) — 4-byte int32 count + each element's `WriteToStream`. +pub fn encode_item_status_array(items: &[ItemStatus]) -> Vec { + let mut out = Vec::new(); + let count = i32::try_from(items.len()).unwrap_or(i32::MAX); + out.extend_from_slice(&count.to_le_bytes()); + for item in items { + item.encode_into(&mut out); + } + out +} + /// Encode an array of `IAsbCustomSerializableType` items per /// `AsbDataCustomSerializer.WriteObjectContent` array branch /// (`AsbContracts.cs:1583-1591` — calls `WriteArrayToStream` which @@ -360,6 +458,42 @@ mod tests { ); } + #[test] + fn item_status_round_trip() { + let s = ItemStatus { + item: ItemIdentity::absolute_by_name("Tag.X"), + status: AsbStatus { + count: -1, + payload: vec![0xC0], + }, + error_code: 0x1234, + error_code_specified: true, + }; + let bytes = s.encode(); + let (decoded, consumed) = ItemStatus::decode(&bytes).unwrap(); + assert_eq!(consumed, bytes.len()); + assert_eq!(decoded, s); + } + + #[test] + fn item_status_array_round_trip() { + let arr = vec![ + ItemStatus::default(), + ItemStatus { + item: ItemIdentity::absolute_by_name("Tag.A"), + status: AsbStatus { + count: 1, + payload: vec![0x01, 0x02], + }, + error_code: 42, + error_code_specified: true, + }, + ]; + let bytes = encode_item_status_array(&arr); + let decoded = decode_item_status_array(&bytes).unwrap(); + assert_eq!(decoded, arr); + } + #[test] fn item_identity_array_count_is_le_int32() { let items = vec![ItemIdentity::default(); 7]; diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs index 2e8766d..80ec157 100644 --- a/rust/crates/mxaccess-asb/src/lib.rs +++ b/rust/crates/mxaccess-asb/src/lib.rs @@ -14,11 +14,15 @@ pub mod envelope; pub mod operations; pub use contracts::{ - ItemIdentity, ItemIdentityType, ItemReferenceType, decode_item_identity_array, - encode_item_identity_array, + ItemIdentity, ItemIdentityType, ItemReferenceType, ItemStatus, decode_item_identity_array, + decode_item_status_array, encode_item_identity_array, encode_item_status_array, }; pub use envelope::{ ConnectionValidator, DecodedEnvelope, EnvelopeError, SoapEnvelope, actions, decode_envelope, encode_envelope, }; -pub use operations::{build_register_items_request_body, build_unregister_items_request_body}; +pub use operations::{ + OperationError, RegisterItemsResponse, UnregisterItemsResponse, build_read_request_body, + build_register_items_request_body, build_unregister_items_request_body, + collect_asbidata_payloads, decode_register_items_response, decode_unregister_items_response, +}; diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs index e4ce584..c3cdef2 100644 --- a/rust/crates/mxaccess-asb/src/operations.rs +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -36,8 +36,11 @@ //! `InitializeArrayFromStream` shape. use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken}; +use mxaccess_codec::CodecError; -use crate::contracts::{ItemIdentity, encode_item_identity_array}; +use crate::contracts::{ + ItemIdentity, ItemStatus, decode_item_status_array, encode_item_identity_array, +}; /// Build the NBFX token stream for the body of a `RegisterItemsIn` /// SOAP envelope. The caller wraps it via [`crate::SoapEnvelope`] + @@ -75,6 +78,135 @@ pub fn build_register_items_request_body( ) } +/// Build the NBFX token stream for `ReadIn`. Mirror of +/// `AsbContracts.cs:161-167`: +/// ```xml +/// +/// {int32 count + each ItemIdentity} +/// +/// ``` +pub fn build_read_request_body(items: &[ItemIdentity]) -> Vec { + let payload = encode_item_identity_array(items); + asbidata_request_body("ReadRequest", &[BodyField::asbidata("Items", payload)]) +} + +/// Decoded `RegisterItemsResponse`. The `Status` array is binary-decoded +/// via `decode_item_status_array`. The optional `ItemCapabilities` +/// (`ItemRegistration[]`) field is **not** decoded here — that contract +/// is regular WCF XML serialization rather than the binary +/// `IAsbCustomSerializableType` fast-path, so it's deferred. Today we +/// just count whether it appeared in the body. See follow-up F28. +#[derive(Debug, Clone, PartialEq)] +pub struct RegisterItemsResponse { + pub status: Vec, + /// Whether the `` element appeared. Decoding the + /// individual `ItemRegistration` records is a future iteration. + pub item_capabilities_present: bool, +} + +/// Decoded `UnregisterItemsResponse`. Single field: the per-item +/// `Status` array (`AsbContracts.cs:153-159`). +#[derive(Debug, Clone, PartialEq)] +pub struct UnregisterItemsResponse { + pub status: Vec, +} + +/// Decode a `RegisterItemsResponse` SOAP body from the NBFX token +/// stream returned by [`crate::decode_envelope`]. +pub fn decode_register_items_response( + body_tokens: &[NbfxToken], +) -> Result { + let payloads = collect_asbidata_payloads(body_tokens, "Status"); + let status_payload = payloads + .into_iter() + .next() + .ok_or(OperationError::MissingField { field: "Status" })?; + let status = decode_item_status_array(&status_payload)?; + let item_capabilities_present = find_element_named(body_tokens, "ItemCapabilities").is_some(); + Ok(RegisterItemsResponse { + status, + item_capabilities_present, + }) +} + +/// Decode an `UnregisterItemsResponse` SOAP body. +pub fn decode_unregister_items_response( + body_tokens: &[NbfxToken], +) -> Result { + let payloads = collect_asbidata_payloads(body_tokens, "Status"); + let status_payload = payloads + .into_iter() + .next() + .ok_or(OperationError::MissingField { field: "Status" })?; + let status = decode_item_status_array(&status_payload)?; + Ok(UnregisterItemsResponse { status }) +} + +/// Walk a SOAP body's NBFX token stream and pull out the +/// `{Bytes}` payload bytes for any element named +/// `field_name`. Returns `Vec>` because some response shapes +/// have multiple ASBIData payloads (e.g. `ReadResponse` has both +/// `Status` and `Values`). +/// +/// Operates on token windows rather than tracking element depth — the +/// response shapes are shallow enough that name-keyed scanning is +/// reliable. Returns whichever payloads it finds; missing fields +/// surface as an empty `Vec`. +pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec> { + let mut out = Vec::new(); + let mut idx = 0; + while idx < tokens.len() { + if let Some(NbfxToken::Element { + name: NbfxName::Inline(local), + .. + }) = tokens.get(idx) + { + if local == field_name { + // Skip attributes / namespace decls. + let mut inner = idx + 1; + while matches!( + tokens.get(inner), + Some(NbfxToken::Attribute { .. }) + | Some(NbfxToken::DefaultNamespace { .. }) + | Some(NbfxToken::NamespaceDeclaration { .. }) + ) { + inner += 1; + } + if let Some(NbfxToken::Element { + name: NbfxName::Inline(asbidata), + .. + }) = tokens.get(inner) + { + if asbidata == "ASBIData" { + if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) = + tokens.get(inner + 1) + { + out.push(payload.clone()); + } + } + } + } + } + idx += 1; + } + out +} + +fn find_element_named<'a>(tokens: &'a [NbfxToken], name: &str) -> Option<&'a NbfxToken> { + tokens.iter().find(|tok| { + matches!(tok, NbfxToken::Element { name: NbfxName::Inline(local), .. } if local == name) + }) +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum OperationError { + #[error("response is missing required field {field}")] + MissingField { field: &'static str }, + #[error("codec error decoding response: {0}")] + Codec(#[from] CodecError), +} + /// Build the NBFX token stream for `UnregisterItemsIn`. Mirror of /// `AsbContracts.cs:145-159`: /// ```xml @@ -307,6 +439,155 @@ mod tests { } } + #[test] + fn read_request_body_uses_correct_outer_element_and_no_register_fields() { + let body = build_read_request_body(&[ItemIdentity::absolute_by_name("Tag.X")]); + assert!(matches!( + &body[0], + NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ReadRequest" + )); + // The Read contract has only `Items`. RequireId / RegisterOnly / + // Values are NOT present. + for tok in &body { + if let NbfxToken::Element { + name: NbfxName::Inline(local), + .. + } = tok + { + assert!(local != "RequireId"); + assert!(local != "RegisterOnly"); + assert!(local != "Values"); + } + } + } + + #[test] + fn register_items_response_round_trips_status_array() { + use mxaccess_codec::AsbStatus; + let status = vec![ + ItemStatus { + item: ItemIdentity::absolute_by_name("Tag.A"), + status: AsbStatus { + count: 0, + payload: vec![], + }, + error_code: 0, + error_code_specified: true, + }, + ItemStatus { + item: ItemIdentity::absolute_by_name("Tag.B"), + status: AsbStatus { + count: -1, + payload: vec![0xC0], + }, + error_code: 7, + error_code_specified: true, + }, + ]; + let payload = crate::contracts::encode_item_status_array(&status); + + // Build a synthetic response body matching the wire shape. + let body = asbidata_request_body( + "RegisterItemsResponse", + &[BodyField::asbidata("Status", payload)], + ); + + let decoded = decode_register_items_response(&body).unwrap(); + assert_eq!(decoded.status, status); + assert!(!decoded.item_capabilities_present); + } + + #[test] + fn register_items_response_records_when_item_capabilities_appears() { + use mxaccess_codec::AsbStatus; + let status = vec![ItemStatus { + item: ItemIdentity::absolute_by_name("X"), + status: AsbStatus::default(), + error_code: 0, + error_code_specified: false, + }]; + let status_payload = crate::contracts::encode_item_status_array(&status); + + // Synthesise a body with both Status and ItemCapabilities elements. + let mut body = asbidata_request_body( + "RegisterItemsResponse", + &[BodyField::asbidata("Status", status_payload)], + ); + // Splice in a synthetic ItemCapabilities element before the + // outer EndElement. + let close_idx = body.len() - 1; + body.insert( + close_idx, + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("ItemCapabilities".to_string()), + }, + ); + body.insert(close_idx + 1, NbfxToken::EndElement); + + let decoded = decode_register_items_response(&body).unwrap(); + assert_eq!(decoded.status, status); + assert!(decoded.item_capabilities_present); + } + + #[test] + fn unregister_items_response_round_trips() { + use mxaccess_codec::AsbStatus; + let status = vec![ItemStatus { + item: ItemIdentity::absolute_by_name("Tag.Y"), + status: AsbStatus { + count: 1, + payload: vec![0x40], + }, + error_code: 0, + error_code_specified: false, + }]; + let payload = crate::contracts::encode_item_status_array(&status); + let body = asbidata_request_body( + "UnregisterItemsResponse", + &[BodyField::asbidata("Status", payload)], + ); + let decoded = decode_unregister_items_response(&body).unwrap(); + assert_eq!(decoded.status, status); + } + + #[test] + fn collect_asbidata_payloads_returns_empty_when_field_missing() { + let body = vec![ + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("Empty".to_string()), + }, + NbfxToken::EndElement, + ]; + assert!(collect_asbidata_payloads(&body, "Status").is_empty()); + } + + #[test] + fn collect_asbidata_payloads_handles_multiple_fields() { + let body = asbidata_request_body( + "ReadResponse", + &[ + BodyField::asbidata("Status", vec![1, 2, 3]), + BodyField::asbidata("Values", vec![4, 5, 6, 7]), + ], + ); + let status = collect_asbidata_payloads(&body, "Status"); + let values = collect_asbidata_payloads(&body, "Values"); + assert_eq!(status, vec![vec![1u8, 2, 3]]); + assert_eq!(values, vec![vec![4u8, 5, 6, 7]]); + } + + #[test] + fn decode_register_items_response_returns_missing_field_when_status_absent() { + let body = asbidata_request_body("RegisterItemsResponse", &[]); + let err = decode_register_items_response(&body).unwrap_err(); + assert!(matches!( + err, + OperationError::MissingField { field: "Status" } + )); + } + #[test] fn empty_items_array_still_produces_valid_envelope() { let body = build_register_items_request_body(&[], false, false);