[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);
|
||||
|
||||
Reference in New Issue
Block a user