feat(mesh): DTO mapper with round-trip goldens
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// The one translation between the five cross-mesh command records and their
|
||||
/// <c>mesh_transport.v1</c> wire shapes (ClusterClient→gRPC migration Phase 0). Both directions live
|
||||
/// here so a field can only be added to one side without the other by making this file
|
||||
/// visibly asymmetric.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nothing here guesses.</b> The transport it replaces — an Akka ClusterClient receptionist —
|
||||
/// dropped an undeliverable command silently, and the entire point of the migration is to stop
|
||||
/// doing that. So every decode that cannot produce a faithful record throws: an unset oneof, an
|
||||
/// <c>APPLY_OUTCOME_UNSPECIFIED</c> ack, an enum value from a newer peer. A loud failure on one
|
||||
/// command is recoverable; a deployment reported applied because an absent enum field decoded to
|
||||
/// 0 is not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Presence, not emptiness.</b> The two nullable members that cross this contract —
|
||||
/// <see cref="AlarmCommand.Comment"/> and <see cref="ApplyAck.FailureReason"/> — are
|
||||
/// <c>optional string</c> on the wire, so <c>null</c> maps to an absent field and <c>""</c> to a
|
||||
/// present-but-empty one. Collapsing them would report an operator's deliberately blank comment
|
||||
/// as "no comment supplied", and a blank failure reason as "no reason recorded".
|
||||
/// <see cref="AlarmCommand.UnshelveAtUtc"/> uses a message field, whose own absence carries the
|
||||
/// null.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Correlation is written twice and read once.</b> Encode stamps the envelope's
|
||||
/// <c>correlation_id</c> and the command's own from the same source. Decode takes the command's
|
||||
/// own for the three records that declare one — that member is domain state the record must
|
||||
/// carry back verbatim — and leaves the envelope's to the transport, which is the only place
|
||||
/// <see cref="AlarmCommand"/>'s correlation can live at all since the record has no member for it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MeshTransportDtoMapper
|
||||
{
|
||||
// ---------------------------------------------------------------- DispatchDeployment
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain dispatch command.</param>
|
||||
/// <returns>The wire <see cref="DispatchDeploymentCommand"/>.</returns>
|
||||
public static DispatchDeploymentCommand ToProto(DispatchDeployment message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new DispatchDeploymentCommand
|
||||
{
|
||||
DeploymentId = message.DeploymentId.ToString(),
|
||||
RevisionHash = message.RevisionHash.Value ?? "",
|
||||
CorrelationId = message.CorrelationId.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire dispatch command.</param>
|
||||
/// <returns>The domain <see cref="DispatchDeployment"/>.</returns>
|
||||
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
|
||||
/// <exception cref="ArgumentException">The revision hash is absent or blank.</exception>
|
||||
public static DispatchDeployment FromProto(DispatchDeploymentCommand proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new DispatchDeployment(
|
||||
DeploymentId.Parse(proto.DeploymentId),
|
||||
RevisionHash.Parse(proto.RevisionHash),
|
||||
CorrelationId.Parse(proto.CorrelationId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RestartDriver
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain restart command.</param>
|
||||
/// <returns>The wire <see cref="RestartDriverCommand"/>.</returns>
|
||||
public static RestartDriverCommand ToProto(RestartDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
// Required (non-optional) proto string setters throw on null and the record's nullable-ref
|
||||
// annotations are compile-time only, so a null reaching here is already a producer bug. Coalesce
|
||||
// rather than throw out of the mapper: a command with a blank field is diagnosable downstream,
|
||||
// whereas a throw here is the silent drop this transport exists to remove.
|
||||
return new RestartDriverCommand
|
||||
{
|
||||
ClusterId = message.ClusterId ?? "",
|
||||
DriverInstanceId = message.DriverInstanceId ?? "",
|
||||
ActorByUserName = message.ActorByUserName ?? "",
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire restart command.</param>
|
||||
/// <returns>The domain <see cref="RestartDriver"/>.</returns>
|
||||
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
|
||||
public static RestartDriver FromProto(RestartDriverCommand proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new RestartDriver(
|
||||
proto.ClusterId,
|
||||
proto.DriverInstanceId,
|
||||
proto.ActorByUserName,
|
||||
Guid.ParseExact(proto.CorrelationId, "N"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ReconnectDriver
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain reconnect command.</param>
|
||||
/// <returns>The wire <see cref="ReconnectDriverCommand"/>.</returns>
|
||||
public static ReconnectDriverCommand ToProto(ReconnectDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new ReconnectDriverCommand
|
||||
{
|
||||
ClusterId = message.ClusterId ?? "",
|
||||
DriverInstanceId = message.DriverInstanceId ?? "",
|
||||
ActorByUserName = message.ActorByUserName ?? "",
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire reconnect command.</param>
|
||||
/// <returns>The domain <see cref="ReconnectDriver"/>.</returns>
|
||||
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
|
||||
public static ReconnectDriver FromProto(ReconnectDriverCommand proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new ReconnectDriver(
|
||||
proto.ClusterId,
|
||||
proto.DriverInstanceId,
|
||||
proto.ActorByUserName,
|
||||
Guid.ParseExact(proto.CorrelationId, "N"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- AlarmCommand
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain alarm command.</param>
|
||||
/// <returns>The wire <see cref="AlarmCommandMessage"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <see cref="AlarmCommand.Operation"/> is copied verbatim. It is an unconstrained string in the
|
||||
/// record and stays one on the wire, so an operation this build does not recognise reaches the
|
||||
/// far side intact instead of collapsing onto an enum's UNSPECIFIED and being dropped.
|
||||
/// </remarks>
|
||||
public static AlarmCommandMessage ToProto(AlarmCommand message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var proto = new AlarmCommandMessage
|
||||
{
|
||||
AlarmId = message.AlarmId ?? "",
|
||||
Operation = message.Operation ?? "",
|
||||
User = message.User ?? "",
|
||||
};
|
||||
|
||||
if (message.Comment is not null)
|
||||
proto.Comment = message.Comment;
|
||||
if (message.UnshelveAtUtc is { } unshelveAt)
|
||||
proto.UnshelveAtUtc = ToUtcTimestamp(unshelveAt);
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire alarm command.</param>
|
||||
/// <returns>The domain <see cref="AlarmCommand"/>.</returns>
|
||||
public static AlarmCommand FromProto(AlarmCommandMessage proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new AlarmCommand(
|
||||
proto.AlarmId,
|
||||
proto.Operation,
|
||||
proto.User,
|
||||
proto.HasComment ? proto.Comment : null,
|
||||
proto.UnshelveAtUtc?.ToDateTime());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ApplyAck
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain per-node apply acknowledgment.</param>
|
||||
/// <returns>The wire <see cref="ApplyAckMessage"/>.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <see cref="ApplyAck.Outcome"/> is not a declared <see cref="ApplyAckOutcome"/>.
|
||||
/// </exception>
|
||||
public static ApplyAckMessage ToProto(ApplyAck message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var proto = new ApplyAckMessage
|
||||
{
|
||||
DeploymentId = message.DeploymentId.ToString(),
|
||||
NodeId = message.NodeId.Value ?? "",
|
||||
Outcome = message.Outcome switch
|
||||
{
|
||||
ApplyAckOutcome.Applied => ApplyOutcome.Applied,
|
||||
ApplyAckOutcome.Failed => ApplyOutcome.Failed,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(message), message.Outcome, "Unknown ApplyAckOutcome"),
|
||||
},
|
||||
CorrelationId = message.CorrelationId.ToString(),
|
||||
};
|
||||
|
||||
if (message.FailureReason is not null)
|
||||
proto.FailureReason = message.FailureReason;
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire apply acknowledgment.</param>
|
||||
/// <returns>The domain <see cref="ApplyAck"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The outcome is <c>APPLY_OUTCOME_UNSPECIFIED</c> or a value this build does not know.
|
||||
/// </exception>
|
||||
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
|
||||
/// <exception cref="ArgumentException">The node id is absent or blank.</exception>
|
||||
public static ApplyAck FromProto(ApplyAckMessage proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
// proto3 decodes an absent enum field as 0, and the deploy path seals a revision on Applied.
|
||||
// ApplyAckOutcome has no Unknown member to land on, so an unspecified or unrecognised outcome
|
||||
// must refuse rather than pick one of the two real answers — either choice would be a lie about
|
||||
// what the node said, and the "Applied" lie seals a deployment nobody applied.
|
||||
var outcome = proto.Outcome switch
|
||||
{
|
||||
ApplyOutcome.Applied => ApplyAckOutcome.Applied,
|
||||
ApplyOutcome.Failed => ApplyAckOutcome.Failed,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"ApplyAckMessage carries outcome '{proto.Outcome}', which has no ApplyAckOutcome " +
|
||||
"equivalent. Refusing to decode rather than report an unsaid deployment result."),
|
||||
};
|
||||
|
||||
return new ApplyAck(
|
||||
DeploymentId.Parse(proto.DeploymentId),
|
||||
NodeId.Parse(proto.NodeId),
|
||||
outcome,
|
||||
proto.HasFailureReason ? proto.FailureReason : null,
|
||||
CorrelationId.Parse(proto.CorrelationId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Envelope
|
||||
|
||||
/// <summary>Wraps a dispatch command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain dispatch command.</param>
|
||||
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
|
||||
public static CommandEnvelope ToEnvelope(DispatchDeployment message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = message.CorrelationId.ToString(),
|
||||
DispatchDeployment = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps a restart command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain restart command.</param>
|
||||
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
|
||||
public static CommandEnvelope ToEnvelope(RestartDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
RestartDriver = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps a reconnect command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain reconnect command.</param>
|
||||
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
|
||||
public static CommandEnvelope ToEnvelope(ReconnectDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
ReconnectDriver = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps an alarm command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain alarm command.</param>
|
||||
/// <param name="correlationId">
|
||||
/// Transport correlation. Explicit because <see cref="AlarmCommand"/> is the one record with no
|
||||
/// correlation member of its own, so the envelope is the only place it can be carried.
|
||||
/// </param>
|
||||
/// <returns>The envelope.</returns>
|
||||
public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = correlationId.ToString(),
|
||||
AlarmCommand = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Untyped dispatch for the transport router, which holds its payload as
|
||||
/// <see cref="MeshCommand.Message"/> (an <see cref="object"/>).
|
||||
/// </summary>
|
||||
/// <param name="message">One of the four central→node command records.</param>
|
||||
/// <param name="alarmCorrelationId">
|
||||
/// Consulted <b>only</b> when <paramref name="message"/> is an <see cref="AlarmCommand"/>. The
|
||||
/// other three carry their own correlation and it is authoritative for them, so passing a
|
||||
/// different value here cannot make the envelope disagree with the command inside it.
|
||||
/// </param>
|
||||
/// <returns>The envelope produced by the matching typed overload.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="message"/> is not one of the four handled command records — the transport
|
||||
/// would otherwise carry a payload the far side cannot decode.
|
||||
/// </exception>
|
||||
public static CommandEnvelope ToEnvelope(object message, CorrelationId alarmCorrelationId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return message switch
|
||||
{
|
||||
DispatchDeployment m => ToEnvelope(m),
|
||||
RestartDriver m => ToEnvelope(m),
|
||||
ReconnectDriver m => ToEnvelope(m),
|
||||
AlarmCommand m => ToEnvelope(m, alarmCorrelationId),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(message), message.GetType().FullName, "Not a cross-mesh command record"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Unwraps a transport envelope into the domain record it carries.</summary>
|
||||
/// <param name="envelope">The received envelope.</param>
|
||||
/// <returns>
|
||||
/// One of <see cref="DispatchDeployment"/>, <see cref="RestartDriver"/>,
|
||||
/// <see cref="ReconnectDriver"/> or <see cref="AlarmCommand"/>, boxed — the node-side handlers
|
||||
/// match on the concrete type exactly as they do under the DPS transport.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The oneof is unset or names a case this build does not handle. Kept unreachable by
|
||||
/// <c>MeshTransportProtoContract</c> and its contract-lock test; it throws anyway because the
|
||||
/// alternative is a command that crosses the mesh and disappears.
|
||||
/// </exception>
|
||||
public static object FromEnvelope(CommandEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
return envelope.CommandCase switch
|
||||
{
|
||||
CommandEnvelope.CommandOneofCase.DispatchDeployment => FromProto(envelope.DispatchDeployment),
|
||||
CommandEnvelope.CommandOneofCase.RestartDriver => FromProto(envelope.RestartDriver),
|
||||
CommandEnvelope.CommandOneofCase.ReconnectDriver => FromProto(envelope.ReconnectDriver),
|
||||
CommandEnvelope.CommandOneofCase.AlarmCommand => FromProto(envelope.AlarmCommand),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"CommandEnvelope (correlation '{envelope.CorrelationId}') carries oneof case " +
|
||||
$"'{envelope.CommandCase}', which this build does not handle."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
|
||||
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
|
||||
/// already-UTC (the producer contract for every <c>…Utc</c> member here).
|
||||
/// </summary>
|
||||
private static Timestamp ToUtcTimestamp(DateTime dt)
|
||||
{
|
||||
var utc = dt.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => dt,
|
||||
DateTimeKind.Local => dt.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
return Timestamp.FromDateTime(utc);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user