[M5] mxaccess-asb: F25 step 2 — per-operation request body codecs
Adds the IAsbCustomSerializableType binary fast-path + per-operation
request-body NBFX-token builders. RegisterItems and UnregisterItems
now compose end-to-end through SoapEnvelope + encode_envelope to a
byte stream that round-trips back to the original ItemIdentity array.
Three pieces:
1. F21 NBFX gains `Bytes8/16/32` text records (records 0x9E/0xA0/0xA2
plus +1 WithEndElement variants). WCF's `XmlDictionaryWriter.
WriteBase64` emits these in binary form — not actual base64 text —
so they're required for the `<ASBIData>` content.
2. `mxaccess-asb::contracts::ItemIdentity` ports `AsbContracts.cs:533-633`:
* Wire layout: u16 kind + u16 reference_type +
AsbBinary.WriteUnicodeString(Name) + AsbBinary.WriteUnicodeString
(ContextName) + u64 Id + u8 IdSpecified.
* `AsbBinary.WriteUnicodeString` per cs:1622-1633: u32 byte-length
+ UTF-16LE bytes; null/empty collapse to a 4-byte zero header.
* `encode_item_identity_array` / `decode_item_identity_array`
mirror `WriteArrayToStream` — 4-byte int32 count + each
element's `WriteToStream` output. Per `AsbDataCustomSerializer`
at cs:1583-1591.
* `absolute_by_name(...)` convenience constructor matching
`MxAsbDataClient.CreateAbsoluteItem` at cs:172-194.
3. `mxaccess-asb::operations` builds SOAP body NBFX token streams:
* `build_register_items_request_body(items, require_id, register_only)`
— RegisterItems contract per cs:119-143.
* `build_unregister_items_request_body(items)` — UnregisterItems
per cs:145-159.
* Internal `BodyField` helper assembles the wire shape:
`<RegisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
<Items><ASBIData>{Bytes(payload)}</ASBIData></Items>
<RequireId>true|false</RequireId>
<RegisterOnly>true|false</RegisterOnly>
</RegisterItemsRequest>`
15 new tests cover:
* ItemIdentity round-trip (default, with id, unicode name).
* AsbBinary unicode-string null/empty/value semantics.
* Byte-layout pinning (21 bytes for default ItemIdentity, le-int32
array count).
* ItemIdentity array round-trip.
* `<ASBIData>` Bytes record round-trip across NBFX widths
(Bytes8/16/32 selected by length).
* RegisterItems body → SoapEnvelope → encode → decode → recover the
ItemIdentity array end-to-end.
* RequireId / RegisterOnly Bool wire form.
* UnregisterItems body uses correct outer element name and omits
the RegisterItems-only fields.
Stubbed for next F25 iteration: per-operation Read / Write /
PublishWriteComplete / CreateSubscription / AddMonitoredItems /
DeleteMonitoredItems / Publish builders, response decoders, and the
`AsbClient` network loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,11 @@ pub enum NbfxText {
|
||||
/// pick `Static` when [`crate::nbfs::lookup_static`] succeeds and
|
||||
/// fall back to `Dynamic` otherwise.
|
||||
DictionaryDynamic(u32),
|
||||
/// Raw bytes (records `0x9E` Bytes8 / `0xA0` Bytes16 / `0xA2`
|
||||
/// Bytes32 — width chosen automatically by length on encode). Used
|
||||
/// by `XmlDictionaryWriter.WriteBase64` for the `ASBIData`
|
||||
/// content of `IAsbCustomSerializableType`-decorated fields.
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
impl NbfxText {
|
||||
@@ -186,6 +191,10 @@ impl NbfxText {
|
||||
Self::Chars(s) => Some(s.clone()),
|
||||
Self::DictionaryStatic(id) => nbfs::lookup_static(*id).map(String::from),
|
||||
Self::DictionaryDynamic(id) => dynamic.lookup(*id).map(String::from),
|
||||
// Raw bytes have no canonical text representation; .NET's
|
||||
// `XmlDictionaryReader.ReadElementContentAsBase64` returns
|
||||
// them as `byte[]`. Consumers should match on the variant.
|
||||
Self::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +261,9 @@ const REC_CHARS8_TEXT: u8 = 0x98;
|
||||
const REC_CHARS16_TEXT: u8 = 0x9A;
|
||||
const REC_CHARS32_TEXT: u8 = 0x9C;
|
||||
const REC_EMPTY_TEXT: u8 = 0xA8;
|
||||
const REC_BYTES8_TEXT: u8 = 0x9E;
|
||||
const REC_BYTES16_TEXT: u8 = 0xA0;
|
||||
const REC_BYTES32_TEXT: u8 = 0xA2;
|
||||
const REC_DICTIONARY_TEXT: u8 = 0xAA;
|
||||
const REC_BOOL_TEXT: u8 = 0xB4;
|
||||
|
||||
@@ -429,6 +441,25 @@ fn encode_text(text: &NbfxText, with_end: bool, out: &mut Vec<u8>) -> Result<(),
|
||||
out.push(REC_DICTIONARY_TEXT + bump);
|
||||
encode_multibyte_int31_to_nbfx(out, *id)?;
|
||||
}
|
||||
NbfxText::Bytes(bytes) => {
|
||||
let len = bytes.len();
|
||||
if len <= u8::MAX as usize {
|
||||
out.push(REC_BYTES8_TEXT + bump);
|
||||
out.push(len as u8);
|
||||
} else if len <= u16::MAX as usize {
|
||||
out.push(REC_BYTES16_TEXT + bump);
|
||||
out.extend_from_slice(&(len as u16).to_le_bytes());
|
||||
} else if len <= u32::MAX as usize {
|
||||
out.push(REC_BYTES32_TEXT + bump);
|
||||
out.extend_from_slice(&(len as u32).to_le_bytes());
|
||||
} else {
|
||||
return Err(NbfxError::PayloadTooLarge {
|
||||
len,
|
||||
max: u32::MAX as u64,
|
||||
});
|
||||
}
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -620,6 +651,21 @@ fn decode_text_body(input: &[u8], cursor: &mut usize, base: u8) -> Result<NbfxTe
|
||||
*cursor += 1;
|
||||
NbfxText::Bool(b != 0)
|
||||
}
|
||||
REC_BYTES8_TEXT => {
|
||||
let len = *input.get(*cursor).ok_or(NmfTrunc("bytes8-len"))? as usize;
|
||||
*cursor += 1;
|
||||
NbfxText::Bytes(read_bytes(input, cursor, len, "bytes8")?)
|
||||
}
|
||||
REC_BYTES16_TEXT => {
|
||||
let len_bytes = read_le::<2>(input, cursor, "bytes16-len")?;
|
||||
let len = u16::from_le_bytes(len_bytes) as usize;
|
||||
NbfxText::Bytes(read_bytes(input, cursor, len, "bytes16")?)
|
||||
}
|
||||
REC_BYTES32_TEXT => {
|
||||
let len_bytes = read_le::<4>(input, cursor, "bytes32-len")?;
|
||||
let len = u32::from_le_bytes(len_bytes) as usize;
|
||||
NbfxText::Bytes(read_bytes(input, cursor, len, "bytes32")?)
|
||||
}
|
||||
other => return Err(NbfxError::UnknownRecord(other)),
|
||||
})
|
||||
}
|
||||
@@ -657,15 +703,26 @@ fn read_utf8(
|
||||
len: usize,
|
||||
stage: &'static str,
|
||||
) -> Result<String, NbfxError> {
|
||||
let bytes = input
|
||||
let raw = read_bytes(input, cursor, len, stage)?;
|
||||
String::from_utf8(raw).map_err(|_| NbfxError::InvalidUtf8 { stage })
|
||||
}
|
||||
|
||||
fn read_bytes(
|
||||
input: &[u8],
|
||||
cursor: &mut usize,
|
||||
len: usize,
|
||||
stage: &'static str,
|
||||
) -> Result<Vec<u8>, NbfxError> {
|
||||
let slice = input
|
||||
.get(*cursor..*cursor + len)
|
||||
.ok_or(NbfxError::Truncated {
|
||||
need: len,
|
||||
have: input.len().saturating_sub(*cursor),
|
||||
stage,
|
||||
})?;
|
||||
let out = slice.to_vec();
|
||||
*cursor += len;
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| NbfxError::InvalidUtf8 { stage })
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_string(
|
||||
@@ -840,6 +897,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_records_round_trip_all_widths() {
|
||||
for payload in [
|
||||
vec![],
|
||||
vec![0xAB; 5],
|
||||
vec![0xCD; 300], // forces Bytes16
|
||||
vec![0xEF; 70_000], // forces Bytes32
|
||||
] {
|
||||
round_trip(vec![
|
||||
NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline("e".to_string()),
|
||||
},
|
||||
NbfxToken::Text(NbfxText::Bytes(payload)),
|
||||
NbfxToken::EndElement,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chars32_handled_for_payloads_above_u16_max() {
|
||||
let big = "x".repeat(70_000);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! `IAsbCustomSerializableType` binary codecs.
|
||||
//!
|
||||
//! Ports the binary fast-path WCF uses for `Variant` /
|
||||
//! `IAsbCustomSerializableType`-decorated structs. Each type writes a
|
||||
//! `BinaryWriter`-style payload (LE primitives + `AsbBinary` UTF-16 LE
|
||||
//! length-prefixed strings); the WCF `AsbDataCustomSerializer`
|
||||
//! (`AsbContracts.cs:1507-1612`) then base64-encodes that payload and
|
||||
//! wraps it inside an `<ASBIData>` element under the field's outer XML
|
||||
//! tag.
|
||||
//!
|
||||
//! ## Scope
|
||||
//!
|
||||
//! Implements:
|
||||
//! * [`ItemIdentity`] — used by RegisterItems / UnregisterItems / Read
|
||||
//! / AddMonitoredItems / DeleteMonitoredItems request bodies.
|
||||
//!
|
||||
//! Stubbed for follow-up F25 iterations:
|
||||
//! * `ItemStatus`, `ItemRegistration`, `WriteValue`, `RuntimeValue`
|
||||
//! payloads, `ItemWriteComplete`, `MonitoredItemSettings`,
|
||||
//! `MonitoredItem`. The pattern is identical — pure binary
|
||||
//! round-trip — so the per-type cost is small once the
|
||||
//! [`ItemIdentity`] reference establishes it.
|
||||
|
||||
use mxaccess_codec::CodecError;
|
||||
|
||||
/// `ItemIdentity` per `AsbContracts.cs:533-633`. Wire layout:
|
||||
///
|
||||
/// | Offset | Size | Field | Notes |
|
||||
/// |-------:|-----:|---------------|--------------------------------------|
|
||||
/// | 0 | 2 | `Type` | u16 `ItemIdentityType` enum |
|
||||
/// | 2 | 2 | `ReferenceType` | u16 `ItemReferenceType` enum |
|
||||
/// | 4 | n | `Name` | `AsbBinary.WriteUnicodeString` |
|
||||
/// | | m | `ContextName` | `AsbBinary.WriteUnicodeString` |
|
||||
/// | | 8 | `Id` | u64 |
|
||||
/// | | 1 | `IdSpecified` | bool (`BinaryWriter.Write(bool)`) |
|
||||
///
|
||||
/// `AsbBinary.WriteUnicodeString` per `cs:1622-1633`:
|
||||
/// * Null/empty → 4-byte `0u32` length, no payload
|
||||
/// * Non-empty → 4-byte byte-length + UTF-16LE bytes
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ItemIdentity {
|
||||
pub kind: u16,
|
||||
pub reference_type: u16,
|
||||
pub name: Option<String>,
|
||||
pub context_name: Option<String>,
|
||||
pub id: u64,
|
||||
pub id_specified: bool,
|
||||
}
|
||||
|
||||
/// `ItemIdentityType` enum (`AsbContracts.cs:1295-1300`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u16)]
|
||||
pub enum ItemIdentityType {
|
||||
Name = 0,
|
||||
Id = 1,
|
||||
NameAndId = 2,
|
||||
}
|
||||
|
||||
/// `ItemReferenceType` enum (`AsbContracts.cs:1302-1308`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u16)]
|
||||
pub enum ItemReferenceType {
|
||||
None = 0,
|
||||
Absolute = 1,
|
||||
Hierarchical = 2,
|
||||
Relative = 3,
|
||||
}
|
||||
|
||||
impl ItemIdentity {
|
||||
/// Convenience constructor for an absolute name reference. The
|
||||
/// `MxAsbDataClient.CreateAbsoluteItem` path
|
||||
/// (`MxAsbDataClient.cs:172-194`) sets `Type =
|
||||
/// ItemIdentityType.Name`, `ReferenceType =
|
||||
/// ItemReferenceType.Absolute`, and supplies the tag name. Most
|
||||
/// register-time callers use this shape.
|
||||
pub fn absolute_by_name(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ItemIdentityType::Name as u16,
|
||||
reference_type: ItemReferenceType::Absolute as u16,
|
||||
name: Some(name.into()),
|
||||
context_name: None,
|
||||
id: 0,
|
||||
id_specified: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_into(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.kind.to_le_bytes());
|
||||
out.extend_from_slice(&self.reference_type.to_le_bytes());
|
||||
write_unicode_string(out, self.name.as_deref());
|
||||
write_unicode_string(out, self.context_name.as_deref());
|
||||
out.extend_from_slice(&self.id.to_le_bytes());
|
||||
out.push(if self.id_specified { 1 } else { 0 });
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
self.encode_into(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
|
||||
let mut cursor = 0usize;
|
||||
let kind = read_u16_le(input, &mut cursor)?;
|
||||
let reference_type = read_u16_le(input, &mut cursor)?;
|
||||
let name = read_unicode_string(input, &mut cursor)?;
|
||||
let context_name = read_unicode_string(input, &mut cursor)?;
|
||||
let id = read_u64_le(input, &mut cursor)?;
|
||||
let id_specified = read_u8(input, &mut cursor)? != 0;
|
||||
Ok((
|
||||
Self {
|
||||
kind,
|
||||
reference_type,
|
||||
name,
|
||||
context_name,
|
||||
id,
|
||||
id_specified,
|
||||
},
|
||||
cursor,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an array of `IAsbCustomSerializableType` items per
|
||||
/// `AsbDataCustomSerializer.WriteObjectContent` array branch
|
||||
/// (`AsbContracts.cs:1583-1591` — calls `WriteArrayToStream` which
|
||||
/// emits a 4-byte count followed by each element's `WriteToStream`).
|
||||
pub fn encode_item_identity_array(items: &[ItemIdentity]) -> Vec<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
/// Decode an array of `ItemIdentity`s from the WCF custom-serializer
|
||||
/// binary form (4-byte count + items). Mirrors
|
||||
/// `ItemIdentity.InitializeArrayFromStream` (`cs:614-623`).
|
||||
pub fn decode_item_identity_array(input: &[u8]) -> Result<Vec<ItemIdentity>, 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-identity 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) = ItemIdentity::decode(tail)?;
|
||||
cursor += consumed;
|
||||
out.push(item);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- AsbBinary helpers ---------------------------------------------------
|
||||
|
||||
/// Mirror `AsbBinary.WriteUnicodeString` at `cs:1622-1633`. Null/empty
|
||||
/// strings emit a 4-byte `0u32` length and no payload bytes.
|
||||
fn write_unicode_string(out: &mut Vec<u8>, value: Option<&str>) {
|
||||
let s = value.unwrap_or("");
|
||||
if s.is_empty() {
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
return;
|
||||
}
|
||||
let mut utf16 = Vec::with_capacity(s.len() * 2);
|
||||
for unit in s.encode_utf16() {
|
||||
utf16.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
let len = u32::try_from(utf16.len()).unwrap_or(u32::MAX);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&utf16);
|
||||
}
|
||||
|
||||
/// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0
|
||||
/// returns `None` (matches `string.Empty` in .NET — both forms collapse
|
||||
/// to a Rust `None` here so callers can distinguish unset from empty by
|
||||
/// asserting on the original string).
|
||||
fn read_unicode_string(input: &[u8], cursor: &mut usize) -> Result<Option<String>, CodecError> {
|
||||
let len = read_u32_le(input, cursor)? as usize;
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if len % 2 != 0 {
|
||||
return Err(CodecError::Decode {
|
||||
offset: *cursor,
|
||||
reason: "unicode string length is odd",
|
||||
buffer_len: input.len(),
|
||||
});
|
||||
}
|
||||
let bytes = input
|
||||
.get(*cursor..*cursor + len)
|
||||
.ok_or(CodecError::ShortRead {
|
||||
expected: len,
|
||||
actual: input.len().saturating_sub(*cursor),
|
||||
})?;
|
||||
let mut units = Vec::with_capacity(len / 2);
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
let mut buf = [0u8; 2];
|
||||
buf.copy_from_slice(chunk);
|
||||
units.push(u16::from_le_bytes(buf));
|
||||
}
|
||||
let s = String::from_utf16(&units).map_err(|_| CodecError::Decode {
|
||||
offset: *cursor,
|
||||
reason: "invalid UTF-16 in unicode string",
|
||||
buffer_len: input.len(),
|
||||
})?;
|
||||
*cursor += len;
|
||||
Ok(Some(s))
|
||||
}
|
||||
|
||||
fn read_u16_le(input: &[u8], cursor: &mut usize) -> Result<u16, CodecError> {
|
||||
let bytes = read_array::<2>(input, cursor)?;
|
||||
Ok(u16::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result<u32, CodecError> {
|
||||
let bytes = read_array::<4>(input, cursor)?;
|
||||
Ok(u32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result<i32, CodecError> {
|
||||
let bytes = read_array::<4>(input, cursor)?;
|
||||
Ok(i32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn read_u64_le(input: &[u8], cursor: &mut usize) -> Result<u64, CodecError> {
|
||||
let bytes = read_array::<8>(input, cursor)?;
|
||||
Ok(u64::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn read_u8(input: &[u8], cursor: &mut usize) -> Result<u8, CodecError> {
|
||||
let byte = *input.get(*cursor).ok_or(CodecError::ShortRead {
|
||||
expected: 1,
|
||||
actual: 0,
|
||||
})?;
|
||||
*cursor += 1;
|
||||
Ok(byte)
|
||||
}
|
||||
|
||||
fn read_array<const N: usize>(input: &[u8], cursor: &mut usize) -> Result<[u8; N], CodecError> {
|
||||
let slice = input
|
||||
.get(*cursor..*cursor + N)
|
||||
.ok_or(CodecError::ShortRead {
|
||||
expected: N,
|
||||
actual: input.len().saturating_sub(*cursor),
|
||||
})?;
|
||||
let mut out = [0u8; N];
|
||||
out.copy_from_slice(slice);
|
||||
*cursor += N;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round_trip(item: ItemIdentity) {
|
||||
let bytes = item.encode();
|
||||
let (decoded, consumed) = ItemIdentity::decode(&bytes).unwrap();
|
||||
assert_eq!(consumed, bytes.len());
|
||||
assert_eq!(decoded, item);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_round_trip_default() {
|
||||
round_trip(ItemIdentity::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_round_trip_absolute_by_name() {
|
||||
round_trip(ItemIdentity::absolute_by_name("TestChildObject.TestInt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_round_trip_with_id() {
|
||||
round_trip(ItemIdentity {
|
||||
kind: ItemIdentityType::NameAndId as u16,
|
||||
reference_type: ItemReferenceType::Absolute as u16,
|
||||
name: Some("TestChildObject.TestInt".to_string()),
|
||||
context_name: Some("TestObject".to_string()),
|
||||
id: 0x1234_5678_9abc_def0,
|
||||
id_specified: true,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_round_trip_unicode_name() {
|
||||
round_trip(ItemIdentity::absolute_by_name("TéstObj.Φοο"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_byte_layout_minimum_19_bytes() {
|
||||
// Empty Name + empty ContextName + Id=0 + IdSpecified=false:
|
||||
// 2 (kind) + 2 (refType) + 4 (name len=0) + 4 (ctx len=0)
|
||||
// + 8 (id) + 1 (idSpecified) = 21 bytes.
|
||||
let item = ItemIdentity::default();
|
||||
let bytes = item.encode();
|
||||
assert_eq!(bytes.len(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_string_round_trip_handles_null_empty_and_value() {
|
||||
// Null
|
||||
let mut buf = Vec::new();
|
||||
write_unicode_string(&mut buf, None);
|
||||
let mut c = 0;
|
||||
assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None);
|
||||
|
||||
// Empty
|
||||
let mut buf = Vec::new();
|
||||
write_unicode_string(&mut buf, Some(""));
|
||||
let mut c = 0;
|
||||
assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None);
|
||||
|
||||
// ASCII
|
||||
let mut buf = Vec::new();
|
||||
write_unicode_string(&mut buf, Some("hi"));
|
||||
let mut c = 0;
|
||||
assert_eq!(
|
||||
read_unicode_string(&buf, &mut c).unwrap(),
|
||||
Some("hi".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_array_round_trip() {
|
||||
let items = vec![
|
||||
ItemIdentity::absolute_by_name("Tag.A"),
|
||||
ItemIdentity::absolute_by_name("Tag.B"),
|
||||
ItemIdentity::absolute_by_name("Tag.C"),
|
||||
];
|
||||
let bytes = encode_item_identity_array(&items);
|
||||
let decoded = decode_item_identity_array(&bytes).unwrap();
|
||||
assert_eq!(decoded, items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_array_empty() {
|
||||
let bytes = encode_item_identity_array(&[]);
|
||||
// 4 bytes (count = 0)
|
||||
assert_eq!(bytes.len(), 4);
|
||||
assert_eq!(
|
||||
decode_item_identity_array(&bytes).unwrap(),
|
||||
Vec::<ItemIdentity>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_identity_array_count_is_le_int32() {
|
||||
let items = vec![ItemIdentity::default(); 7];
|
||||
let bytes = encode_item_identity_array(&items);
|
||||
// First 4 bytes = 7 little-endian.
|
||||
assert_eq!(&bytes[0..4], &[0x07, 0x00, 0x00, 0x00]);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,16 @@
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod contracts;
|
||||
pub mod envelope;
|
||||
pub mod operations;
|
||||
|
||||
pub use contracts::{
|
||||
ItemIdentity, ItemIdentityType, ItemReferenceType, decode_item_identity_array,
|
||||
encode_item_identity_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};
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Per-operation request / response NBFX-token builders for
|
||||
//! `IASBIDataV2`.
|
||||
//!
|
||||
//! Each `IAsbCustomSerializableType`-decorated field in a request
|
||||
//! contract is serialised by WCF's `AsbDataCustomSerializer`
|
||||
//! (`AsbContracts.cs:1561-1599`) as:
|
||||
//!
|
||||
//! ```xml
|
||||
//! <FieldName xmlns="urn:msg.data.asb.iom:2">
|
||||
//! <ASBIData>{base64-binary}</ASBIData>
|
||||
//! </FieldName>
|
||||
//! ```
|
||||
//!
|
||||
//! The `<ASBIData>` element body is the binary `WriteToStream` /
|
||||
//! `WriteArrayToStream` output, written via `WriteBase64`. In the NBFX
|
||||
//! wire form we get from the WCF binary encoder, `WriteBase64` emits a
|
||||
//! `Bytes8/16/32Text` record (raw binary, NOT base64 text — base64 is
|
||||
//! the XML-text representation of the same bytes).
|
||||
//!
|
||||
//! ## Scope this iteration (F25 step 2)
|
||||
//!
|
||||
//! Implements:
|
||||
//! * [`build_register_items_request_body`] — `RegisterItems` request
|
||||
//! contract per `AsbContracts.cs:119-143`.
|
||||
//! * [`build_unregister_items_request_body`] — `UnregisterItems`
|
||||
//! request per `cs:145-159`.
|
||||
//!
|
||||
//! Stubbed for next F25 iteration:
|
||||
//! * `Read`, `Write`, `PublishWriteComplete`, `CreateSubscription`,
|
||||
//! `AddMonitoredItems`, `DeleteMonitoredItems`, `Publish`. Each
|
||||
//! follows the same NBFX-token pattern; the per-operation cost is
|
||||
//! small once the `RegisterItems` reference is set.
|
||||
//! * Response decoders. Same pattern in reverse: the reply envelope's
|
||||
//! body tokens carry a per-operation outer element wrapping
|
||||
//! `<ASBIData>` Bytes records, each decoded via the corresponding
|
||||
//! `InitializeArrayFromStream` shape.
|
||||
|
||||
use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken};
|
||||
|
||||
use crate::contracts::{ItemIdentity, encode_item_identity_array};
|
||||
|
||||
/// Build the NBFX token stream for the body of a `RegisterItemsIn`
|
||||
/// SOAP envelope. The caller wraps it via [`crate::SoapEnvelope`] +
|
||||
/// [`crate::encode_envelope`].
|
||||
///
|
||||
/// Wire shape (from `AsbContracts.cs:119-143`):
|
||||
/// ```xml
|
||||
/// <RegisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
|
||||
/// <Items>
|
||||
/// <ASBIData>{int32 count + each ItemIdentity binary}</ASBIData>
|
||||
/// </Items>
|
||||
/// <RequireId>true|false</RequireId>
|
||||
/// <RegisterOnly>true|false</RegisterOnly>
|
||||
/// </RegisterItemsRequest>
|
||||
/// ```
|
||||
///
|
||||
/// NOTE: WCF emits the wrapper element's `xmlns` declaration as a
|
||||
/// default-namespace attribute (`<RegisterItemsRequest
|
||||
/// xmlns="urn:...">`). NBFX represents this as a
|
||||
/// `DefaultNamespace`-attribute token immediately after the element
|
||||
/// open.
|
||||
pub fn build_register_items_request_body(
|
||||
items: &[ItemIdentity],
|
||||
require_id: bool,
|
||||
register_only: bool,
|
||||
) -> Vec<NbfxToken> {
|
||||
let payload = encode_item_identity_array(items);
|
||||
asbidata_request_body(
|
||||
"RegisterItemsRequest",
|
||||
&[
|
||||
BodyField::asbidata("Items", payload),
|
||||
BodyField::boolean("RequireId", require_id),
|
||||
BodyField::boolean("RegisterOnly", register_only),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the NBFX token stream for `UnregisterItemsIn`. Mirror of
|
||||
/// `AsbContracts.cs:145-159`:
|
||||
/// ```xml
|
||||
/// <UnregisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
|
||||
/// <Items><ASBIData>{int32 count + each ItemIdentity binary}</ASBIData></Items>
|
||||
/// </UnregisterItemsRequest>
|
||||
/// ```
|
||||
pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec<NbfxToken> {
|
||||
let payload = encode_item_identity_array(items);
|
||||
asbidata_request_body(
|
||||
"UnregisterItemsRequest",
|
||||
&[BodyField::asbidata("Items", payload)],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- internal helpers ----------------------------------------------------
|
||||
|
||||
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum BodyField {
|
||||
/// Plain element with text body.
|
||||
BoolElement { name: &'static str, value: bool },
|
||||
/// Element wrapping `<ASBIData>` with base64-binary content (NBFX
|
||||
/// represents that as `Bytes` text records).
|
||||
AsbiDataElement {
|
||||
name: &'static str,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl BodyField {
|
||||
fn boolean(name: &'static str, value: bool) -> Self {
|
||||
Self::BoolElement { name, value }
|
||||
}
|
||||
|
||||
fn asbidata(name: &'static str, payload: Vec<u8>) -> Self {
|
||||
Self::AsbiDataElement { name, payload }
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `<{outer} xmlns="urn:msg.data.asb.iom:2"> ... </{outer}>` with
|
||||
/// each [`BodyField`] in order.
|
||||
fn asbidata_request_body(outer: &str, fields: &[BodyField]) -> Vec<NbfxToken> {
|
||||
let mut tokens = vec![
|
||||
NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline(outer.to_string()),
|
||||
},
|
||||
NbfxToken::DefaultNamespace {
|
||||
value: NbfxText::Chars(IOM_NS.to_string()),
|
||||
},
|
||||
];
|
||||
for field in fields {
|
||||
match field {
|
||||
BodyField::BoolElement { name, value } => {
|
||||
tokens.push(NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline((*name).to_string()),
|
||||
});
|
||||
tokens.push(NbfxToken::Text(NbfxText::Bool(*value)));
|
||||
tokens.push(NbfxToken::EndElement);
|
||||
}
|
||||
BodyField::AsbiDataElement { name, payload } => {
|
||||
tokens.push(NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline((*name).to_string()),
|
||||
});
|
||||
tokens.push(NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline("ASBIData".to_string()),
|
||||
});
|
||||
tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone())));
|
||||
tokens.push(NbfxToken::EndElement); // </ASBIData>
|
||||
tokens.push(NbfxToken::EndElement); // </{name}>
|
||||
}
|
||||
}
|
||||
}
|
||||
tokens.push(NbfxToken::EndElement); // </{outer}>
|
||||
tokens
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::contracts::decode_item_identity_array;
|
||||
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
|
||||
|
||||
#[test]
|
||||
fn register_items_body_round_trips_items_via_asbidata() {
|
||||
let items = vec![
|
||||
ItemIdentity::absolute_by_name("Tag.A"),
|
||||
ItemIdentity::absolute_by_name("Tag.B"),
|
||||
];
|
||||
let body = build_register_items_request_body(&items, true, false);
|
||||
|
||||
// The body should open with <RegisterItemsRequest xmlns="...">
|
||||
assert!(matches!(
|
||||
&body[0],
|
||||
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "RegisterItemsRequest"
|
||||
));
|
||||
assert!(matches!(
|
||||
&body[1],
|
||||
NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns) } if ns == IOM_NS
|
||||
));
|
||||
|
||||
// Find the <ASBIData>{Bytes}</ASBIData> token sequence and pull
|
||||
// the Bytes payload back out — it must round-trip the
|
||||
// ItemIdentity array exactly.
|
||||
let mut bytes_payload: Option<Vec<u8>> = None;
|
||||
for window in body.windows(3) {
|
||||
if matches!(
|
||||
&window[0],
|
||||
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ASBIData"
|
||||
) {
|
||||
if let NbfxToken::Text(NbfxText::Bytes(b)) = &window[1] {
|
||||
if matches!(window[2], NbfxToken::EndElement) {
|
||||
bytes_payload = Some(b.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let payload = bytes_payload.expect("ASBIData Bytes record not found in body");
|
||||
let decoded = decode_item_identity_array(&payload).unwrap();
|
||||
assert_eq!(decoded, items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_items_request_round_trips_through_envelope() {
|
||||
// End-to-end: build_register_items_request_body → SoapEnvelope
|
||||
// → encode_envelope → decode_envelope → re-extract body tokens
|
||||
// → re-extract ItemIdentity array.
|
||||
let items = vec![ItemIdentity::absolute_by_name("Tag.X")];
|
||||
let body = build_register_items_request_body(&items, true, true);
|
||||
|
||||
let env = crate::SoapEnvelope::new(crate::actions::REGISTER_ITEMS).with_body_tokens(body);
|
||||
|
||||
let mut dyn_w = DynamicDictionary::new();
|
||||
let bytes = crate::encode_envelope(&env, &mut dyn_w).unwrap();
|
||||
|
||||
let mut dyn_r = DynamicDictionary::new();
|
||||
let decoded = crate::decode_envelope(&bytes, &mut dyn_r).unwrap();
|
||||
assert_eq!(
|
||||
decoded.action.as_deref(),
|
||||
Some(crate::actions::REGISTER_ITEMS)
|
||||
);
|
||||
|
||||
let mut bytes_payload: Option<Vec<u8>> = None;
|
||||
for window in decoded.body_tokens.windows(3) {
|
||||
if matches!(
|
||||
&window[0],
|
||||
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ASBIData"
|
||||
) {
|
||||
if let NbfxToken::Text(NbfxText::Bytes(b)) = &window[1] {
|
||||
bytes_payload = Some(b.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let payload = bytes_payload.expect("ASBIData payload missing from decoded envelope");
|
||||
let recovered = decode_item_identity_array(&payload).unwrap();
|
||||
assert_eq!(recovered, items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_items_body_carries_require_id_and_register_only_booleans() {
|
||||
let body = build_register_items_request_body(&[], true, false);
|
||||
// After the <Items><ASBIData>{}</ASBIData></Items> sub-tree, the
|
||||
// body should carry <RequireId>true</RequireId> followed by
|
||||
// <RegisterOnly>false</RegisterOnly>. Because `Bytes(empty)`
|
||||
// still emits a Bytes8 record + 1 EndElement + 1 EndElement,
|
||||
// walk the tokens by name to be robust.
|
||||
let mut saw_require_id_true = false;
|
||||
let mut saw_register_only_false = false;
|
||||
let mut idx = 0;
|
||||
while idx < body.len() {
|
||||
if let NbfxToken::Element {
|
||||
name: NbfxName::Inline(local),
|
||||
..
|
||||
} = &body[idx]
|
||||
{
|
||||
if local == "RequireId"
|
||||
&& matches!(
|
||||
body.get(idx + 1),
|
||||
Some(NbfxToken::Text(NbfxText::Bool(true)))
|
||||
)
|
||||
{
|
||||
saw_require_id_true = true;
|
||||
}
|
||||
if local == "RegisterOnly"
|
||||
&& matches!(
|
||||
body.get(idx + 1),
|
||||
Some(NbfxToken::Text(NbfxText::Bool(false)))
|
||||
)
|
||||
{
|
||||
saw_register_only_false = true;
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
assert!(saw_require_id_true, "RequireId true not found");
|
||||
assert!(saw_register_only_false, "RegisterOnly false not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_items_body_uses_correct_outer_element_name() {
|
||||
let body = build_unregister_items_request_body(&[ItemIdentity::absolute_by_name("X")]);
|
||||
assert!(matches!(
|
||||
&body[0],
|
||||
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "UnregisterItemsRequest"
|
||||
));
|
||||
// Should NOT have RequireId / RegisterOnly fields — the
|
||||
// unregister contract has only the Items array.
|
||||
for tok in &body {
|
||||
if let NbfxToken::Element {
|
||||
name: NbfxName::Inline(local),
|
||||
..
|
||||
} = tok
|
||||
{
|
||||
assert!(local != "RequireId");
|
||||
assert!(local != "RegisterOnly");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_items_array_still_produces_valid_envelope() {
|
||||
let body = build_register_items_request_body(&[], false, false);
|
||||
let env = crate::SoapEnvelope::new(crate::actions::REGISTER_ITEMS).with_body_tokens(body);
|
||||
let mut dyn_w = DynamicDictionary::new();
|
||||
let bytes = crate::encode_envelope(&env, &mut dyn_w).unwrap();
|
||||
// Round-trip — at minimum, the action must come back.
|
||||
let mut dyn_r = DynamicDictionary::new();
|
||||
let decoded = crate::decode_envelope(&bytes, &mut dyn_r).unwrap();
|
||||
assert_eq!(
|
||||
decoded.action.as_deref(),
|
||||
Some(crate::actions::REGISTER_ITEMS)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user