fix(client): Client.Shared EndpointSelector uses the shared OpcUaEndpointUrlRewriter (#812)

Client.Shared now project-references Driver.OpcUaClient.Contracts (Core.Abstractions
only, no NuGet/SDK; pinned by ContractsIsDependencyLightTests) and delegates the
host/port rewrite to OpcUaEndpointUrlRewriter, deleting its own copy.

The two copies differed; reconciled toward the client so its pinned behaviour is
unchanged (EndpointSelectorRewriteParityTests, written green against the old copy):
- no-op rewrite (same host case-insensitively + same port) now returns the advertised
  string verbatim in the shared rewriter too (it re-serialised: added a trailing slash
  and case-folded a case-only host difference);
- a real rewrite serialises via AbsoluteUri, not Uri.ToString(), which un-escaped the
  advertised path (/x%20y became /x y) in the driver + browser;
- unusable URLs: the rewriter still falls back to the configured URL (driver/browser);
  the client keeps its loud failure as call-site validation (null -> InvalidOperation,
  unparseable -> UriFormatException).
This commit is contained in:
Joseph Doherty
2026-11-10 22:46:51 -05:00
parent 90491aa8b5
commit d52a3b1c64
4 changed files with 64 additions and 29 deletions
@@ -5,7 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// OpcUaClient driver family. A server reports its GetEndpoints results with ITS view of its own address; behind
/// NAT, a port map, a container port publish or a proxy that address is unreachable from here (or bypasses the
/// proxy). Shared by the runtime driver (Gitea #676) and the AdminUI live-browse picker (Gitea #806, which used
/// the advertised URL unchanged and failed DNS on a published-port fixture the driver handled).
/// the advertised URL unchanged and failed DNS on a published-port fixture the driver handled), and — since
/// Gitea #812 — by the Client CLI/UI's <c>EndpointSelector</c> (Client.Shared), whose own copy of this rule it
/// replaced.
/// </summary>
/// <remarks>
/// Pure string/<see cref="Uri"/> logic, deliberately in <c>.Contracts</c>: it is not session-typed, so it must not
@@ -18,8 +20,10 @@ public static class OpcUaEndpointUrlRewriter
/// <summary>
/// The address to open the session on. When the advertised URL's scheme matches the configured one, the
/// configured host AND port (when it names one) replace the advertised ones and the advertised path is kept —
/// the SDK's own <c>CoreClientUtils.SelectEndpoint</c> rule. A different scheme is a different transport and is
/// left alone; an unparseable advertised URL falls back to the configured one.
/// the SDK's own <c>CoreClientUtils.SelectEndpoint</c> rule. When host (case-insensitively) and port already
/// match, the advertised string is returned verbatim. A different scheme is a different transport and is left
/// alone; an unparseable advertised URL falls back to the configured one (callers that must fail loudly
/// instead, like the Client CLI/UI, validate before calling).
/// </summary>
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
/// <param name="configuredUrl">The URL the operator configured (the one GetEndpoints was sent to).</param>
@@ -32,12 +36,16 @@ public static class OpcUaEndpointUrlRewriter
return configuredUrl;
if (!string.Equals(advertised.Scheme, configured.Scheme, StringComparison.OrdinalIgnoreCase)) return advertisedUrl;
// A port-less configured URL (Uri.Port == -1 for opc.tcp) keeps the advertised port — copying -1 would drop it
// and the SDK would fall back to 4840 (review of 3b88056f; same fix as the client's EndpointSelector).
var builder = new UriBuilder(advertised)
{
Host = configured.DnsSafeHost,
Port = configured.Port > 0 ? configured.Port : advertised.Port,
};
return builder.Uri.ToString();
// and the SDK would fall back to 4840 (review of 3b88056f).
var port = configured.Port > 0 ? configured.Port : advertised.Port;
// Nothing to rewrite: return the advertised string verbatim rather than a re-serialised copy (no added
// trailing slash, no case-folding of a host that only differs in case — DNS is case-insensitive). This is the
// Client CLI/UI's long-standing rule, kept when its own copy was folded in here (#812).
if (string.Equals(advertised.DnsSafeHost, configured.DnsSafeHost, StringComparison.OrdinalIgnoreCase)
&& advertised.Port == port)
return advertisedUrl;
var builder = new UriBuilder(advertised) { Host = configured.DnsSafeHost, Port = port };
// AbsoluteUri, not ToString(): ToString() un-escapes, turning an advertised "/x%20y" into "/x y" (#812).
return builder.Uri.AbsoluteUri;
}
}