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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user