Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs
T

242 lines
13 KiB
C#

using System.Globalization;
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The element/attribute reading rules shared by <see cref="MTConnectProbeParser"/> and
/// <see cref="MTConnectStreamsParser"/>. Both documents are the same dialect of XML and must be
/// read by the same rules; keeping one copy is what stops the two parsers drifting apart on the
/// two rules below, either of which fails silently rather than loudly.
/// </summary>
/// <remarks>
/// <para>
/// <b>Rule 1: elements are matched on <see cref="XName.LocalName"/>, ignoring the
/// namespace.</b> The namespace URI carries the MTConnect version
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>), so a fully-qualified match
/// would hard-code a version. It also silently drops vendor-extension elements declared in
/// their own namespace, whose standard children still inherit the default MTConnect
/// namespace — the browse tree simply comes back short, with no error anywhere.
/// </para>
/// <para>
/// <b>Rule 2: attributes are read <i>unqualified</i>.</b> A prefixed attribute
/// (<c>xsi:type</c> on a document root, a vendor's <c>x:dataItemId</c>) must never be
/// mistaken for the real <c>type</c> / <c>dataItemId</c>, which would invent a data item the
/// probe never declared.
/// </para>
/// </remarks>
internal static class MTConnectXml
{
/// <summary>Does <paramref name="element"/> have the given local name, whatever its namespace?</summary>
/// <param name="element">The element to test.</param>
/// <param name="localName">The unqualified name to match.</param>
public static bool IsNamed(XElement element, string localName) =>
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
/// <summary>
/// Does the element the reader is positioned on have the given local name, whatever its
/// namespace?
/// </summary>
/// <remarks>
/// <see cref="MTConnectStreamsParser"/> reads its documents through a forward-only
/// <see cref="XmlReader"/> rather than a materialised tree — one sample chunk is read once
/// and thrown away, so building an <see cref="XDocument"/> for it is pure waste. The reader
/// overloads here exist so that going forward-only did not fork the two silent rules in the
/// type-level remarks: both flavours match on the local name, read attributes unqualified,
/// and produce the same error text.
/// </remarks>
/// <param name="reader">A reader positioned on an element.</param>
/// <param name="localName">The unqualified name to match.</param>
public static bool IsNamed(XmlReader reader, string localName) =>
string.Equals(reader.LocalName, localName, StringComparison.Ordinal);
/// <summary>Direct element children of <paramref name="parent"/> with the given local name.</summary>
/// <param name="parent">The element whose children to filter.</param>
/// <param name="localName">The unqualified name to match.</param>
public static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
parent.Elements().Where(child => IsNamed(child, localName));
/// <summary>
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
/// See the type-level remarks for why only unqualified attributes are considered.
/// </summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
public static string? OptionalAttribute(XElement element, string name)
{
var value = element.Attribute(name)?.Value;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>
/// Reads an unqualified attribute off the element the reader is positioned on, normalizing a
/// present-but-empty value to <c>null</c>. See <see cref="IsNamed(XmlReader, string)"/> for
/// why the reader overloads exist.
/// </summary>
/// <remarks>
/// <see cref="XmlReader.GetAttribute(string)"/> matches on the attribute's <i>qualified</i>
/// name, so a prefixed <c>x:dataItemId</c> does not answer to <c>dataItemId</c> — exactly
/// what rule 2 in the type-level remarks requires, and what <see cref="XElement.Attribute"/>
/// does for a namespace-free <see cref="XName"/>.
/// </remarks>
/// <param name="reader">A reader positioned on an element.</param>
/// <param name="name">The unqualified attribute name.</param>
public static string? OptionalAttribute(XmlReader reader, string name)
{
var value = reader.GetAttribute(name);
return string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>Reads a required unqualified attribute, or throws naming the owner and the attribute.</summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message (e.g. "/probe response").</param>
/// <exception cref="InvalidDataException">The attribute is absent or blank.</exception>
public static string RequiredAttribute(XElement element, string name, string owner, string subject) =>
OptionalAttribute(element, name) ?? throw MissingAttribute(name, owner, subject);
/// <summary>
/// Reads a required unqualified attribute off the element the reader is positioned on, or
/// throws naming the owner and the attribute.
/// </summary>
/// <param name="reader">A reader positioned on an element.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <exception cref="InvalidDataException">The attribute is absent or blank.</exception>
public static string RequiredAttribute(XmlReader reader, string name, string owner, string subject) =>
OptionalAttribute(reader, name) ?? throw MissingAttribute(name, owner, subject);
/// <summary>Reads an optional unqualified attribute as an <see cref="int"/>.</summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <exception cref="InvalidDataException">The attribute is present but not an integer.</exception>
public static int? OptionalIntAttribute(XElement element, string name, string owner, string subject)
{
var raw = OptionalAttribute(element, name);
if (raw is null)
{
return null;
}
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
throw new InvalidDataException(
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
}
return value;
}
/// <summary>Reads a required unqualified attribute as a <see cref="long"/>.</summary>
/// <param name="element">The element carrying the attribute.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <exception cref="InvalidDataException">The attribute is absent, blank, or not an integer.</exception>
public static long RequiredLongAttribute(XElement element, string name, string owner, string subject) =>
AsLong(RequiredAttribute(element, name, owner, subject), name, owner, subject);
/// <summary>
/// Reads a required unqualified attribute off the element the reader is positioned on, as a
/// <see cref="long"/>.
/// </summary>
/// <param name="reader">A reader positioned on an element.</param>
/// <param name="name">The unqualified attribute name.</param>
/// <param name="owner">How to describe the element in the error message.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <exception cref="InvalidDataException">The attribute is absent, blank, or not an integer.</exception>
public static long RequiredLongAttribute(XmlReader reader, string name, string owner, string subject) =>
AsLong(RequiredAttribute(reader, name, owner, subject), name, owner, subject);
/// <summary>The one spelling of "that attribute is missing", shared by both flavours.</summary>
private static InvalidDataException MissingAttribute(string name, string owner, string subject) =>
new($"MTConnect {subject} is malformed: {owner} has no '{name}' attribute.");
/// <summary>The one spelling of "that attribute is not a number", shared by both flavours.</summary>
private static long AsLong(string raw, string name, string owner, string subject)
{
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
throw new InvalidDataException(
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
}
return value;
}
/// <summary>
/// Builds the error message for a document whose root is not what was expected, lifting the
/// Agent's own error text when the response is an <c>MTConnectError</c> document — which
/// Agents return under <b>HTTP 200</b>, so <c>EnsureSuccessStatusCode</c> never fires and
/// this is the only place the operator can learn what the Agent objected to.
/// </summary>
/// <param name="root">The document's actual root element.</param>
/// <param name="expectedRootName">The root element name the caller required.</param>
/// <param name="subject">How to describe the document in the error message.</param>
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
{
var message =
$"MTConnect {subject} root element is <{root.Name.LocalName}>, expected <{expectedRootName}>.";
if (!IsNamed(root, "MTConnectError"))
{
return message;
}
var errors = ExtractErrorText(root);
return errors is null ? message : $"{message} The Agent reported: {errors}";
}
/// <summary>
/// The Agent's own words from an <c>MTConnectError</c> document: every <c>&lt;Error&gt;</c>
/// beneath <paramref name="root"/>, rendered <c>errorCode: text</c> where a code is present
/// and joined with <c>"; "</c>. <c>null</c> when the document carries no readable error
/// detail at all (no <c>&lt;Error&gt;</c> elements, or only blank ones).
/// </summary>
/// <remarks>
/// <para>
/// <b>Shared because both callers must read the same document the same way.</b> An
/// Agent's objection reaches this driver through two unrelated doors — a read path that
/// got an error document where it expected a model
/// (<see cref="DescribeUnexpectedRoot"/>), and the write path deciding whether a 200
/// actually accepted the observation. Two copies of this walk would drift on the same
/// two silent rules the type-level remarks describe, and on the write side a missed
/// <c>&lt;Error&gt;</c> does not merely degrade a message: it reads as a successful
/// write.
/// </para>
/// <para>
/// <b><c>null</c> means "no readable detail", NOT "not an error document".</b> Only the
/// caller knows which document it is holding — check the root name first. A caller that
/// treats a <c>null</c> here as "no error occurred" turns a hollow
/// <c>&lt;MTConnectError/&gt;</c> into a success.
/// </para>
/// </remarks>
/// <param name="root">The <c>MTConnectError</c> document's root element.</param>
public static string? ExtractErrorText(XElement root)
{
ArgumentNullException.ThrowIfNull(root);
var errors = root.Descendants()
.Where(e => IsNamed(e, "Error"))
.Select(e =>
{
var code = OptionalAttribute(e, "errorCode");
var text = e.Value.Trim();
return code is null ? text : $"{code}: {text}";
})
.Where(text => text.Length > 0)
.ToList();
return errors.Count == 0 ? null : string.Join("; ", errors);
}
}