feat(lmxproxy): phase 7 — integration tests, deployment to windev, v1 cutover

- Replaced STA dispatch thread with Task.Run pattern for COM interop
- Fixed TypedValue oneof tracking with property-level _setCase field
- Added x-api-key DelegatingHandler for gRPC metadata authentication
- Fixed CheckApiKey RPC to validate request body key (not header)
- Integration tests: 15/17 pass (reads, subscribes, API keys, connections)
- 2 write tests pending (OnWriteComplete callback timing issue)
- v2 service deployed on windev port 50100

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 01:11:44 -04:00
parent 6d9bf594ec
commit 779598d962
14 changed files with 497 additions and 383 deletions
@@ -37,52 +37,51 @@ public interface IScadaService
[DataContract]
public class TypedValue
{
// Tracks which oneof field was set (by property setter during deserialization or manual assignment).
private TypedValueCase _setCase = TypedValueCase.None;
private bool _boolValue;
private int _int32Value;
private long _int64Value;
private float _floatValue;
private double _doubleValue;
private string? _stringValue;
private byte[]? _bytesValue;
private long _datetimeValue;
private ArrayValue? _arrayValue;
[DataMember(Order = 1)]
public bool BoolValue { get; set; }
public bool BoolValue { get => _boolValue; set { _boolValue = value; _setCase = TypedValueCase.BoolValue; } }
[DataMember(Order = 2)]
public int Int32Value { get; set; }
public int Int32Value { get => _int32Value; set { _int32Value = value; _setCase = TypedValueCase.Int32Value; } }
[DataMember(Order = 3)]
public long Int64Value { get; set; }
public long Int64Value { get => _int64Value; set { _int64Value = value; _setCase = TypedValueCase.Int64Value; } }
[DataMember(Order = 4)]
public float FloatValue { get; set; }
public float FloatValue { get => _floatValue; set { _floatValue = value; _setCase = TypedValueCase.FloatValue; } }
[DataMember(Order = 5)]
public double DoubleValue { get; set; }
public double DoubleValue { get => _doubleValue; set { _doubleValue = value; _setCase = TypedValueCase.DoubleValue; } }
[DataMember(Order = 6)]
public string? StringValue { get; set; }
public string? StringValue { get => _stringValue; set { _stringValue = value; _setCase = TypedValueCase.StringValue; } }
[DataMember(Order = 7)]
public byte[]? BytesValue { get; set; }
public byte[]? BytesValue { get => _bytesValue; set { _bytesValue = value; _setCase = TypedValueCase.BytesValue; } }
[DataMember(Order = 8)]
public long DatetimeValue { get; set; }
public long DatetimeValue { get => _datetimeValue; set { _datetimeValue = value; _setCase = TypedValueCase.DatetimeValue; } }
[DataMember(Order = 9)]
public ArrayValue? ArrayValue { get; set; }
public ArrayValue? ArrayValue { get => _arrayValue; set { _arrayValue = value; _setCase = TypedValueCase.ArrayValue; } }
/// <summary>
/// Indicates which oneof case is set. Determined by checking non-default values.
/// This is NOT a wire field -- it's a convenience helper.
/// Indicates which oneof case is set. Tracked via property setters so default values
/// (false, 0, 0.0) are correctly distinguished from "not set".
/// </summary>
public TypedValueCase GetValueCase()
{
// Check in reverse priority order to handle protobuf oneof semantics.
// For the oneof, only one should be set at a time.
if (ArrayValue != null) return TypedValueCase.ArrayValue;
if (DatetimeValue != 0) return TypedValueCase.DatetimeValue;
if (BytesValue != null) return TypedValueCase.BytesValue;
if (StringValue != null) return TypedValueCase.StringValue;
if (DoubleValue != 0d) return TypedValueCase.DoubleValue;
if (FloatValue != 0f) return TypedValueCase.FloatValue;
if (Int64Value != 0) return TypedValueCase.Int64Value;
if (Int32Value != 0) return TypedValueCase.Int32Value;
if (BoolValue) return TypedValueCase.BoolValue;
return TypedValueCase.None;
}
public TypedValueCase GetValueCase() => _setCase;
}
/// <summary>Identifies which field in TypedValue is set.</summary>
@@ -22,7 +22,7 @@ public partial class LmxProxyClient
var endpoint = BuildEndpointUri();
_logger.LogInformation("Connecting to LmxProxy at {Endpoint}", endpoint);
GrpcChannel channel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger);
GrpcChannel channel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger, _apiKey);
IScadaService client;
try
{
@@ -20,9 +20,9 @@ internal static class GrpcChannelFactory
}
/// <summary>
/// Creates a <see cref="GrpcChannel"/> with the specified address and TLS configuration.
/// Creates a <see cref="GrpcChannel"/> with the specified address, TLS configuration, and optional API key header.
/// </summary>
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger, string? apiKey = null)
{
var handler = new SocketsHttpHandler
{
@@ -34,15 +34,43 @@ internal static class GrpcChannelFactory
ConfigureTls(handler, tlsConfiguration, logger);
}
HttpMessageHandler finalHandler = handler;
// Add API key header to all outgoing requests if provided
if (!string.IsNullOrEmpty(apiKey))
{
finalHandler = new ApiKeyDelegatingHandler(apiKey, handler);
}
var channelOptions = new GrpcChannelOptions
{
HttpHandler = handler
HttpHandler = finalHandler
};
logger.LogDebug("Creating gRPC channel to {Address}, TLS={UseTls}", address, tlsConfiguration?.UseTls ?? false);
return GrpcChannel.ForAddress(address, channelOptions);
}
/// <summary>
/// DelegatingHandler that adds the x-api-key header to all outgoing requests.
/// </summary>
private sealed class ApiKeyDelegatingHandler : DelegatingHandler
{
private readonly string _apiKey;
public ApiKeyDelegatingHandler(string apiKey, HttpMessageHandler innerHandler)
: base(innerHandler)
{
_apiKey = apiKey;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.TryAddWithoutValidation("x-api-key", _apiKey);
return base.SendAsync(request, cancellationToken);
}
}
private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tls, ILogger logger)
{
handler.SslOptions = new SslClientAuthenticationOptions