[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:
Joseph Doherty
2026-05-05 11:24:19 -04:00
parent 25dbd8d3bd
commit a2b8989cbf
5 changed files with 784 additions and 3 deletions
+324
View File
@@ -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)
);
}
}