Compare commits

...

49 Commits

Author SHA1 Message Date
Joseph Doherty cf20142634 Fix Galaxy projection and constraint review findings 2026-04-29 14:24:11 -04:00
Joseph Doherty b995c174eb Implement Galaxy filters and API key constraints 2026-04-29 13:37:00 -04:00
Joseph Doherty ac2787f619 Fix Galaxy paging review findings 2026-04-29 11:59:49 -04:00
Joseph Doherty d543679044 Fix runtime review findings 2026-04-29 10:39:49 -04:00
Joseph Doherty 133c83029b Add Galaxy repository API and clients 2026-04-29 07:27:00 -04:00
Joseph Doherty 047d875fe6 Fix remaining reliability findings 2026-04-28 06:38:05 -04:00
Joseph Doherty b0041c5d18 Fix reliability findings 2026-04-28 06:27:01 -04:00
Joseph Doherty 907aa49aea Improve gateway reliability and client e2e coverage 2026-04-28 06:11:18 -04:00
Joseph Doherty 4fc355b357 Improve gateway reliability and dashboard docs 2026-04-28 00:13:22 -04:00
Joseph Doherty bd4a09a35e Add Polly resilience policies 2026-04-27 15:37:56 -04:00
Joseph Doherty d431ff9660 Fix dashboard static assets and add client e2e scripts 2026-04-27 12:10:40 -04:00
Joseph Doherty 3d11ac3316 Add bulk MXAccess subscription commands 2026-04-26 22:29:27 -04:00
Joseph Doherty daff16cfd2 Add orchestration prompt 2026-04-26 21:31:41 -04:00
dohertj2 6ce61a4f77 Merge pull request #100 from agent-2/issue-50-client-packaging-documentation
Issue #50: document client packaging
2026-04-26 21:28:47 -04:00
Joseph Doherty 4ea2c4fd86 Issue #50: clarify packaging API key placeholders 2026-04-26 21:26:28 -04:00
dohertj2 09e01de9c8 Merge pull request #101 from agent-1/issue-49-cross-language-smoke-test-matrix
Issue #49: add cross-language smoke test matrix
2026-04-26 21:24:29 -04:00
Joseph Doherty 41a2d70f8f Merge remote-tracking branch 'origin/main' into agent-2/issue-50-client-packaging-documentation 2026-04-26 21:23:06 -04:00
Joseph Doherty 79f73e04fd Issue #49: add cross-language smoke matrix 2026-04-26 21:21:49 -04:00
Joseph Doherty f2118f7028 Issue #50: document client packaging 2026-04-26 21:20:43 -04:00
dohertj2 9159f6f093 Merge pull request #99 from agent-1/issue-48-implement-java-client-session-values-errors-and-cli
Issue #48: implement Java client session values errors and CLI
2026-04-26 21:04:24 -04:00
Joseph Doherty d6939432f9 Issue #48: implement Java client session values errors and CLI 2026-04-26 20:59:28 -04:00
dohertj2 02143ef7e2 Merge pull request #98 from agent-2/issue-35-parity-fixture-matrix
Issue #35: add parity fixture matrix
2026-04-26 20:54:24 -04:00
dohertj2 c032852065 Merge pull request #97 from agent-3/issue-46-implement-python-async-client-values-errors-and-cli
Issue #46: implement Python async client values errors and CLI
2026-04-26 20:50:10 -04:00
Joseph Doherty 1d93e77234 Merge remote-tracking branch 'origin/main' into agent-2/issue-35-parity-fixture-matrix 2026-04-26 20:49:43 -04:00
Joseph Doherty 0a670eb381 Issue #35: add parity fixture matrix 2026-04-26 20:47:05 -04:00
Joseph Doherty b57662aae7 Issue #46: implement Python async client values errors and CLI 2026-04-26 20:46:18 -04:00
dohertj2 14afb325c3 Merge pull request #96 from agent-1/issue-47-scaffold-java-gradle-build
Issue #47: scaffold Java Gradle build
2026-04-26 20:42:39 -04:00
Joseph Doherty af42891d5a Issue #47: scaffold Java Gradle build 2026-04-26 20:36:27 -04:00
dohertj2 01a51df053 Merge pull request #95 from agent-2/issue-44-implement-rust-client-session-values-errors-and-cli
Issue #44: implement Rust client session values errors and CLI
2026-04-26 20:34:28 -04:00
Joseph Doherty 89a8fb876a Issue #44: implement Rust client session values errors and CLI 2026-04-26 20:30:04 -04:00
dohertj2 c58358fad9 Merge pull request #94 from agent-3/issue-45-scaffold-python-package
Issue #45: scaffold Python package
2026-04-26 20:28:13 -04:00
dohertj2 8d312a6d2e Merge pull request #93 from agent-1/issue-40-implement-dotnet-values-status-errors-and-cli
Issue #40: implement .NET values status errors and CLI
2026-04-26 20:22:58 -04:00
Joseph Doherty f861a8b3b8 Issue #45: scaffold Python package 2026-04-26 20:22:35 -04:00
Joseph Doherty 499708b2a2 Issue #40: implement .NET values status errors and CLI 2026-04-26 20:17:02 -04:00
dohertj2 191b724f95 Merge pull request #92 from agent-3/issue-42-implement-go-client-session-values-errors-and-cli
Issue #42: implement Go client session values errors and CLI
2026-04-26 20:14:56 -04:00
Joseph Doherty 8793011838 Issue #42: implement Go client session values errors and CLI 2026-04-26 20:09:58 -04:00
dohertj2 b275eedb44 Merge pull request #91 from agent-2/issue-34-worker-live-mxaccess-smoke-test
Issue #34: Worker Live MXAccess Smoke Test
2026-04-26 20:06:58 -04:00
Joseph Doherty a9ef6d10d4 Issue #34: handle worktree roots in live smoke tests 2026-04-26 20:03:21 -04:00
Joseph Doherty 0f17a1d1d9 Add live MXAccess worker smoke test 2026-04-26 19:58:33 -04:00
dohertj2 160343aff4 Merge pull request #90 from agent-3/issue-43-scaffold-rust-workspace
Issue #43: scaffold Rust workspace
2026-04-26 19:52:33 -04:00
dohertj2 8ef98b8beb Merge pull request #89 from agent-1/issue-39-implement-dotnet-gatewayclient-and-session
Issue #39: implement .NET GatewayClient and session
2026-04-26 19:50:37 -04:00
Joseph Doherty f049d3e603 Merge remote-tracking branch 'origin/main' into agent-3/issue-43-scaffold-rust-workspace 2026-04-26 19:49:30 -04:00
Joseph Doherty ee88f9d647 Scaffold Rust client workspace 2026-04-26 19:47:26 -04:00
Joseph Doherty 6e34efd1a5 Merge remote-tracking branch 'origin/main' into agent-1/issue-39-implement-dotnet-gatewayclient-and-session 2026-04-26 19:47:17 -04:00
Joseph Doherty 01d6c33156 Implement .NET gateway client sessions 2026-04-26 19:45:43 -04:00
dohertj2 ec4e2f687e Merge pull request #88 from agent-2/issue-33-implement-graceful-shutdown
Issue #33: implement graceful shutdown
2026-04-26 19:44:00 -04:00
Joseph Doherty f7929cc12f Merge remote-tracking branch 'origin/main' into agent-2/issue-33-implement-graceful-shutdown
# Conflicts:
#	src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs
#	src/MxGateway.Worker/Ipc/WorkerPipeClient.cs
#	src/MxGateway.Worker/Ipc/WorkerPipeSession.cs
2026-04-26 19:41:04 -04:00
Joseph Doherty d890eff862 Implement graceful worker shutdown 2026-04-26 19:36:22 -04:00
dohertj2 9dcd4baff2 Merge pull request #86 from agent-3/issue-41-scaffold-go-module
Issue #41: scaffold Go module
2026-04-26 19:33:44 -04:00
325 changed files with 133204 additions and 835 deletions
@@ -0,0 +1,117 @@
using System.Globalization;
namespace MxGateway.Client.Cli;
internal sealed class CliArguments
{
private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase);
public CliArguments(IEnumerable<string> args)
{
string? pendingName = null;
foreach (string arg in args)
{
if (arg.StartsWith("--", StringComparison.Ordinal))
{
if (pendingName is not null)
{
_flags.Add(pendingName);
}
pendingName = arg[2..];
continue;
}
if (pendingName is null)
{
throw new ArgumentException($"Unexpected argument '{arg}'.");
}
_values[pendingName] = arg;
pendingName = null;
}
if (pendingName is not null)
{
_flags.Add(pendingName);
}
}
public bool HasFlag(string name)
{
return _flags.Contains(name);
}
public string? GetOptional(string name)
{
return _values.TryGetValue(name, out string? value)
? value
: null;
}
public string GetRequired(string name)
{
string? value = GetOptional(name);
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"Missing required option --{name}.");
}
return value;
}
public int GetInt32(string name, int? defaultValue = null)
{
string? value = GetOptional(name);
if (string.IsNullOrWhiteSpace(value))
{
if (defaultValue.HasValue)
{
return defaultValue.Value;
}
throw new ArgumentException($"Missing required option --{name}.");
}
return int.Parse(value, CultureInfo.InvariantCulture);
}
public uint GetUInt32(string name, uint defaultValue)
{
string? value = GetOptional(name);
return string.IsNullOrWhiteSpace(value)
? defaultValue
: uint.Parse(value, CultureInfo.InvariantCulture);
}
public ulong GetUInt64(string name, ulong defaultValue)
{
string? value = GetOptional(name);
return string.IsNullOrWhiteSpace(value)
? defaultValue
: ulong.Parse(value, CultureInfo.InvariantCulture);
}
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
{
string? value = GetOptional(name);
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
if (value.EndsWith("ms", StringComparison.OrdinalIgnoreCase))
{
return TimeSpan.FromMilliseconds(double.Parse(value[..^2], CultureInfo.InvariantCulture));
}
if (value.EndsWith('s'))
{
return TimeSpan.FromSeconds(double.Parse(value[..^1], CultureInfo.InvariantCulture));
}
return TimeSpan.Parse(value, CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,39 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
public interface IMxGatewayCliClient : IAsyncDisposable
{
Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken);
Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken);
Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken);
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken);
Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken);
Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken);
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,84 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
{
private readonly MxGatewayClient _client;
private readonly Lazy<GalaxyRepositoryClient> _galaxyClient;
public MxGatewayCliClientAdapter(MxGatewayClient client)
{
_client = client;
_galaxyClient = new Lazy<GalaxyRepositoryClient>(
() => GalaxyRepositoryClient.Create(_client.Options));
}
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken)
{
return _client.OpenSessionRawAsync(request, cancellationToken);
}
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken)
{
return _client.CloseSessionRawAsync(request, cancellationToken);
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken)
{
return _client.InvokeAsync(request, cancellationToken);
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken)
{
return _client.StreamEventsAsync(request, cancellationToken);
}
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken);
}
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken);
}
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken);
}
public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
}
public async ValueTask DisposeAsync()
{
if (_galaxyClient.IsValueCreated)
{
await _galaxyClient.Value.DisposeAsync().ConfigureAwait(false);
}
await _client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,14 @@
namespace MxGateway.Client.Cli;
internal static class MxGatewayCliSecretRedactor
{
public static string Redact(string value, string? apiKey)
{
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
{
return value;
}
return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,3 @@
using MxGateway.Client.Cli;
return MxGatewayClientCli.Run(args, Console.Out, Console.Error);
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
@@ -0,0 +1,108 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = [];
public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = [];
public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = [];
public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true };
public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false };
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
public Queue<Exception> TestConnectionExceptions { get; } = new();
public Queue<Exception> GetLastDeployTimeExceptions { get; } = new();
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
TestConnectionCalls.Add((request, callOptions));
if (TestConnectionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(TestConnectionReply);
}
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
GetLastDeployTimeCalls.Add((request, callOptions));
if (GetLastDeployTimeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(GetLastDeployTimeReply);
}
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
DiscoverHierarchyCalls.Add((request, callOptions));
if (DiscoverHierarchyExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(
DiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
? reply
: DiscoverHierarchyReply);
}
public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = [];
public List<DeployEvent> WatchDeployEvents { get; } = [];
public Exception? WatchDeployEventsException { get; set; }
/// <summary>
/// When set, awaited before each event yield so tests can observe cancellation
/// mid-stream. Receives the call's cancellation token.
/// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
WatchDeployEventsCalls.Add((request, callOptions));
if (WatchDeployEventsException is not null)
{
throw WatchDeployEventsException;
}
foreach (DeployEvent deployEvent in WatchDeployEvents)
{
if (WatchDeployEventsBeforeYield is not null)
{
await WatchDeployEventsBeforeYield(callOptions.CancellationToken).ConfigureAwait(false);
}
callOptions.CancellationToken.ThrowIfCancellationRequested();
yield return deployEvent;
}
}
}
@@ -0,0 +1,107 @@
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests;
internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport
{
private readonly Queue<MxCommandReply> _invokeReplies = new();
private readonly List<MxEvent> _events = [];
public MxGatewayClientOptions Options { get; } = options;
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = [];
public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = [];
public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = [];
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
public OpenSessionReply OpenSessionReply { get; set; } = new()
{
SessionId = "session-fixture",
BackendName = "mxaccess-worker",
GatewayProtocolVersion = 1,
WorkerProtocolVersion = 1,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
public CloseSessionReply CloseSessionReply { get; set; } = new()
{
SessionId = "session-fixture",
FinalState = SessionState.Closed,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
public Queue<Exception> OpenSessionExceptions { get; } = new();
public Queue<Exception> CloseSessionExceptions { get; } = new();
public Queue<Exception> InvokeExceptions { get; } = new();
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CallOptions callOptions)
{
OpenSessionCalls.Add((request, callOptions));
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(OpenSessionReply);
}
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions)
{
CloseSessionCalls.Add((request, callOptions));
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(CloseSessionReply);
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CallOptions callOptions)
{
InvokeCalls.Add((request, callOptions));
if (InvokeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(_invokeReplies.Dequeue());
}
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions)
{
StreamEventsCalls.Add((request, callOptions));
foreach (MxEvent gatewayEvent in _events)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return gatewayEvent;
}
}
public void AddInvokeReply(MxCommandReply reply)
{
_invokeReplies.Enqueue(reply);
}
public void AddEvent(MxEvent gatewayEvent)
{
_events.Add(gatewayEvent);
}
}
@@ -0,0 +1,371 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests
{
[Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync(cancellation.Token);
Assert.True(ok);
var call = Assert.Single(transport.TestConnectionCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
[Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.False(ok);
}
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply { Present = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.Null(deployTime);
Assert.Single(transport.GetLastDeployTimeCalls);
}
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{
DateTime expected = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply
{
Present = true,
TimeOfLastDeploy = Timestamp.FromDateTime(expected),
};
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.NotNull(deployTime);
Assert.Equal(expected, deployTime!.Value);
}
[Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "page-2",
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 12,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
BrowseName = "TestMachine_001/DelmiaReceiver",
ParentGobjectId = 5,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
MxDataType = 8,
DataTypeName = "MxString",
},
},
},
},
});
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 13,
TagName = "DelmiaReceiver_002",
},
},
});
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
Assert.Equal(2, objects.Count);
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
Assert.Equal(5000, transport.DiscoverHierarchyCalls[0].Request.PageSize);
Assert.Equal("", transport.DiscoverHierarchyCalls[0].Request.PageToken);
Assert.Equal("page-2", transport.DiscoverHierarchyCalls[1].Request.PageToken);
GalaxyObject obj = objects[0];
Assert.Equal(12, obj.GobjectId);
Assert.Equal("DelmiaReceiver_001", obj.TagName);
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
Assert.Equal("DownloadPath", attribute.AttributeName);
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
}
[Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(cancellation.Token);
var call = Assert.Single(transport.DiscoverHierarchyCalls);
// The retry pipeline links the caller token with a per-call timeout token,
// so the transport sees the linked token rather than the caller's directly.
// Verify the link relationship by cancelling the caller and checking the
// call-side token reflects it.
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
});
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
});
await using GalaxyRepositoryClient client = CreateClient(transport);
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
async () => await client.DiscoverHierarchyAsync());
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions
{
RootContainedPath = "Area1/Line3",
MaxDepth = 2,
CategoryIds = [10, 13],
TemplateChainContains = ["Pump"],
TagNameGlob = "Pump_*",
IncludeAttributes = false,
AlarmBearingOnly = true,
HistorizedOnly = true,
});
DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request;
Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase);
Assert.Equal("Area1/Line3", request.RootContainedPath);
Assert.Equal(2, request.MaxDepth);
Assert.Equal([10, 13], request.CategoryIds);
Assert.Equal(["Pump"], request.TemplateChainContains);
Assert.Equal("Pump_*", request.TagNameGlob);
Assert.True(request.HasIncludeAttributes);
Assert.False(request.IncludeAttributes);
Assert.True(request.AlarmBearingOnly);
Assert.True(request.HistorizedOnly);
}
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionExceptions.Enqueue(CreateTransientRpcException());
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.True(ok);
Assert.Equal(2, transport.TestConnectionCalls.Count);
}
[Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyExceptions.Enqueue(CreateTransientRpcException());
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync();
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
}
[Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime deployTime = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deployTime),
TimeOfLastDeploy = Timestamp.FromDateTime(deployTime),
TimeOfLastDeployPresent = true,
ObjectCount = 7,
AttributeCount = 42,
});
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync())
{
received.Add(evt);
}
DeployEvent only = Assert.Single(received);
Assert.Equal(1ul, only.Sequence);
Assert.Equal(7, only.ObjectCount);
Assert.Equal(42, only.AttributeCount);
Assert.True(only.TimeOfLastDeployPresent);
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
// No last_seen_deploy_time supplied → request leaves the field unset.
Assert.Null(call.Request.LastSeenDeployTime);
}
[Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime t0 = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
for (int index = 1; index <= 3; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = (ulong)index,
ObservedAt = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeploy = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeployPresent = true,
ObjectCount = 10 + index,
AttributeCount = 100 + index,
});
}
DateTimeOffset lastSeen = new(t0, TimeSpan.Zero);
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync(lastSeen))
{
received.Add(evt);
}
Assert.Equal(3, received.Count);
Assert.Equal(new ulong[] { 1, 2, 3 }, received.Select(e => e.Sequence).ToArray());
Assert.Equal(new[] { 11, 12, 13 }, received.Select(e => e.ObjectCount).ToArray());
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.NotNull(call.Request.LastSeenDeployTime);
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
}
[Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Add many events; the test will cancel after the first.
for (int index = 1; index <= 10; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent { Sequence = (ulong)index });
}
using CancellationTokenSource cancellation = new();
// Cancel before the second yield by wiring the fake's pre-yield hook.
int yields = 0;
transport.WatchDeployEventsBeforeYield = _ =>
{
yields++;
if (yields >= 2)
{
cancellation.Cancel();
}
return Task.CompletedTask;
};
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (DeployEvent evt in client
.WatchDeployEventsAsync(cancellationToken: cancellation.Token))
{
received.Add(evt);
}
});
// The first event yields before cancellation triggers on the second pass.
Assert.Single(received);
Assert.Equal(1ul, received[0].Sequence);
}
[Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
Assert.Throws<ObjectDisposedException>(() =>
client.WatchDeployEventsAsync());
}
[Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.TestConnectionAsync());
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
{
return new GalaxyRepositoryClient(transport.Options, transport);
}
private static FakeGalaxyRepositoryTransport CreateTransport()
{
return new FakeGalaxyRepositoryTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
private static RpcException CreateTransientRpcException()
{
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
}
}
@@ -0,0 +1,78 @@
using Google.Protobuf;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests;
public sealed class MxCommandReplyExtensionsTests
{
[Fact]
public void EnsureSuccess_WithRegisterFixture_ReturnsReply()
{
MxCommandReply reply = ReadReplyFixture("register.ok.reply.json");
Assert.Same(reply, reply.EnsureProtocolSuccess());
Assert.Same(reply, reply.EnsureMxAccessSuccess());
}
[Fact]
public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses()
{
MxCommandReply reply = ReadReplyFixture("write.mxaccess-failure.reply.json");
reply.EnsureProtocolSuccess();
MxAccessException exception = Assert.Throws<MxAccessException>(
reply.EnsureMxAccessSuccess);
Assert.Equal(-2147220992, exception.HResultCode);
Assert.Equal(reply.Statuses.Count, exception.Statuses.Count);
Assert.Equal(reply, exception.Reply);
Assert.Contains("0x80040200", exception.Message);
}
[Fact]
public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException()
{
MxCommandReply reply = new()
{
SessionId = "session-missing",
CorrelationId = "correlation",
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.SessionNotFound,
Message = "Session was not found.",
},
};
MxGatewaySessionException exception = Assert.Throws<MxGatewaySessionException>(
reply.EnsureProtocolSuccess);
Assert.Equal("session-missing", exception.SessionId);
Assert.Equal(ProtocolStatusCode.SessionNotFound, exception.ProtocolStatus?.Code);
}
private static MxCommandReply ReadReplyFixture(string fileName)
{
DirectoryInfo directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string path = Path.Combine(
directory.FullName,
"clients",
"proto",
"fixtures",
"behavior",
"command-replies",
fileName);
if (File.Exists(path))
{
return JsonParser.Default.Parse<MxCommandReply>(File.ReadAllText(path));
}
directory = directory.Parent!;
}
throw new FileNotFoundException(fileName);
}
}
@@ -1,4 +1,7 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Client.Cli;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
@@ -13,8 +16,471 @@ public sealed class MxGatewayClientCliTests
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
Assert.Equal(0, exitCode);
Assert.Contains("gateway-protocol=1", output.ToString());
Assert.Contains("gateway-protocol=2", output.ToString());
Assert.Contains("worker-protocol=1", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
{
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
Assert.Equal(0, exitCode);
Assert.Contains("\"gatewayProtocolVersion\":2", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Write,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"write",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--session-id",
"session-fixture",
"--server-handle",
"12",
"--item-handle",
"34",
"--type",
"int32",
"--value",
"123",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
Assert.Equal(123, request.Command.Write.Value.Int32Value);
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey()
{
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = await MxGatewayClientCli.RunAsync(
[
"open-session",
"--endpoint",
"http://localhost:5000",
"--api-key",
"secret-api-key",
],
output,
error,
_ => throw new InvalidOperationException("boom secret-api-key"));
Assert.Equal(1, exitCode);
Assert.DoesNotContain("secret-api-key", error.ToString());
Assert.Contains("[redacted]", error.ToString());
}
[Fact]
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.Events.Add(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 1,
});
fakeClient.Events.Add(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnWriteComplete,
WorkerSequence = 2,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"stream-events",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--session-id",
"session-fixture",
"--max-events",
"1",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Contains("workerSequence", output.ToString());
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
}
[Fact]
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new()
{
InvokeFailure = new InvalidOperationException("register failed"),
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"smoke",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--item",
"Area001.Pump001.Speed",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(1, exitCode);
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
Assert.Equal("session-fixture", closeRequest.SessionId);
}
[Fact]
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new()
{
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-test-connection",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
Assert.Contains("\"ok\": true", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 7,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
ParentGobjectId = 1,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
},
},
},
},
});
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 8,
TagName = "DelmiaReceiver_002",
ContainedName = "DelmiaReceiver",
ParentGobjectId = 1,
},
},
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-discover",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
string text = output.ToString();
Assert.Contains("objects=2", text);
Assert.Contains("DelmiaReceiver_001", text);
Assert.Contains("DelmiaReceiver_002", text);
Assert.Contains("attributes=1", text);
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deploy),
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
TimeOfLastDeployPresent = true,
ObjectCount = 5,
AttributeCount = 17,
});
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 2,
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
TimeOfLastDeployPresent = true,
ObjectCount = 6,
AttributeCount = 18,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-watch",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--last-seen-deploy-time",
"2026-04-28T14:00:00Z",
"--max-events",
"2",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
Assert.NotNull(request.LastSeenDeployTime);
string text = output.ToString();
Assert.Contains("sequence=1", text);
Assert.Contains("sequence=2", text);
Assert.Contains("objects=5", text);
Assert.Contains("attributes=18", text);
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 42,
ObjectCount = 99,
AttributeCount = 1024,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-watch",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--max-events",
"1",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("\"sequence\": \"42\"", text);
Assert.Contains("\"objectCount\": 99", text);
}
private sealed class FakeCliClient : IMxGatewayCliClient
{
public Queue<MxCommandReply> InvokeReplies { get; } = new();
public List<MxCommandRequest> InvokeRequests { get; } = [];
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
public List<MxEvent> Events { get; } = [];
public Exception? InvokeFailure { get; init; }
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new OpenSessionReply
{
SessionId = "session-fixture",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
GatewayProtocolVersion = 1,
WorkerProtocolVersion = 1,
});
}
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken)
{
CloseSessionRequests.Add(request);
return Task.FromResult(new CloseSessionReply
{
SessionId = request.SessionId,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
FinalState = SessionState.Closed,
});
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken)
{
InvokeRequests.Add(request);
if (InvokeFailure is not null)
{
throw InvokeFailure;
}
return Task.FromResult(InvokeReplies.Dequeue());
}
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (MxEvent gatewayEvent in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return gatewayEvent;
}
}
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
GalaxyTestConnectionRequests.Add(request);
return Task.FromResult(GalaxyTestConnectionReply);
}
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
GalaxyGetLastDeployTimeRequests.Add(request);
return Task.FromResult(GalaxyGetLastDeployTimeReply);
}
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
GalaxyDiscoverHierarchyRequests.Add(request);
return Task.FromResult(
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
? reply
: GalaxyDiscoverHierarchyReply);
}
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
GalaxyWatchDeployEventsRequests.Add(request);
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return deployEvent;
}
}
}
}
@@ -25,4 +25,17 @@ public sealed class MxGatewayClientOptionsTests
Assert.Throws<ArgumentException>(options.Validate);
}
[Fact]
public void Validate_WithInvalidRetryOptions_Throws()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 0 },
};
Assert.Throws<ArgumentOutOfRangeException>(options.Validate);
}
}
@@ -0,0 +1,310 @@
using MxGateway.Contracts.Proto;
using Grpc.Core;
namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientSessionTests
{
[Fact]
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
{
using CancellationTokenSource cancellation = new();
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
await client.OpenSessionRawAsync(new OpenSessionRequest(), cancellation.Token);
var call = Assert.Single(transport.OpenSessionCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken);
}
[Fact]
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
{
FakeGatewayTransport transport = CreateTransport();
transport.OpenSessionReply.WorkerProcessId = 1234;
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
Assert.Equal("session-fixture", session.SessionId);
Assert.Same(transport.OpenSessionReply, session.OpenSessionReply);
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
}
[Fact]
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Register = new RegisterReply { ServerHandle = 12 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int serverHandle = await session.RegisterAsync("fixture-client");
Assert.Equal(12, serverHandle);
var call = Assert.Single(transport.InvokeCalls);
Assert.Equal("session-fixture", call.Request.SessionId);
Assert.False(string.IsNullOrWhiteSpace(call.Request.ClientCorrelationId));
Assert.Equal(MxCommandKind.Register, call.Request.Command.Kind);
Assert.Equal("fixture-client", call.Request.Command.Register.ClientName);
}
[Fact]
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AddItem2,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddItem2 = new AddItem2Reply { ItemHandle = 34 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int itemHandle = await session.AddItem2Async(12, "Area001.Pump001.Speed", "runtime");
Assert.Equal(34, itemHandle);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.AddItem2, request.Command.Kind);
Assert.Equal(12, request.Command.AddItem2.ServerHandle);
Assert.Equal("Area001.Pump001.Speed", request.Command.AddItem2.ItemDefinition);
Assert.Equal("runtime", request.Command.AddItem2.ItemContext);
}
[Fact]
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Write,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxValue value = new()
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = 123,
};
MxCommandReply reply = await session.WriteRawAsync(12, 34, value, 56);
Assert.Equal(MxCommandKind.Write, reply.Kind);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
Assert.Equal(12, request.Command.Write.ServerHandle);
Assert.Equal(34, request.Command.Write.ItemHandle);
Assert.Same(value, request.Command.Write.Value);
Assert.Equal(56, request.Command.Write.UserId);
}
[Fact]
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Write2,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxValue value = 123.ToMxValue();
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56);
Assert.Equal(MxCommandKind.Write2, reply.Kind);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Write2, request.Command.Kind);
Assert.Equal(12, request.Command.Write2.ServerHandle);
Assert.Equal(34, request.Command.Write2.ItemHandle);
Assert.Same(value, request.Command.Write2.Value);
Assert.Same(timestampValue, request.Command.Write2.TimestampValue);
Assert.Equal(56, request.Command.Write2.UserId);
}
[Fact]
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 12,
TagAddress = "Area001.Pump001.Speed",
ItemHandle = 34,
WasSuccessful = true,
},
},
},
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
12,
["Area001.Pump001.Speed"]);
SubscribeResult result = Assert.Single(results);
Assert.Equal(34, result.ItemHandle);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.SubscribeBulk, request.Command.Kind);
Assert.Equal(12, request.Command.SubscribeBulk.ServerHandle);
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
}
[Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 1,
});
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnWriteComplete,
WorkerSequence = 2,
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
List<ulong> sequences = [];
await foreach (MxEvent gatewayEvent in session.StreamEventsAsync(afterWorkerSequence: 0))
{
sequences.Add(gatewayEvent.WorkerSequence);
}
Assert.Equal([1UL, 2UL], sequences);
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
Assert.Equal("session-fixture", request.SessionId);
}
[Fact]
public async Task CloseAsync_IsExplicitAndIdempotent()
{
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
CloseSessionReply first = await session.CloseAsync();
CloseSessionReply second = await session.CloseAsync();
Assert.Same(first, second);
var call = Assert.Single(transport.CloseSessionCalls);
Assert.Equal("session-fixture", call.Request.SessionId);
}
[Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
{
FakeGatewayTransport transport = CreateTransport();
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Ping,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.InvokeAsync(new MxCommandRequest
{
SessionId = session.SessionId,
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
});
Assert.Equal(2, transport.InvokeCalls.Count);
}
[Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
{
FakeGatewayTransport transport = CreateTransport();
transport.OpenSessionExceptions.Enqueue(CreateTransientRpcException());
await using MxGatewayClient client = CreateClient(transport);
await Assert.ThrowsAsync<RpcException>(async () => await client.OpenSessionAsync());
Assert.Single(transport.OpenSessionCalls);
}
[Fact]
public async Task InvokeAsync_DoesNotRetryWriteCommand()
{
FakeGatewayTransport transport = CreateTransport();
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await Assert.ThrowsAsync<RpcException>(async () =>
await session.WriteRawAsync(1, 2, 3.ToMxValue(), userId: 0));
Assert.Single(transport.InvokeCalls);
}
[Fact]
public async Task InvokeHelpers_PassCancellationTokenToTransport()
{
using CancellationTokenSource cancellation = new();
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Advise,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.AdviseAsync(12, 34, cancellation.Token);
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
}
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{
return new MxGatewayClient(transport.Options, transport);
}
private static FakeGatewayTransport CreateTransport()
{
return new FakeGatewayTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
private static RpcException CreateTransientRpcException()
{
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
}
}
@@ -0,0 +1,57 @@
using System.Text.Json;
using Google.Protobuf;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests;
public sealed class MxStatusProxyExtensionsTests
{
[Fact]
public void FixtureStatuses_ProjectSuccessAndPreserveRawFields()
{
using JsonDocument document = JsonDocument.Parse(ReadFixture(
"statuses",
"status-conversion-cases.json"));
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
{
MxStatusProxy status = JsonParser.Default.Parse<MxStatusProxy>(
testCase.GetProperty("status").GetRawText());
int success = testCase.GetProperty("status").GetProperty("success").GetInt32();
Assert.Equal(success != 0 && status.Category is MxStatusCategory.Ok, status.IsSuccess());
Assert.Equal(
testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(),
status.RawCategory);
Assert.Equal(
testCase.GetProperty("status").GetProperty("rawDetectedBy").GetInt32(),
status.RawDetectedBy);
}
}
private static string ReadFixture(string category, string fileName)
{
DirectoryInfo directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string path = Path.Combine(
directory.FullName,
"clients",
"proto",
"fixtures",
"behavior",
category,
fileName);
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent!;
}
throw new FileNotFoundException(fileName);
}
}
@@ -0,0 +1,79 @@
using System.Text.Json;
using Google.Protobuf;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests;
public sealed class MxValueExtensionsTests
{
[Fact]
public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues()
{
Assert.Equal(MxValue.KindOneofCase.BoolValue, true.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.Int32Value, 123.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.Int64Value, 123L.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.FloatValue, 1.25F.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.DoubleValue, 2.5D.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase);
}
[Fact]
public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues()
{
MxValue value = new[] { "alpha", "beta" }.ToMxValue();
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase);
Assert.Equal(["alpha", "beta"], value.ArrayValue.StringValues.Values);
Assert.Equal([2U], value.ArrayValue.Dimensions);
}
[Fact]
public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata()
{
using JsonDocument document = JsonDocument.Parse(ReadFixture(
"values",
"value-conversion-cases.json"));
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
{
string expectedKind = testCase.GetProperty("expectedKind").GetString()!;
MxValue value = JsonParser.Default.Parse<MxValue>(
testCase.GetProperty("value").GetRawText());
Assert.Equal(expectedKind, value.GetProjectionKind());
if (testCase.GetProperty("id").GetString() is "raw-fallback.variant")
{
Assert.Equal(32767, value.RawDataType);
Assert.Equal([1, 2, 3, 4, 5], Assert.IsType<byte[]>(value.ToClrValue()));
}
}
}
private static string ReadFixture(string category, string fileName)
{
DirectoryInfo directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string path = Path.Combine(
directory.FullName,
"clients",
"proto",
"fixtures",
"behavior",
category,
fileName);
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent!;
}
throw new FileNotFoundException(fileName);
}
}
@@ -0,0 +1,24 @@
namespace MxGateway.Client;
public sealed record DiscoverHierarchyOptions
{
public int? RootGobjectId { get; init; }
public string? RootTagName { get; init; }
public string? RootContainedPath { get; init; }
public int? MaxDepth { get; init; }
public IReadOnlyList<int> CategoryIds { get; init; } = Array.Empty<int>();
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
public string? TagNameGlob { get; init; }
public bool? IncludeAttributes { get; init; }
public bool AlarmBearingOnly { get; init; }
public bool HistorizedOnly { get; init; }
}
@@ -0,0 +1,381 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
/// All RPCs are read-only metadata calls that share the gateway's API-key auth
/// interceptor and require the <c>metadata:read</c> scope server-side.
/// </summary>
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private const int DiscoverHierarchyPageSize = 5000;
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
internal GalaxyRepositoryClient(
MxGatewayClientOptions options,
IGalaxyRepositoryClientTransport transport)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
_channel = null;
}
private GalaxyRepositoryClient(
GrpcChannel channel,
IGalaxyRepositoryClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
}
public MxGatewayClientOptions Options { get; }
public GalaxyRepository.GalaxyRepositoryClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new GalaxyRepositoryClient(
channel,
new GrpcGalaxyRepositoryClientTransport(
options,
new GalaxyRepository.GalaxyRepositoryClient(channel)));
}
/// <summary>
/// Probes the Galaxy Repository database connection. Returns true when the
/// gateway can reach the configured ZB SQL Server.
/// </summary>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
TestConnectionReply reply = await TestConnectionRawAsync(
new TestConnectionRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Ok;
}
public Task<TestConnectionReply> TestConnectionRawAsync(
TestConnectionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.TestConnectionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Returns the timestamp of the most recent Galaxy deployment, or
/// <see langword="null"/> when no deployment has been recorded.
/// </summary>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
{
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
new GetLastDeployTimeRequest(),
cancellationToken)
.ConfigureAwait(false);
if (!reply.Present || reply.TimeOfLastDeploy is null)
{
return null;
}
return reply.TimeOfLastDeploy.ToDateTime();
}
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy. Each <see cref="GalaxyObject"/>
/// includes its dynamic attributes so callers can determine which tag references
/// they may subscribe to via the MxAccessGateway service.
/// </summary>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default)
{
List<GalaxyObject> objects = [];
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
string pageToken = string.Empty;
do
{
DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options);
request.PageSize = DiscoverHierarchyPageSize;
request.PageToken = pageToken;
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
request,
cancellationToken)
.ConfigureAwait(false);
objects.AddRange(reply.Objects);
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken)
&& !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return objects;
}
private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
DiscoverHierarchyRequest request = new()
{
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
if (options.RootGobjectId.HasValue)
{
request.RootGobjectId = options.RootGobjectId.Value;
}
else if (!string.IsNullOrWhiteSpace(options.RootTagName))
{
request.RootTagName = options.RootTagName;
}
else if (!string.IsNullOrWhiteSpace(options.RootContainedPath))
{
request.RootContainedPath = options.RootContainedPath;
}
if (options.MaxDepth.HasValue)
{
request.MaxDepth = options.MaxDepth.Value;
}
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
/// per new <c>time_of_last_deploy</c>. Pass <paramref name="lastSeenDeployTime"/> to
/// suppress the bootstrap when the caller already holds the current deploy time.
/// </summary>
/// <remarks>
/// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the
/// stream is interrupted the caller must reopen it; the server does not guarantee
/// at-least-once delivery beyond the per-subscriber buffer (gaps in
/// <see cref="DeployEvent.Sequence"/> indicate dropped events).
/// </remarks>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
DateTimeOffset? lastSeenDeployTime = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
WatchDeployEventsRequest request = new();
if (lastSeenDeployTime is { } seen)
{
request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen);
}
return WatchDeployEventsRawAsync(request, cancellationToken);
}
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return WatchDeployEventsCoreAsync(request, cancellationToken);
}
private async IAsyncEnumerable<DeployEvent> WatchDeployEventsCoreAsync(
WatchDeployEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (DeployEvent deployEvent in _transport
.WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken))
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return deployEvent;
}
}
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
@@ -0,0 +1,127 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client;
internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient;
GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient;
public async Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.TestConnectionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.GetLastDeployTimeAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.DiscoverHierarchyAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<DeployEvent> call = RawClient.WatchDeployEvents(request, callOptions);
IAsyncStreamReader<DeployEvent> responseStream = call.ResponseStream;
while (true)
{
DeployEvent? deployEvent;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
deployEvent = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return deployEvent;
}
}
IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
return WatchDeployEventsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
}
@@ -0,0 +1,127 @@
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
internal sealed class GrpcMxGatewayClientTransport(
MxGatewayClientOptions options,
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient;
MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient;
public async Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.OpenSessionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.CloseSessionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.InvokeAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<MxEvent> call = RawClient.StreamEvents(request, callOptions);
IAsyncStreamReader<MxEvent> responseStream = call.ResponseStream;
while (true)
{
MxEvent? gatewayEvent;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
gatewayEvent = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return gatewayEvent;
}
}
IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions)
{
return StreamEventsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
}
@@ -0,0 +1,27 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client;
internal interface IGalaxyRepositoryClientTransport
{
MxGatewayClientOptions Options { get; }
GalaxyRepository.GalaxyRepositoryClient? RawClient { get; }
Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions);
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions);
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions);
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions);
}
@@ -0,0 +1,27 @@
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
internal interface IMxGatewayClientTransport
{
MxGatewayClientOptions Options { get; }
MxAccessGateway.MxAccessGatewayClient? RawClient { get; }
Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CallOptions callOptions);
Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions);
Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CallOptions callOptions);
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions);
}
@@ -0,0 +1,24 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public sealed class MxAccessException : MxGatewayCommandException
{
public MxAccessException(
string message,
MxCommandReply reply,
Exception? innerException = null)
: base(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
reply.HasHresult ? reply.Hresult : null,
reply.Statuses.ToArray(),
innerException)
{
Reply = reply;
}
public MxCommandReply Reply { get; }
}
@@ -0,0 +1,96 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public static class MxCommandReplyExtensions
{
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
ProtocolStatusCode code = reply.ProtocolStatus?.Code
?? ProtocolStatusCode.Unspecified;
if (code is ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure)
{
return reply;
}
throw CreateProtocolException(reply, code);
}
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
bool mxAccessFailure = reply.ProtocolStatus?.Code is ProtocolStatusCode.MxaccessFailure;
bool hResultFailure = reply.HasHresult && reply.Hresult != 0;
bool statusFailure = reply.Statuses.Any(status => !status.IsSuccess());
if (!mxAccessFailure && !hResultFailure && !statusFailure)
{
return reply;
}
throw new MxAccessException(CreateMxAccessMessage(reply), reply);
}
private static MxGatewayException CreateProtocolException(
MxCommandReply reply,
ProtocolStatusCode code)
{
string message = CreateProtocolMessage(reply);
int? hResult = reply.HasHresult ? reply.Hresult : null;
MxStatusProxy[] statuses = reply.Statuses.ToArray();
return code switch
{
ProtocolStatusCode.SessionNotFound or ProtocolStatusCode.SessionNotReady
=> new MxGatewaySessionException(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
hResult,
statuses),
ProtocolStatusCode.WorkerUnavailable
=> new MxGatewayWorkerException(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
hResult,
statuses),
_
=> new MxGatewayCommandException(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
hResult,
statuses),
};
}
private static string CreateProtocolMessage(MxCommandReply reply)
{
string statusMessage = string.IsNullOrWhiteSpace(reply.ProtocolStatus?.Message)
? "Gateway protocol failure."
: reply.ProtocolStatus.Message;
return $"{statusMessage} code={reply.ProtocolStatus?.Code}; session={reply.SessionId}; correlation={reply.CorrelationId}";
}
private static string CreateMxAccessMessage(MxCommandReply reply)
{
string statusSummary = reply.Statuses.Count is 0
? "no MXSTATUS_PROXY entries"
: string.Join("; ", reply.Statuses.Select(status => status.ToDiagnosticSummary()));
string hResult = reply.HasHresult
? $"0x{reply.Hresult:X8}"
: "none";
return $"MXAccess command failed. kind={reply.Kind}; hresult={hResult}; statuses={statusSummary}";
}
}
@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
<PropertyGroup>
@@ -0,0 +1,25 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public sealed class MxGatewayAuthenticationException : MxGatewayException
{
public MxGatewayAuthenticationException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,25 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public sealed class MxGatewayAuthorizationException : MxGatewayException
{
public MxGatewayAuthorizationException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -1,41 +1,233 @@
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client;
/// <summary>
/// Provides the initial .NET client entry point and raw generated gRPC client.
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
/// </summary>
public sealed class MxGatewayClient : IAsyncDisposable
{
private readonly GrpcChannel _channel;
private readonly IMxGatewayClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
private MxGatewayClient(GrpcChannel channel)
internal MxGatewayClient(
MxGatewayClientOptions options,
IMxGatewayClientTransport transport)
{
_channel = channel;
RawClient = new MxAccessGateway.MxAccessGatewayClient(channel);
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
_channel = null!;
}
public MxAccessGateway.MxAccessGatewayClient RawClient { get; }
private MxGatewayClient(
GrpcChannel channel,
IMxGatewayClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<MxGatewayClient>());
}
public MxGatewayClientOptions Options { get; }
public MxAccessGateway.MxAccessGatewayClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
public static MxGatewayClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new MxGatewayClient(channel);
return new MxGatewayClient(
channel,
new GrpcMxGatewayClientTransport(
options,
new MxAccessGateway.MxAccessGatewayClient(channel)));
}
public async Task<MxGatewaySession> OpenSessionAsync(
OpenSessionRequest? request = null,
CancellationToken cancellationToken = default)
{
OpenSessionReply reply = await OpenSessionRawAsync(
request ?? new OpenSessionRequest(),
cancellationToken)
.ConfigureAwait(false);
return new MxGatewaySession(this, reply);
}
public Task<OpenSessionReply> OpenSessionRawAsync(
OpenSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken));
}
public Task<CloseSessionReply> CloseSessionRawAsync(
CloseSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.CloseSessionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
if (MxGatewayClientRetryPolicy.IsRetryableCommand(request.Command?.Kind ?? MxCommandKind.Unspecified))
{
return ExecuteSafeUnaryAsync(
token => _transport.InvokeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken));
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken));
}
public ValueTask DisposeAsync()
{
_channel.Dispose();
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
@@ -21,6 +21,12 @@ public sealed class MxGatewayClientOptions
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan? StreamTimeout { get; init; }
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
public MxGatewayClientRetryOptions Retry { get; init; } = new();
public ILoggerFactory? LoggerFactory { get; init; }
public void Validate()
@@ -54,5 +60,35 @@ public sealed class MxGatewayClientOptions
nameof(DefaultCallTimeout),
"The default call timeout must be greater than zero.");
}
if (StreamTimeout is not null && StreamTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(StreamTimeout),
"The stream timeout must be greater than zero when configured.");
}
if (MaxGrpcMessageBytes <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(MaxGrpcMessageBytes),
"The maximum gRPC message size must be greater than zero.");
}
if (UseTls && Endpoint.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException(
"UseTls requires an https gateway endpoint.",
nameof(Endpoint));
}
if (!UseTls && Endpoint.Scheme == Uri.UriSchemeHttps)
{
throw new ArgumentException(
"An https gateway endpoint requires UseTls.",
nameof(Endpoint));
}
Retry.Validate();
}
}
@@ -0,0 +1,43 @@
namespace MxGateway.Client;
public sealed class MxGatewayClientRetryOptions
{
public int MaxAttempts { get; init; } = 2;
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(2);
public bool UseJitter { get; init; } = true;
public void Validate()
{
if (MaxAttempts <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(MaxAttempts),
"The retry max attempts value must be greater than zero.");
}
if (Delay <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(Delay),
"The retry delay must be greater than zero.");
}
if (MaxDelay <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(MaxDelay),
"The retry max delay must be greater than zero.");
}
if (MaxDelay < Delay)
{
throw new ArgumentOutOfRangeException(
nameof(MaxDelay),
"The retry max delay must be greater than or equal to the retry delay.");
}
}
}
@@ -0,0 +1,62 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto;
using Polly;
using Polly.Retry;
namespace MxGateway.Client;
internal static class MxGatewayClientRetryPolicy
{
public static ResiliencePipeline Create(
MxGatewayClientRetryOptions options,
ILogger? logger)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
return new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = Math.Max(0, options.MaxAttempts - 1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = options.UseJitter,
Delay = options.Delay,
MaxDelay = options.MaxDelay,
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsTransientGrpcFailure),
OnRetry = args =>
{
logger?.LogDebug(
args.Outcome.Exception,
"Retrying MXAccess Gateway client call after transient gRPC failure. Attempt {Attempt}.",
args.AttemptNumber + 1);
return default;
},
})
.Build();
}
public static bool IsRetryableCommand(MxCommandKind kind)
{
return kind is MxCommandKind.Ping
or MxCommandKind.GetSessionState
or MxCommandKind.GetWorkerInfo;
}
private static bool IsTransientGrpcFailure(Exception exception)
{
return exception switch
{
RpcException rpcException => IsTransientStatus(rpcException.StatusCode),
MxGatewayException { InnerException: RpcException rpcException } => IsTransientStatus(rpcException.StatusCode),
_ => false,
};
}
private static bool IsTransientStatus(StatusCode statusCode)
{
return statusCode is StatusCode.Unavailable
or StatusCode.DeadlineExceeded
or StatusCode.ResourceExhausted;
}
}
@@ -0,0 +1,25 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public class MxGatewayCommandException : MxGatewayException
{
public MxGatewayCommandException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,45 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public class MxGatewayException : Exception
{
public MxGatewayException(string message)
: base(message)
{
Statuses = [];
}
public MxGatewayException(string message, Exception? innerException)
: base(message, innerException)
{
Statuses = [];
}
public MxGatewayException(
string message,
string? sessionId,
string? correlationId,
ProtocolStatus? protocolStatus,
int? hResult,
IReadOnlyList<MxStatusProxy> statuses,
Exception? innerException = null)
: base(message, innerException)
{
SessionId = sessionId;
CorrelationId = correlationId;
ProtocolStatus = protocolStatus;
HResultCode = hResult;
Statuses = statuses;
}
public string? SessionId { get; }
public string? CorrelationId { get; }
public ProtocolStatus? ProtocolStatus { get; }
public int? HResultCode { get; }
public IReadOnlyList<MxStatusProxy> Statuses { get; }
}
@@ -0,0 +1,489 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
/// <summary>
/// Represents one gateway-backed MXAccess session.
/// </summary>
public sealed class MxGatewaySession : IAsyncDisposable
{
private readonly MxGatewayClient _client;
private readonly SemaphoreSlim _closeLock = new(1, 1);
private CloseSessionReply? _closeReply;
internal MxGatewaySession(
MxGatewayClient client,
OpenSessionReply openSessionReply)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply));
}
public string SessionId => OpenSessionReply.SessionId;
public OpenSessionReply OpenSessionReply { get; }
public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default)
{
if (_closeReply is not null)
{
return _closeReply;
}
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_closeReply is not null)
{
return _closeReply;
}
_closeReply = await _client.CloseSessionRawAsync(
new CloseSessionRequest { SessionId = SessionId },
cancellationToken)
.ConfigureAwait(false);
return _closeReply;
}
finally
{
_closeLock.Release();
}
}
public async Task<int> RegisterAsync(
string clientName,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
}
public Task<MxCommandReply> RegisterRawAsync(
string clientName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = clientName },
},
cancellationToken);
}
public async Task<int> AddItemAsync(
int serverHandle,
string itemDefinition,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AddItemRawAsync(
serverHandle,
itemDefinition,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
}
public Task<MxCommandReply> AddItemRawAsync(
int serverHandle,
string itemDefinition,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = itemDefinition,
},
},
cancellationToken);
}
public async Task<int> AddItem2Async(
int serverHandle,
string itemDefinition,
string itemContext,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AddItem2RawAsync(
serverHandle,
itemDefinition,
itemContext,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
}
public Task<MxCommandReply> AddItem2RawAsync(
int serverHandle,
string itemDefinition,
string itemContext,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddItem2,
AddItem2 = new AddItem2Command
{
ServerHandle = serverHandle,
ItemDefinition = itemDefinition,
ItemContext = itemContext ?? string.Empty,
},
},
cancellationToken);
}
public async Task AdviseAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AdviseRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
public Task<MxCommandReply> AdviseRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Advise,
Advise = new AdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
public async Task UnAdviseAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await UnAdviseRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
public Task<MxCommandReply> UnAdviseRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.UnAdvise,
UnAdvise = new UnAdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
public async Task RemoveItemAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await RemoveItemRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
public Task<MxCommandReply> RemoveItemRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
public async Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
AddItemBulkCommand command = new() { ServerHandle = serverHandle };
command.TagAddresses.Add(tagAddresses);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddItemBulk,
AddItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItemBulk?.Results.ToArray() ?? [];
}
public async Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
AdviseItemBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AdviseItemBulk,
AdviseItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AdviseItemBulk?.Results.ToArray() ?? [];
}
public async Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
RemoveItemBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.RemoveItemBulk,
RemoveItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.RemoveItemBulk?.Results.ToArray() ?? [];
}
public async Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
UnAdviseItemBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.UnAdviseItemBulk,
UnAdviseItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.UnAdviseItemBulk?.Results.ToArray() ?? [];
}
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
SubscribeBulkCommand command = new() { ServerHandle = serverHandle };
command.TagAddresses.Add(tagAddresses);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.SubscribeBulk,
SubscribeBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.SubscribeBulk?.Results.ToArray() ?? [];
}
public async Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
UnsubscribeBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.UnsubscribeBulk,
UnsubscribeBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
}
public async Task WriteAsync(
int serverHandle,
int itemHandle,
MxValue value,
int userId,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
public Task<MxCommandReply> WriteRawAsync(
int serverHandle,
int itemHandle,
MxValue value,
int userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(value);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = value,
UserId = userId,
},
},
cancellationToken);
}
public async Task Write2Async(
int serverHandle,
int itemHandle,
MxValue value,
MxValue timestampValue,
int userId,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await Write2RawAsync(
serverHandle,
itemHandle,
value,
timestampValue,
userId,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
public Task<MxCommandReply> Write2RawAsync(
int serverHandle,
int itemHandle,
MxValue value,
MxValue timestampValue,
int userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(timestampValue);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Write2,
Write2 = new Write2Command
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = value,
TimestampValue = timestampValue,
UserId = userId,
},
},
cancellationToken);
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return _client.InvokeAsync(request, cancellationToken);
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
ulong afterWorkerSequence = 0,
CancellationToken cancellationToken = default)
{
return _client.StreamEventsAsync(
new StreamEventsRequest
{
SessionId = SessionId,
AfterWorkerSequence = afterWorkerSequence,
},
cancellationToken);
}
public async ValueTask DisposeAsync()
{
await CloseAsync().ConfigureAwait(false);
_closeLock.Dispose();
}
private Task<MxCommandReply> InvokeCommandAsync(
MxCommand command,
CancellationToken cancellationToken)
{
return _client.InvokeAsync(
new MxCommandRequest
{
SessionId = SessionId,
ClientCorrelationId = Guid.NewGuid().ToString("N"),
Command = command,
},
cancellationToken);
}
}
@@ -0,0 +1,25 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public sealed class MxGatewaySessionException : MxGatewayException
{
public MxGatewaySessionException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,25 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public sealed class MxGatewayWorkerException : MxGatewayException
{
public MxGatewayWorkerException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,25 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
public static class MxStatusProxyExtensions
{
public static bool IsSuccess(this MxStatusProxy status)
{
ArgumentNullException.ThrowIfNull(status);
return status.Success != 0
&& status.Category is MxStatusCategory.Ok;
}
public static string ToDiagnosticSummary(this MxStatusProxy status)
{
ArgumentNullException.ThrowIfNull(status);
string diagnosticText = string.IsNullOrWhiteSpace(status.DiagnosticText)
? "no diagnostic text"
: status.DiagnosticText;
return $"{status.Category} by {status.DetectedBy}; detail={status.Detail}; {diagnosticText}";
}
}
@@ -0,0 +1,284 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
/// <summary>
/// Creates and projects gateway MXAccess values without hiding the raw
/// protobuf value carried by command replies and events.
/// </summary>
public static class MxValueExtensions
{
public static MxValue ToMxValue(this bool value)
{
return new MxValue
{
DataType = MxDataType.Boolean,
VariantType = "VT_BOOL",
BoolValue = value,
};
}
public static MxValue ToMxValue(this int value)
{
return new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = value,
};
}
public static MxValue ToMxValue(this long value)
{
return new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I8",
Int64Value = value,
};
}
public static MxValue ToMxValue(this float value)
{
return new MxValue
{
DataType = MxDataType.Float,
VariantType = "VT_R4",
FloatValue = value,
};
}
public static MxValue ToMxValue(this double value)
{
return new MxValue
{
DataType = MxDataType.Double,
VariantType = "VT_R8",
DoubleValue = value,
};
}
public static MxValue ToMxValue(this string value)
{
ArgumentNullException.ThrowIfNull(value);
return new MxValue
{
DataType = MxDataType.String,
VariantType = "VT_BSTR",
StringValue = value,
};
}
public static MxValue ToMxValue(this DateTimeOffset value)
{
return new MxValue
{
DataType = MxDataType.Time,
VariantType = "VT_DATE",
TimestampValue = Timestamp.FromDateTimeOffset(value),
};
}
public static MxValue ToMxValue(this DateTime value)
{
return new DateTimeOffset(
value.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
: value.ToUniversalTime())
.ToMxValue();
}
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new BoolArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Boolean, "VT_ARRAY|VT_BOOL", values.Count, new MxArray
{
ElementDataType = MxDataType.Boolean,
VariantType = "VT_ARRAY|VT_BOOL",
BoolValues = array,
});
}
public static MxValue ToMxValue(this IReadOnlyList<int> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new Int32Array();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I4", values.Count, new MxArray
{
ElementDataType = MxDataType.Integer,
VariantType = "VT_ARRAY|VT_I4",
Int32Values = array,
});
}
public static MxValue ToMxValue(this IReadOnlyList<long> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new Int64Array();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I8", values.Count, new MxArray
{
ElementDataType = MxDataType.Integer,
VariantType = "VT_ARRAY|VT_I8",
Int64Values = array,
});
}
public static MxValue ToMxValue(this IReadOnlyList<float> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new FloatArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Float, "VT_ARRAY|VT_R4", values.Count, new MxArray
{
ElementDataType = MxDataType.Float,
VariantType = "VT_ARRAY|VT_R4",
FloatValues = array,
});
}
public static MxValue ToMxValue(this IReadOnlyList<double> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new DoubleArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Double, "VT_ARRAY|VT_R8", values.Count, new MxArray
{
ElementDataType = MxDataType.Double,
VariantType = "VT_ARRAY|VT_R8",
DoubleValues = array,
});
}
public static MxValue ToMxValue(this IReadOnlyList<string> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new StringArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.String, "VT_ARRAY|VT_BSTR", values.Count, new MxArray
{
ElementDataType = MxDataType.String,
VariantType = "VT_ARRAY|VT_BSTR",
StringValues = array,
});
}
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new TimestampArray();
array.Values.Add(values.Select(Timestamp.FromDateTimeOffset));
return CreateArrayValue(MxDataType.Time, "VT_ARRAY|VT_DATE", values.Count, new MxArray
{
ElementDataType = MxDataType.Time,
VariantType = "VT_ARRAY|VT_DATE",
TimestampValues = array,
});
}
public static string GetProjectionKind(this MxValue value)
{
ArgumentNullException.ThrowIfNull(value);
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => "boolValue",
MxValue.KindOneofCase.Int32Value => "int32Value",
MxValue.KindOneofCase.Int64Value => "int64Value",
MxValue.KindOneofCase.FloatValue => "floatValue",
MxValue.KindOneofCase.DoubleValue => "doubleValue",
MxValue.KindOneofCase.StringValue => "stringValue",
MxValue.KindOneofCase.TimestampValue => "timestampValue",
MxValue.KindOneofCase.ArrayValue => "arrayValue",
MxValue.KindOneofCase.RawValue => "rawValue",
_ => value.IsNull ? "nullValue" : "unspecified",
};
}
public static object? ToClrValue(this MxValue value)
{
ArgumentNullException.ThrowIfNull(value);
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => value.BoolValue,
MxValue.KindOneofCase.Int32Value => value.Int32Value,
MxValue.KindOneofCase.Int64Value => value.Int64Value,
MxValue.KindOneofCase.FloatValue => value.FloatValue,
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
MxValue.KindOneofCase.StringValue => value.StringValue,
MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTimeOffset(),
MxValue.KindOneofCase.ArrayValue => value.ArrayValue.ToClrArrayValue(),
MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(),
_ => value.IsNull ? null : value,
};
}
public static object? ToClrArrayValue(this MxArray array)
{
ArgumentNullException.ThrowIfNull(array);
return array.ValuesCase switch
{
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values
.Select(timestamp => timestamp.ToDateTimeOffset())
.ToArray(),
MxArray.ValuesOneofCase.RawValues => array.RawValues.Values
.Select(value => value.ToByteArray())
.ToArray(),
_ => null,
};
}
public static MxValue ToRawMxValue(
byte[] value,
string variantType,
string rawDiagnostic,
int rawDataType = 0)
{
ArgumentNullException.ThrowIfNull(value);
return new MxValue
{
DataType = MxDataType.Unknown,
VariantType = variantType,
RawDiagnostic = rawDiagnostic,
RawDataType = rawDataType,
RawValue = ByteString.CopyFrom(value),
};
}
private static MxValue CreateArrayValue(
MxDataType dataType,
string variantType,
int length,
MxArray array)
{
array.Dimensions.Add((uint)length);
return new MxValue
{
DataType = dataType,
VariantType = variantType,
ArrayValue = array,
};
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
+225 -2
View File
@@ -7,9 +7,9 @@ CLI, and unit tests.
| Project | Purpose |
|---------|---------|
| `MxGateway.Client` | .NET 10 library entry point and raw gRPC client access. |
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
| `MxGateway.Client.Tests` | Unit tests for the scaffold and generated contract wiring. |
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
the client compiles against the same generated protobuf and gRPC types as the
@@ -22,3 +22,226 @@ future client build switches to client-local `Grpc.Tools` generation.
dotnet build clients/dotnet/MxGateway.Client.sln
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
```
## Packaging
Create local library and CLI artifacts from the repository root:
```powershell
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
```
The library package references the shared contracts project at build time. The
published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`.
## Regenerating Protobuf Bindings
The .NET client uses the generated C# types from
`src/MxGateway.Contracts/Generated`. Regenerate those files through the
contracts project:
```powershell
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
```
## Client Usage
`MxGatewayClient` opens a gRPC channel to the gateway and attaches the API key
to every unary and streaming call as `authorization: Bearer <api-key>`.
Cancellation tokens passed to the public methods flow to the generated gRPC
call. Client-side cancellation stops waiting for the gateway response; it does
not abort an MXAccess COM call that is already executing inside a worker.
```csharp
await using MxGatewayClient client = MxGatewayClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
MxGatewaySession session = await client.OpenSessionAsync();
try
{
int serverHandle = await session.RegisterAsync("sample-client");
int itemHandle = await session.AddItemAsync(
serverHandle,
"Area001.Pump001.Speed");
await session.AdviseAsync(serverHandle, itemHandle);
}
finally
{
await session.CloseAsync();
}
```
Use `OpenSessionRawAsync`, `CloseSessionRawAsync`, `InvokeAsync`, and
`StreamEventsAsync` when tests or parity tools need direct generated protobuf
messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`.
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
the first `CloseSessionReply` instead of sending another close request.
## Values, Status, And Errors
The client provides extension helpers for generated protobuf values. Use
`ToMxValue()` on .NET scalar values and typed arrays to create `MxValue`
instances for `Write` and `Write2`. Use `ToClrValue()` and
`GetProjectionKind()` when test or diagnostic code needs to inspect generated
`MxValue` replies while preserving `rawDiagnostic`, raw data type fields, and
raw byte payloads.
`MxStatusProxy.IsSuccess()` and `ToDiagnosticSummary()` expose MXAccess status
arrays without collapsing them into a single gateway success flag. Command
reply helpers follow the same split:
```csharp
reply.EnsureProtocolSuccess();
reply.EnsureMxAccessSuccess();
```
`EnsureProtocolSuccess()` raises gateway, session, worker, or command
exceptions for gateway-level failures. It leaves
`PROTOCOL_STATUS_CODE_MXACCESS_FAILURE` to `EnsureMxAccessSuccess()` so callers
can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
itself rejects a command. `MxAccessException.Reply` contains the raw generated
reply.
## CLI Usage
The test CLI supports deterministic JSON output for automation:
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
`smoke` opens a session, registers a client, adds one item, advises it,
optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
output redacts API keys supplied through `--api-key`.
## Galaxy Repository Browse
`GalaxyRepositoryClient` is a separate read-only wrapper around the
`GalaxyRepository` gRPC service exposed by the same gateway. It shares the API
key auth interceptor with `MxGatewayClient` and requires the `metadata:read`
scope server-side. Use it to probe the ZB SQL connection, watch
`time_of_last_deploy` for redeployments, and enumerate the deployed Galaxy
object hierarchy plus each object's dynamic attributes.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
bool ok = await repository.TestConnectionAsync();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();
IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
foreach (GalaxyObject galaxyObject in objects)
{
Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
{
Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}");
}
}
```
Use `DiscoverHierarchyOptions` to request a server-side slice without pulling
the full Galaxy:
```csharp
IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions
{
RootContainedPath = "Area1/Line3",
TagNameGlob = "Pump_*",
IncludeAttributes = false,
});
```
The CLI exposes the same operations:
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
```
### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
server emits a bootstrap event with the current state on subscribe, then one
event per new `time_of_last_deploy`. Pass a `lastSeenDeployTime` to suppress the
bootstrap when the caller already holds the current deploy time. Use the
monotonic `Sequence` field to detect dropped events: gaps mean the
per-subscriber server-side buffer overflowed and the caller should reconcile.
Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The
caller is responsible for reopening the stream on transient failures.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);
DateTimeOffset? lastSeen = null;
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
lastSeen,
cancellationToken))
{
Console.WriteLine(
$"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
{
lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
}
}
```
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
```
Use TLS options for a secured gateway:
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
```
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/client-proto-generation.md)
- [.NET Client Detailed Design](../../docs/clients-dotnet-csharp-design.md)
+163 -5
View File
@@ -37,19 +37,177 @@ Run the Go module checks from `clients/go`:
```powershell
go test ./...
go build ./...
go vet ./...
```
The scaffold tests parse the shared golden JSON fixtures with the generated Go
types. Later client implementation tests add fake gRPC services, auth metadata,
streaming, value conversion, and CLI behavior.
The tests parse the shared JSON fixtures, exercise value and status conversion,
use `bufconn` for fake gateway auth and streaming behavior, and cover CLI JSON
redaction.
## Packaging
Build a local CLI executable from `clients/go`:
```powershell
New-Item -ItemType Directory -Force ../../artifacts/clients/go | Out-Null
go build -o ../../artifacts/clients/go/mxgw-go.exe ./cmd/mxgw-go
```
Install the CLI into the active `GOBIN` or `GOPATH/bin`:
```powershell
go install ./cmd/mxgw-go
```
Other Go modules can consume the library package with the module path
`gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway`.
## Client API
Use `mxgateway.Dial` with `mxgateway.Options` to configure plaintext or TLS
transport, API-key metadata, dial timeout, and per-call timeout:
```go
client, err := mxgateway.Dial(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
```
`Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
## Galaxy Repository browse
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
read-only metadata-only browse over the AVEVA System Platform Galaxy
Repository. It uses the same API-key authentication as the MXAccess Gateway
and requires the `metadata:read` scope. Use `mxgateway.DialGalaxy` to obtain a
`*GalaxyClient` that mirrors the connection-management conventions of
`Client`:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
return err
}
defer galaxy.Close()
ok, err := galaxy.TestConnection(ctx)
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
objects, err := galaxy.DiscoverHierarchy(ctx)
```
`GetLastDeployTime` returns `(time.Time{}, false, nil)` when the server
reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access.
### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
bootstrap event with the current Galaxy state immediately on subscribe, then
one `DeployEvent` per new deploy. `Sequence` is monotonic per server start;
gaps signal dropped events. Pass a non-nil `lastSeenDeployTime` to suppress the
bootstrap event when resuming from a known checkpoint:
```go
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
if err != nil {
return err
}
for {
select {
case ev, ok := <-events:
if !ok {
return nil // stream completed (server EOF or ctx cancelled)
}
log.Printf("seq=%d objects=%d attrs=%d",
ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
case streamErr := <-errs:
if streamErr != nil {
return streamErr // *GatewayError
}
case <-ctx.Done():
return ctx.Err()
}
}
```
Cancel the supplied context to tear down the stream cleanly. Both channels
close after EOF, cancellation, or a terminal error; surfaced errors are wrapped
in `*GatewayError`.
The CLI exposes the same RPC via `galaxy-watch`:
```powershell
go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
```
The command runs until Ctrl+C (or the optional `-limit` is reached) and prints
one line per event in text mode or one JSON object per event with `-json`.
## CLI
The scaffold CLI exposes version information:
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
the gateway:
```powershell
go run ./cmd/mxgw-go version -json
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
```
Additional commands are implemented with the client/session wrapper work.
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
enabled. CLI output redacts the key value and never writes the raw secret.
Use TLS options for a secured gateway:
```powershell
go run ./cmd/mxgw-go smoke -endpoint mxgateway.example.local:5001 -ca-cert C:\certs\mxgateway-ca.pem -server-name-override mxgateway.example.local -api-key-env MXGATEWAY_API_KEY -item Area001.Tag.Value -json
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
```
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/client-proto-generation.md)
- [Go Client Detailed Design](../../docs/clients-golang-design.md)
+834 -11
View File
@@ -1,12 +1,22 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
)
type versionOutput struct {
@@ -15,29 +25,88 @@ type versionOutput struct {
WorkerProtocolVersion uint32 `json:"workerProtocolVersion"`
}
type commonOptions struct {
Endpoint string `json:"endpoint"`
APIKey string `json:"apiKey"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
Plaintext bool `json:"plaintext"`
CACertFile string `json:"caCertFile,omitempty"`
ServerName string `json:"serverNameOverride,omitempty"`
CallTimeout string `json:"callTimeout,omitempty"`
apiKeyValue string
timeout time.Duration
}
type openSessionOutput struct {
Command string `json:"command"`
Options commonOptions `json:"options"`
Reply json.RawMessage `json:"reply"`
}
type commandReplyOutput struct {
Command string `json:"command"`
Options commonOptions `json:"options"`
Reply json.RawMessage `json:"reply"`
}
func main() {
if err := run(os.Args[1:]); err != nil {
if err := runWithIO(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
}
func run(args []string) error {
return runWithIO(context.Background(), args, os.Stdout, os.Stderr)
}
func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: mxgw-go version [-json]")
writeUsage(stderr)
return errors.New("missing command")
}
switch args[0] {
case "version":
return runVersion(args[1:])
return runVersion(args[1:], stdout, stderr)
case "open-session":
return runOpenSession(ctx, args[1:], stdout, stderr)
case "close-session":
return runCloseSession(ctx, args[1:], stdout, stderr)
case "register":
return runRegister(ctx, args[1:], stdout, stderr)
case "add-item":
return runAddItem(ctx, args[1:], stdout, stderr)
case "advise":
return runAdvise(ctx, args[1:], stdout, stderr)
case "subscribe-bulk":
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk":
return runUnsubscribeBulk(ctx, args[1:], stdout, stderr)
case "write":
return runWrite(ctx, args[1:], stdout, stderr)
case "stream-events":
return runStreamEvents(ctx, args[1:], stdout, stderr)
case "smoke":
return runSmoke(ctx, args[1:], stdout, stderr)
case "galaxy-test-connection":
return runGalaxyTestConnection(ctx, args[1:], stdout, stderr)
case "galaxy-last-deploy":
return runGalaxyLastDeploy(ctx, args[1:], stdout, stderr)
case "galaxy-discover":
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
case "galaxy-watch":
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
default:
writeUsage(stderr)
return fmt.Errorf("unknown command %q", args[0])
}
}
func runVersion(args []string) error {
func runVersion(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("version", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
flags.SetOutput(stderr)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
@@ -51,13 +120,767 @@ func runVersion(args []string) error {
}
if *jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(output)
return writeJSON(stdout, output)
}
fmt.Fprintf(os.Stdout, "mxgw-go %s\n", output.ClientVersion)
fmt.Fprintf(os.Stdout, "gateway protocol %d\n", output.GatewayProtocolVersion)
fmt.Fprintf(os.Stdout, "worker protocol %d\n", output.WorkerProtocolVersion)
fmt.Fprintf(stdout, "mxgw-go %s\n", output.ClientVersion)
fmt.Fprintf(stdout, "gateway protocol %d\n", output.GatewayProtocolVersion)
fmt.Fprintf(stdout, "worker protocol %d\n", output.WorkerProtocolVersion)
return nil
}
func runOpenSession(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("open-session", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
clientName := flags.String("client-session-name", "", "client session name")
backend := flags.String("backend", "", "requested backend")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
reply, err := client.OpenSessionRaw(ctx, (&mxgateway.OpenSessionOptions{
RequestedBackend: *backend,
ClientSessionName: *clientName,
}).Request())
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, openSessionOutput{
Command: "open-session",
Options: options,
Reply: mustMarshalProto(reply),
})
}
fmt.Fprintln(stdout, reply.GetSessionId())
return nil
}
func runCloseSession(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("close-session", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
reply, err := client.CloseSessionRaw(ctx, &mxgateway.CloseSessionRequest{SessionId: *sessionID})
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, commandReplyOutput{
Command: "close-session",
Options: options,
Reply: mustMarshalProto(reply),
})
}
fmt.Fprintln(stdout, reply.GetFinalState())
return nil
}
func runRegister(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("register", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
clientName := flags.String("client-name", "", "MXAccess client name")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" || *clientName == "" {
return errors.New("session-id and client-name are required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.RegisterRaw(ctx, *clientName)
return writeCommandOutput(stdout, *jsonOutput, "register", options, reply, err)
}
func runAddItem(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("add-item", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
item := flags.String("item", "", "item definition")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" || *item == "" {
return errors.New("session-id and item are required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.AddItemRaw(ctx, int32(*serverHandle), *item)
return writeCommandOutput(stdout, *jsonOutput, "add-item", options, reply, err)
}
func runAdvise(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("advise", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.AdviseRaw(ctx, int32(*serverHandle), int32(*itemHandle))
return writeCommandOutput(stdout, *jsonOutput, "advise", options, reply, err)
}
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
items := flags.String("items", "", "comma-separated item definitions")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" || *items == "" {
return errors.New("session-id and items are required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
results, err := session.SubscribeBulk(ctx, int32(*serverHandle), parseStringList(*items))
return writeBulkOutput(stdout, *jsonOutput, "subscribe-bulk", options, results, err)
}
func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("unsubscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" || *itemHandles == "" {
return errors.New("session-id and item-handles are required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), parseInt32List(*itemHandles))
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
}
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("write", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
valueText := flags.String("value", "", "value text")
userID := flags.Int("user-id", 0, "MXAccess user id")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
value, err := parseValue(*valueType, *valueText)
if err != nil {
return err
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.WriteRaw(ctx, int32(*serverHandle), int32(*itemHandle), value, int32(*userID))
return writeCommandOutput(stdout, *jsonOutput, "write", options, reply, err)
}
func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("stream-events", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
after := flags.Uint64("after-worker-sequence", 0, "first worker sequence to read after")
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
client, _, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
if err != nil {
return err
}
defer subscription.Close()
events := subscription.Events()
count := 0
for result := range events {
if result.Err != nil {
return result.Err
}
if *jsonOutput {
fmt.Fprintln(stdout, string(mustMarshalProto(result.Event)))
} else {
fmt.Fprintf(stdout, "%d %s\n", result.Event.GetWorkerSequence(), result.Event.GetFamily())
}
count++
if *limit > 0 && count >= *limit {
cancelStream()
return nil
}
}
return nil
}
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
clientName := flags.String("client-name", "mxgw-go-smoke", "MXAccess client name")
item := flags.String("item", "", "item definition")
if err := flags.Parse(args); err != nil {
return err
}
if *item == "" {
return errors.New("item is required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session, err := client.OpenSession(ctx, mxgateway.OpenSessionOptions{ClientSessionName: *clientName})
if err != nil {
return err
}
serverHandle, err := session.Register(ctx, *clientName)
if err != nil {
return closeSmokeSession(ctx, session, err)
}
itemHandle, err := session.AddItem(ctx, serverHandle, *item)
if err != nil {
return closeSmokeSession(ctx, session, err)
}
if err := session.Advise(ctx, serverHandle, itemHandle); err != nil {
return closeSmokeSession(ctx, session, err)
}
if err := closeSmokeSession(ctx, session, nil); err != nil {
return err
}
output := map[string]any{
"command": "smoke",
"options": options,
"sessionId": session.ID(),
"serverHandle": serverHandle,
"itemHandle": itemHandle,
}
if *jsonOutput {
return writeJSON(stdout, output)
}
fmt.Fprintf(stdout, "session=%s server=%d item=%d\n", session.ID(), serverHandle, itemHandle)
return nil
}
func closeSmokeSession(ctx context.Context, session *mxgateway.Session, primaryErr error) error {
closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if deadline, ok := ctx.Deadline(); ok {
if until := time.Until(deadline); until > 0 && until < 5*time.Second {
cancel()
closeCtx, cancel = context.WithTimeout(context.Background(), until)
defer cancel()
}
}
_, closeErr := session.Close(closeCtx)
if primaryErr != nil {
return primaryErr
}
return closeErr
}
func parseStringList(value string) []string {
parts := strings.Split(value, ",")
items := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
items = append(items, item)
}
}
return items
}
func parseInt32List(value string) []int32 {
parts := strings.Split(value, ",")
items := make([]int32, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item == "" {
continue
}
parsed, err := strconv.ParseInt(item, 10, 32)
if err != nil {
panic(err)
}
items = append(items, int32(parsed))
}
return items
}
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
common := &commonOptions{}
flags.StringVar(&common.Endpoint, "endpoint", "localhost:5000", "gateway endpoint")
flags.StringVar(&common.APIKey, "api-key", "", "gateway API key")
flags.StringVar(&common.APIKeyEnv, "api-key-env", "MXGATEWAY_API_KEY", "environment variable containing the API key")
flags.BoolVar(&common.Plaintext, "plaintext", false, "use plaintext transport")
flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file")
flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override")
flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout")
return common
}
func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Client, commonOptions, error) {
options, err := common.resolved()
if err != nil {
return nil, options, err
}
client, err := mxgateway.Dial(ctx, mxgateway.Options{
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
})
return client, options, err
}
func (o *commonOptions) resolved() (commonOptions, error) {
resolved := *o
if resolved.APIKey == "" && resolved.APIKeyEnv != "" {
resolved.apiKeyValue = os.Getenv(resolved.APIKeyEnv)
} else {
resolved.apiKeyValue = resolved.APIKey
}
resolved.APIKey = mxgateway.RedactAPIKey(resolved.apiKeyValue)
if resolved.CallTimeout != "" {
timeout, err := time.ParseDuration(resolved.CallTimeout)
if err != nil {
return resolved, err
}
resolved.timeout = timeout
}
return resolved, nil
}
func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
switch valueType {
case "bool":
value, err := strconv.ParseBool(valueText)
if err != nil {
return nil, err
}
return mxgateway.BoolValue(value), nil
case "int32":
value, err := strconv.ParseInt(valueText, 10, 32)
if err != nil {
return nil, err
}
return mxgateway.Int32Value(int32(value)), nil
case "int64":
value, err := strconv.ParseInt(valueText, 10, 64)
if err != nil {
return nil, err
}
return mxgateway.Int64Value(value), nil
case "float":
value, err := strconv.ParseFloat(valueText, 32)
if err != nil {
return nil, err
}
return mxgateway.FloatValue(float32(value)), nil
case "double":
value, err := strconv.ParseFloat(valueText, 64)
if err != nil {
return nil, err
}
return mxgateway.DoubleValue(value), nil
case "string":
return mxgateway.StringValue(valueText), nil
default:
return nil, fmt.Errorf("unsupported value type %q", valueType)
}
}
func writeCommandOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, reply *mxgateway.MxCommandReply, err error) error {
if err != nil {
return err
}
if jsonOutput {
return writeJSON(stdout, commandReplyOutput{
Command: command,
Options: options,
Reply: mustMarshalProto(reply),
})
}
fmt.Fprintln(stdout, reply.GetKind())
return nil
}
func writeBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.SubscribeResult, err error) error {
if err != nil {
return err
}
if jsonOutput {
return writeJSON(stdout, map[string]any{
"command": command,
"options": options,
"results": results,
})
}
fmt.Fprintln(stdout, len(results))
return nil
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func mustMarshalProto(message protojsonMessage) json.RawMessage {
data, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(message)
if err != nil {
panic(err)
}
return data
}
type protojsonMessage interface {
ProtoReflect() protoreflect.Message
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch>")
}
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
options, err := common.resolved()
if err != nil {
return nil, options, err
}
client, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
})
return client, options, err
}
func runGalaxyTestConnection(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-test-connection", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
ok, err := client.TestConnection(ctx)
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, map[string]any{
"command": "galaxy-test-connection",
"options": options,
"ok": ok,
})
}
fmt.Fprintln(stdout, ok)
return nil
}
func runGalaxyLastDeploy(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-last-deploy", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
deployTime, present, err := client.GetLastDeployTime(ctx)
if err != nil {
return err
}
if *jsonOutput {
payload := map[string]any{
"command": "galaxy-last-deploy",
"options": options,
"present": present,
}
if present {
payload["timeOfLastDeploy"] = deployTime.UTC().Format(time.RFC3339Nano)
}
return writeJSON(stdout, payload)
}
if !present {
fmt.Fprintln(stdout, "absent")
return nil
}
fmt.Fprintln(stdout, deployTime.UTC().Format(time.RFC3339Nano))
return nil
}
func runGalaxyDiscover(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-discover", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
objects, err := client.DiscoverHierarchy(ctx)
if err != nil {
return err
}
if *jsonOutput {
marshaled := make([]json.RawMessage, 0, len(objects))
for _, obj := range objects {
marshaled = append(marshaled, mustMarshalProto(obj))
}
return writeJSON(stdout, map[string]any{
"command": "galaxy-discover",
"options": options,
"objects": marshaled,
})
}
for _, obj := range objects {
fmt.Fprintf(stdout, "%d\t%s\t%s\t(attrs=%d)\n", obj.GetGobjectId(), obj.GetTagName(), obj.GetContainedName(), len(obj.GetAttributes()))
}
return nil
}
func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-watch", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
lastSeen := flags.String("last-seen-deploy-time", "", "RFC3339 timestamp; when set, suppresses the bootstrap event")
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
if err := flags.Parse(args); err != nil {
return err
}
var lastSeenPtr *time.Time
if *lastSeen != "" {
parsed, err := time.Parse(time.RFC3339, *lastSeen)
if err != nil {
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
}
lastSeenPtr = &parsed
}
client, _, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stopSignals()
streamCtx, cancelStream := context.WithCancel(signalCtx)
defer cancelStream()
events, errs, err := client.WatchDeployEvents(streamCtx, lastSeenPtr)
if err != nil {
return err
}
count := 0
for {
select {
case event, ok := <-events:
if !ok {
// Drain any terminal error before returning.
if streamErr, errOk := <-errs; errOk && streamErr != nil {
return streamErr
}
return nil
}
if *jsonOutput {
fmt.Fprintln(stdout, string(mustMarshalProto(event)))
} else {
fmt.Fprintln(stdout, formatDeployEvent(event))
}
count++
if *limit > 0 && count >= *limit {
cancelStream()
return nil
}
case streamErr, ok := <-errs:
if !ok {
return nil
}
if streamErr != nil {
return streamErr
}
case <-signalCtx.Done():
cancelStream()
// Allow goroutine to drain.
for range events {
}
return nil
}
}
}
func formatDeployEvent(event *mxgateway.DeployEvent) string {
observed := ""
if ts := event.GetObservedAt(); ts != nil {
observed = ts.AsTime().UTC().Format(time.RFC3339Nano)
}
deploy := "absent"
if event.GetTimeOfLastDeployPresent() {
if ts := event.GetTimeOfLastDeploy(); ts != nil {
deploy = ts.AsTime().UTC().Format(time.RFC3339Nano)
}
}
return fmt.Sprintf(
"seq=%d observed=%s deploy=%s objects=%d attributes=%d",
event.GetSequence(),
observed,
deploy,
event.GetObjectCount(),
event.GetAttributeCount(),
)
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
func TestRunVersionJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
var output versionOutput
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("parse JSON: %v", err)
}
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
t.Fatalf("protocol versions were not populated: %+v", output)
}
}
func TestCommonOptionsRedactsAPIKey(t *testing.T) {
options, err := (&commonOptions{
Endpoint: "localhost:5000",
APIKey: "mxgw_super_secret",
Plaintext: true,
CallTimeout: "2s",
}).resolved()
if err != nil {
t.Fatalf("resolved() error = %v", err)
}
data, err := json.Marshal(options)
if err != nil {
t.Fatalf("marshal options: %v", err)
}
if strings.Contains(string(data), "super_secret") {
t.Fatalf("redacted JSON leaked API key: %s", data)
}
if !strings.Contains(string(data), "mxgw") {
t.Fatalf("redacted JSON did not preserve key shape: %s", data)
}
}
func TestParseValueBuildsTypedValue(t *testing.T) {
value, err := parseValue("int32", "123")
if err != nil {
t.Fatalf("parseValue() error = %v", err)
}
if got := value.GetInt32Value(); got != 123 {
t.Fatalf("int32 value = %d, want 123", got)
}
}
+6 -2
View File
@@ -30,13 +30,17 @@ $env:Path = "$goPluginPath;$env:Path"
--go_opt=paths=source_relative `
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
"--go_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
mxaccess_worker.proto
mxaccess_worker.proto `
galaxy_repository.proto
& $protoc `
--proto_path=$protoRoot `
--go-grpc_out=$outputRoot `
--go-grpc_opt=paths=source_relative `
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
mxaccess_gateway.proto
"--go-grpc_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
galaxy_repository.proto
@@ -0,0 +1,970 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
wrapperspb "google.golang.org/protobuf/types/known/wrapperspb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type TestConnectionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TestConnectionRequest) Reset() {
*x = TestConnectionRequest{}
mi := &file_galaxy_repository_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestConnectionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestConnectionRequest) ProtoMessage() {}
func (x *TestConnectionRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestConnectionRequest.ProtoReflect.Descriptor instead.
func (*TestConnectionRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{0}
}
type TestConnectionReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TestConnectionReply) Reset() {
*x = TestConnectionReply{}
mi := &file_galaxy_repository_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestConnectionReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestConnectionReply) ProtoMessage() {}
func (x *TestConnectionReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestConnectionReply.ProtoReflect.Descriptor instead.
func (*TestConnectionReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{1}
}
func (x *TestConnectionReply) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
type GetLastDeployTimeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLastDeployTimeRequest) Reset() {
*x = GetLastDeployTimeRequest{}
mi := &file_galaxy_repository_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLastDeployTimeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLastDeployTimeRequest) ProtoMessage() {}
func (x *GetLastDeployTimeRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetLastDeployTimeRequest.ProtoReflect.Descriptor instead.
func (*GetLastDeployTimeRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{2}
}
type GetLastDeployTimeReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"`
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLastDeployTimeReply) Reset() {
*x = GetLastDeployTimeReply{}
mi := &file_galaxy_repository_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLastDeployTimeReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLastDeployTimeReply) ProtoMessage() {}
func (x *GetLastDeployTimeReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetLastDeployTimeReply.ProtoReflect.Descriptor instead.
func (*GetLastDeployTimeReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{3}
}
func (x *GetLastDeployTimeReply) GetPresent() bool {
if x != nil {
return x.Present
}
return false
}
func (x *GetLastDeployTimeReply) GetTimeOfLastDeploy() *timestamppb.Timestamp {
if x != nil {
return x.TimeOfLastDeploy
}
return nil
}
type DiscoverHierarchyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Maximum number of objects to return. The server applies its default when
// unset and rejects non-positive values.
PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
// Opaque token returned by a previous DiscoverHierarchy response.
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
// Optional. When set, return only this object and its descendants.
// Empty = full hierarchy.
//
// Types that are valid to be assigned to Root:
//
// *DiscoverHierarchyRequest_RootGobjectId
// *DiscoverHierarchyRequest_RootTagName
// *DiscoverHierarchyRequest_RootContainedPath
Root isDiscoverHierarchyRequest_Root `protobuf_oneof:"root"`
// Optional. Cap on descendant depth from root. Zero returns only the root.
// Unset means unlimited depth.
MaxDepth *wrapperspb.Int32Value `protobuf:"bytes,6,opt,name=max_depth,json=maxDepth,proto3" json:"max_depth,omitempty"`
// Optional object category id filters.
CategoryIds []int32 `protobuf:"varint,7,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
// Optional case-insensitive substring filters against template names.
TemplateChainContains []string `protobuf:"bytes,8,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
// Optional anchored, case-insensitive glob over object tag_name.
TagNameGlob string `protobuf:"bytes,9,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
// Optional. Unset or true includes attributes. False returns object skeletons.
IncludeAttributes *bool `protobuf:"varint,10,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
// Optional. Return only objects with at least one alarm-bearing attribute.
AlarmBearingOnly bool `protobuf:"varint,11,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
// Optional. Return only objects with at least one historized attribute.
HistorizedOnly bool `protobuf:"varint,12,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoverHierarchyRequest) Reset() {
*x = DiscoverHierarchyRequest{}
mi := &file_galaxy_repository_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoverHierarchyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoverHierarchyRequest) ProtoMessage() {}
func (x *DiscoverHierarchyRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoverHierarchyRequest.ProtoReflect.Descriptor instead.
func (*DiscoverHierarchyRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{4}
}
func (x *DiscoverHierarchyRequest) GetPageSize() int32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *DiscoverHierarchyRequest) GetPageToken() string {
if x != nil {
return x.PageToken
}
return ""
}
func (x *DiscoverHierarchyRequest) GetRoot() isDiscoverHierarchyRequest_Root {
if x != nil {
return x.Root
}
return nil
}
func (x *DiscoverHierarchyRequest) GetRootGobjectId() int32 {
if x != nil {
if x, ok := x.Root.(*DiscoverHierarchyRequest_RootGobjectId); ok {
return x.RootGobjectId
}
}
return 0
}
func (x *DiscoverHierarchyRequest) GetRootTagName() string {
if x != nil {
if x, ok := x.Root.(*DiscoverHierarchyRequest_RootTagName); ok {
return x.RootTagName
}
}
return ""
}
func (x *DiscoverHierarchyRequest) GetRootContainedPath() string {
if x != nil {
if x, ok := x.Root.(*DiscoverHierarchyRequest_RootContainedPath); ok {
return x.RootContainedPath
}
}
return ""
}
func (x *DiscoverHierarchyRequest) GetMaxDepth() *wrapperspb.Int32Value {
if x != nil {
return x.MaxDepth
}
return nil
}
func (x *DiscoverHierarchyRequest) GetCategoryIds() []int32 {
if x != nil {
return x.CategoryIds
}
return nil
}
func (x *DiscoverHierarchyRequest) GetTemplateChainContains() []string {
if x != nil {
return x.TemplateChainContains
}
return nil
}
func (x *DiscoverHierarchyRequest) GetTagNameGlob() string {
if x != nil {
return x.TagNameGlob
}
return ""
}
func (x *DiscoverHierarchyRequest) GetIncludeAttributes() bool {
if x != nil && x.IncludeAttributes != nil {
return *x.IncludeAttributes
}
return false
}
func (x *DiscoverHierarchyRequest) GetAlarmBearingOnly() bool {
if x != nil {
return x.AlarmBearingOnly
}
return false
}
func (x *DiscoverHierarchyRequest) GetHistorizedOnly() bool {
if x != nil {
return x.HistorizedOnly
}
return false
}
type isDiscoverHierarchyRequest_Root interface {
isDiscoverHierarchyRequest_Root()
}
type DiscoverHierarchyRequest_RootGobjectId struct {
RootGobjectId int32 `protobuf:"varint,3,opt,name=root_gobject_id,json=rootGobjectId,proto3,oneof"`
}
type DiscoverHierarchyRequest_RootTagName struct {
RootTagName string `protobuf:"bytes,4,opt,name=root_tag_name,json=rootTagName,proto3,oneof"`
}
type DiscoverHierarchyRequest_RootContainedPath struct {
RootContainedPath string `protobuf:"bytes,5,opt,name=root_contained_path,json=rootContainedPath,proto3,oneof"`
}
func (*DiscoverHierarchyRequest_RootGobjectId) isDiscoverHierarchyRequest_Root() {}
func (*DiscoverHierarchyRequest_RootTagName) isDiscoverHierarchyRequest_Root() {}
func (*DiscoverHierarchyRequest_RootContainedPath) isDiscoverHierarchyRequest_Root() {}
type DiscoverHierarchyReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"`
// Non-empty when another page is available.
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
// Total number of objects in the cached hierarchy at the time of the call.
TotalObjectCount int32 `protobuf:"varint,3,opt,name=total_object_count,json=totalObjectCount,proto3" json:"total_object_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoverHierarchyReply) Reset() {
*x = DiscoverHierarchyReply{}
mi := &file_galaxy_repository_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoverHierarchyReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoverHierarchyReply) ProtoMessage() {}
func (x *DiscoverHierarchyReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoverHierarchyReply.ProtoReflect.Descriptor instead.
func (*DiscoverHierarchyReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{5}
}
func (x *DiscoverHierarchyReply) GetObjects() []*GalaxyObject {
if x != nil {
return x.Objects
}
return nil
}
func (x *DiscoverHierarchyReply) GetNextPageToken() string {
if x != nil {
return x.NextPageToken
}
return ""
}
func (x *DiscoverHierarchyReply) GetTotalObjectCount() int32 {
if x != nil {
return x.TotalObjectCount
}
return 0
}
type WatchDeployEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Optional. When set, the bootstrap event is suppressed if the cached deploy
// time matches this value. Future events are still emitted normally.
LastSeenDeployTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=last_seen_deploy_time,json=lastSeenDeployTime,proto3" json:"last_seen_deploy_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WatchDeployEventsRequest) Reset() {
*x = WatchDeployEventsRequest{}
mi := &file_galaxy_repository_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WatchDeployEventsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WatchDeployEventsRequest) ProtoMessage() {}
func (x *WatchDeployEventsRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WatchDeployEventsRequest.ProtoReflect.Descriptor instead.
func (*WatchDeployEventsRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{6}
}
func (x *WatchDeployEventsRequest) GetLastSeenDeployTime() *timestamppb.Timestamp {
if x != nil {
return x.LastSeenDeployTime
}
return nil
}
type DeployEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Monotonically increasing per server start. Gaps indicate dropped events.
Sequence uint64 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"`
// Server wall-clock when the cache observed the deploy.
ObservedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=observed_at,json=observedAt,proto3" json:"observed_at,omitempty"`
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
TimeOfLastDeployPresent bool `protobuf:"varint,4,opt,name=time_of_last_deploy_present,json=timeOfLastDeployPresent,proto3" json:"time_of_last_deploy_present,omitempty"`
ObjectCount int32 `protobuf:"varint,5,opt,name=object_count,json=objectCount,proto3" json:"object_count,omitempty"`
AttributeCount int32 `protobuf:"varint,6,opt,name=attribute_count,json=attributeCount,proto3" json:"attribute_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeployEvent) Reset() {
*x = DeployEvent{}
mi := &file_galaxy_repository_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeployEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeployEvent) ProtoMessage() {}
func (x *DeployEvent) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeployEvent.ProtoReflect.Descriptor instead.
func (*DeployEvent) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{7}
}
func (x *DeployEvent) GetSequence() uint64 {
if x != nil {
return x.Sequence
}
return 0
}
func (x *DeployEvent) GetObservedAt() *timestamppb.Timestamp {
if x != nil {
return x.ObservedAt
}
return nil
}
func (x *DeployEvent) GetTimeOfLastDeploy() *timestamppb.Timestamp {
if x != nil {
return x.TimeOfLastDeploy
}
return nil
}
func (x *DeployEvent) GetTimeOfLastDeployPresent() bool {
if x != nil {
return x.TimeOfLastDeployPresent
}
return false
}
func (x *DeployEvent) GetObjectCount() int32 {
if x != nil {
return x.ObjectCount
}
return 0
}
func (x *DeployEvent) GetAttributeCount() int32 {
if x != nil {
return x.AttributeCount
}
return 0
}
type GalaxyObject struct {
state protoimpl.MessageState `protogen:"open.v1"`
GobjectId int32 `protobuf:"varint,1,opt,name=gobject_id,json=gobjectId,proto3" json:"gobject_id,omitempty"`
TagName string `protobuf:"bytes,2,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"`
ContainedName string `protobuf:"bytes,3,opt,name=contained_name,json=containedName,proto3" json:"contained_name,omitempty"`
BrowseName string `protobuf:"bytes,4,opt,name=browse_name,json=browseName,proto3" json:"browse_name,omitempty"`
ParentGobjectId int32 `protobuf:"varint,5,opt,name=parent_gobject_id,json=parentGobjectId,proto3" json:"parent_gobject_id,omitempty"`
IsArea bool `protobuf:"varint,6,opt,name=is_area,json=isArea,proto3" json:"is_area,omitempty"`
CategoryId int32 `protobuf:"varint,7,opt,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"`
HostedByGobjectId int32 `protobuf:"varint,8,opt,name=hosted_by_gobject_id,json=hostedByGobjectId,proto3" json:"hosted_by_gobject_id,omitempty"`
TemplateChain []string `protobuf:"bytes,9,rep,name=template_chain,json=templateChain,proto3" json:"template_chain,omitempty"`
Attributes []*GalaxyAttribute `protobuf:"bytes,10,rep,name=attributes,proto3" json:"attributes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GalaxyObject) Reset() {
*x = GalaxyObject{}
mi := &file_galaxy_repository_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GalaxyObject) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GalaxyObject) ProtoMessage() {}
func (x *GalaxyObject) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GalaxyObject.ProtoReflect.Descriptor instead.
func (*GalaxyObject) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{8}
}
func (x *GalaxyObject) GetGobjectId() int32 {
if x != nil {
return x.GobjectId
}
return 0
}
func (x *GalaxyObject) GetTagName() string {
if x != nil {
return x.TagName
}
return ""
}
func (x *GalaxyObject) GetContainedName() string {
if x != nil {
return x.ContainedName
}
return ""
}
func (x *GalaxyObject) GetBrowseName() string {
if x != nil {
return x.BrowseName
}
return ""
}
func (x *GalaxyObject) GetParentGobjectId() int32 {
if x != nil {
return x.ParentGobjectId
}
return 0
}
func (x *GalaxyObject) GetIsArea() bool {
if x != nil {
return x.IsArea
}
return false
}
func (x *GalaxyObject) GetCategoryId() int32 {
if x != nil {
return x.CategoryId
}
return 0
}
func (x *GalaxyObject) GetHostedByGobjectId() int32 {
if x != nil {
return x.HostedByGobjectId
}
return 0
}
func (x *GalaxyObject) GetTemplateChain() []string {
if x != nil {
return x.TemplateChain
}
return nil
}
func (x *GalaxyObject) GetAttributes() []*GalaxyAttribute {
if x != nil {
return x.Attributes
}
return nil
}
type GalaxyAttribute struct {
state protoimpl.MessageState `protogen:"open.v1"`
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GalaxyAttribute) Reset() {
*x = GalaxyAttribute{}
mi := &file_galaxy_repository_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GalaxyAttribute) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GalaxyAttribute) ProtoMessage() {}
func (x *GalaxyAttribute) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GalaxyAttribute.ProtoReflect.Descriptor instead.
func (*GalaxyAttribute) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{9}
}
func (x *GalaxyAttribute) GetAttributeName() string {
if x != nil {
return x.AttributeName
}
return ""
}
func (x *GalaxyAttribute) GetFullTagReference() string {
if x != nil {
return x.FullTagReference
}
return ""
}
func (x *GalaxyAttribute) GetMxDataType() int32 {
if x != nil {
return x.MxDataType
}
return 0
}
func (x *GalaxyAttribute) GetDataTypeName() string {
if x != nil {
return x.DataTypeName
}
return ""
}
func (x *GalaxyAttribute) GetIsArray() bool {
if x != nil {
return x.IsArray
}
return false
}
func (x *GalaxyAttribute) GetArrayDimension() int32 {
if x != nil {
return x.ArrayDimension
}
return 0
}
func (x *GalaxyAttribute) GetArrayDimensionPresent() bool {
if x != nil {
return x.ArrayDimensionPresent
}
return false
}
func (x *GalaxyAttribute) GetMxAttributeCategory() int32 {
if x != nil {
return x.MxAttributeCategory
}
return 0
}
func (x *GalaxyAttribute) GetSecurityClassification() int32 {
if x != nil {
return x.SecurityClassification
}
return 0
}
func (x *GalaxyAttribute) GetIsHistorized() bool {
if x != nil {
return x.IsHistorized
}
return false
}
func (x *GalaxyAttribute) GetIsAlarm() bool {
if x != nil {
return x.IsAlarm
}
return false
}
var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" +
"\n" +
"\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n" +
"\x15TestConnectionRequest\"%\n" +
"\x13TestConnectionReply\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\"\x1a\n" +
"\x18GetLastDeployTimeRequest\"}\n" +
"\x16GetLastDeployTimeReply\x12\x18\n" +
"\apresent\x18\x01 \x01(\bR\apresent\x12I\n" +
"\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"\xbb\x04\n" +
"\x18DiscoverHierarchyRequest\x12\x1b\n" +
"\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" +
"\n" +
"page_token\x18\x02 \x01(\tR\tpageToken\x12(\n" +
"\x0froot_gobject_id\x18\x03 \x01(\x05H\x00R\rrootGobjectId\x12$\n" +
"\rroot_tag_name\x18\x04 \x01(\tH\x00R\vrootTagName\x120\n" +
"\x13root_contained_path\x18\x05 \x01(\tH\x00R\x11rootContainedPath\x128\n" +
"\tmax_depth\x18\x06 \x01(\v2\x1b.google.protobuf.Int32ValueR\bmaxDepth\x12!\n" +
"\fcategory_ids\x18\a \x03(\x05R\vcategoryIds\x126\n" +
"\x17template_chain_contains\x18\b \x03(\tR\x15templateChainContains\x12\"\n" +
"\rtag_name_glob\x18\t \x01(\tR\vtagNameGlob\x122\n" +
"\x12include_attributes\x18\n" +
" \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
"\x12alarm_bearing_only\x18\v \x01(\bR\x10alarmBearingOnly\x12'\n" +
"\x0fhistorized_only\x18\f \x01(\bR\x0ehistorizedOnlyB\x06\n" +
"\x04rootB\x15\n" +
"\x13_include_attributes\"\xac\x01\n" +
"\x16DiscoverHierarchyReply\x12<\n" +
"\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\x12&\n" +
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12,\n" +
"\x12total_object_count\x18\x03 \x01(\x05R\x10totalObjectCount\"i\n" +
"\x18WatchDeployEventsRequest\x12M\n" +
"\x15last_seen_deploy_time\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x12lastSeenDeployTime\"\xbb\x02\n" +
"\vDeployEvent\x12\x1a\n" +
"\bsequence\x18\x01 \x01(\x04R\bsequence\x12;\n" +
"\vobserved_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"observedAt\x12I\n" +
"\x13time_of_last_deploy\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\x12<\n" +
"\x1btime_of_last_deploy_present\x18\x04 \x01(\bR\x17timeOfLastDeployPresent\x12!\n" +
"\fobject_count\x18\x05 \x01(\x05R\vobjectCount\x12'\n" +
"\x0fattribute_count\x18\x06 \x01(\x05R\x0eattributeCount\"\x95\x03\n" +
"\fGalaxyObject\x12\x1d\n" +
"\n" +
"gobject_id\x18\x01 \x01(\x05R\tgobjectId\x12\x19\n" +
"\btag_name\x18\x02 \x01(\tR\atagName\x12%\n" +
"\x0econtained_name\x18\x03 \x01(\tR\rcontainedName\x12\x1f\n" +
"\vbrowse_name\x18\x04 \x01(\tR\n" +
"browseName\x12*\n" +
"\x11parent_gobject_id\x18\x05 \x01(\x05R\x0fparentGobjectId\x12\x17\n" +
"\ais_area\x18\x06 \x01(\bR\x06isArea\x12\x1f\n" +
"\vcategory_id\x18\a \x01(\x05R\n" +
"categoryId\x12/\n" +
"\x14hosted_by_gobject_id\x18\b \x01(\x05R\x11hostedByGobjectId\x12%\n" +
"\x0etemplate_chain\x18\t \x03(\tR\rtemplateChain\x12E\n" +
"\n" +
"attributes\x18\n" +
" \x03(\v2%.galaxy_repository.v1.GalaxyAttributeR\n" +
"attributes\"\xd7\x03\n" +
"\x0fGalaxyAttribute\x12%\n" +
"\x0eattribute_name\x18\x01 \x01(\tR\rattributeName\x12,\n" +
"\x12full_tag_reference\x18\x02 \x01(\tR\x10fullTagReference\x12 \n" +
"\fmx_data_type\x18\x03 \x01(\x05R\n" +
"mxDataType\x12$\n" +
"\x0edata_type_name\x18\x04 \x01(\tR\fdataTypeName\x12\x19\n" +
"\bis_array\x18\x05 \x01(\bR\aisArray\x12'\n" +
"\x0farray_dimension\x18\x06 \x01(\x05R\x0earrayDimension\x126\n" +
"\x17array_dimension_present\x18\a \x01(\bR\x15arrayDimensionPresent\x122\n" +
"\x15mx_attribute_category\x18\b \x01(\x05R\x13mxAttributeCategory\x127\n" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
"\x10GalaxyRepository\x12h\n" +
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var (
file_galaxy_repository_proto_rawDescOnce sync.Once
file_galaxy_repository_proto_rawDescData []byte
)
func file_galaxy_repository_proto_rawDescGZIP() []byte {
file_galaxy_repository_proto_rawDescOnce.Do(func() {
file_galaxy_repository_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)))
})
return file_galaxy_repository_proto_rawDescData
}
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
(*GetLastDeployTimeRequest)(nil), // 2: galaxy_repository.v1.GetLastDeployTimeRequest
(*GetLastDeployTimeReply)(nil), // 3: galaxy_repository.v1.GetLastDeployTimeReply
(*DiscoverHierarchyRequest)(nil), // 4: galaxy_repository.v1.DiscoverHierarchyRequest
(*DiscoverHierarchyReply)(nil), // 5: galaxy_repository.v1.DiscoverHierarchyReply
(*WatchDeployEventsRequest)(nil), // 6: galaxy_repository.v1.WatchDeployEventsRequest
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
}
var file_galaxy_repository_proto_depIdxs = []int32{
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
11, // [11:15] is the sub-list for method output_type
7, // [7:11] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_galaxy_repository_proto_init() }
func file_galaxy_repository_proto_init() {
if File_galaxy_repository_proto != nil {
return
}
file_galaxy_repository_proto_msgTypes[4].OneofWrappers = []any{
(*DiscoverHierarchyRequest_RootGobjectId)(nil),
(*DiscoverHierarchyRequest_RootTagName)(nil),
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_galaxy_repository_proto_goTypes,
DependencyIndexes: file_galaxy_repository_proto_depIdxs,
MessageInfos: file_galaxy_repository_proto_msgTypes,
}.Build()
File_galaxy_repository_proto = out.File
file_galaxy_repository_proto_goTypes = nil
file_galaxy_repository_proto_depIdxs = nil
}
@@ -0,0 +1,261 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GalaxyRepository_TestConnection_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/TestConnection"
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
)
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryClient interface {
TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error)
GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
}
type galaxyRepositoryClient struct {
cc grpc.ClientConnInterface
}
func NewGalaxyRepositoryClient(cc grpc.ClientConnInterface) GalaxyRepositoryClient {
return &galaxyRepositoryClient{cc}
}
func (c *galaxyRepositoryClient) TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TestConnectionReply)
err := c.cc.Invoke(ctx, GalaxyRepository_TestConnection_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLastDeployTimeReply)
err := c.cc.Invoke(ctx, GalaxyRepository_GetLastDeployTime_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiscoverHierarchyReply)
err := c.cc.Invoke(ctx, GalaxyRepository_DiscoverHierarchy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &GalaxyRepository_ServiceDesc.Streams[0], GalaxyRepository_WatchDeployEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchDeployEventsRequest, DeployEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryServer interface {
TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error)
GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
mustEmbedUnimplementedGalaxyRepositoryServer()
}
// UnimplementedGalaxyRepositoryServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGalaxyRepositoryServer struct{}
func (UnimplementedGalaxyRepositoryServer) TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error) {
return nil, status.Error(codes.Unimplemented, "method TestConnection not implemented")
}
func (UnimplementedGalaxyRepositoryServer) GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error) {
return nil, status.Error(codes.Unimplemented, "method GetLastDeployTime not implemented")
}
func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error) {
return nil, status.Error(codes.Unimplemented, "method DiscoverHierarchy not implemented")
}
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
// UnsafeGalaxyRepositoryServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GalaxyRepositoryServer will
// result in compilation errors.
type UnsafeGalaxyRepositoryServer interface {
mustEmbedUnimplementedGalaxyRepositoryServer()
}
func RegisterGalaxyRepositoryServer(s grpc.ServiceRegistrar, srv GalaxyRepositoryServer) {
// If the following call panics, it indicates UnimplementedGalaxyRepositoryServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GalaxyRepository_ServiceDesc, srv)
}
func _GalaxyRepository_TestConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestConnectionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_TestConnection_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, req.(*TestConnectionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_GetLastDeployTime_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLastDeployTimeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_GetLastDeployTime_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, req.(*GetLastDeployTimeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_DiscoverHierarchy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DiscoverHierarchyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_DiscoverHierarchy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, req.(*DiscoverHierarchyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchDeployEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(GalaxyRepositoryServer).WatchDeployEvents(m, &grpc.GenericServerStream[WatchDeployEventsRequest, DeployEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
ServiceName: "galaxy_repository.v1.GalaxyRepository",
HandlerType: (*GalaxyRepositoryServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "TestConnection",
Handler: _GalaxyRepository_TestConnection_Handler,
},
{
MethodName: "GetLastDeployTime",
Handler: _GalaxyRepository_GetLastDeployTime_Handler,
},
{
MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchDeployEvents",
Handler: _GalaxyRepository_WatchDeployEvents_Handler,
ServerStreams: true,
},
},
Metadata: "galaxy_repository.proto",
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
package mxgateway
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const authorizationHeader = "authorization"
func unaryAuthInterceptor(apiKey string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
return invoker(authContext(ctx, apiKey), method, req, reply, cc, opts...)
}
}
func streamAuthInterceptor(apiKey string) grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return streamer(authContext(ctx, apiKey), desc, cc, method, opts...)
}
}
func authContext(ctx context.Context, apiKey string) context.Context {
if apiKey == "" {
return ctx
}
return metadata.AppendToOutgoingContext(ctx, authorizationHeader, "Bearer "+apiKey)
}
+251
View File
@@ -0,0 +1,251 @@
package mxgateway
import (
"context"
"crypto/tls"
"errors"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/durationpb"
)
const (
defaultDialTimeout = 10 * time.Second
defaultCallTimeout = 30 * time.Second
defaultMaxGrpcMessageBytes = 16 * 1024 * 1024
)
// Client owns a gateway gRPC connection and exposes session-oriented helpers.
type Client struct {
conn *grpc.ClientConn
raw pb.MxAccessGatewayClient
opts Options
}
// Dial opens a gRPC connection to the gateway and configures auth metadata,
// transport security, and blocking dial cancellation from ctx.
func Dial(ctx context.Context, opts Options) (*Client, error) {
if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil {
return nil, err
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(resolveMaxGrpcMessageBytes(opts)),
grpc.MaxCallSendMsgSize(resolveMaxGrpcMessageBytes(opts)),
),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewClient(conn, opts), nil
}
func resolveMaxGrpcMessageBytes(opts Options) int {
if opts.MaxGrpcMessageBytes > 0 {
return opts.MaxGrpcMessageBytes
}
return defaultMaxGrpcMessageBytes
}
// NewClient wraps an existing gRPC connection. The caller owns closing conn
// unless it calls Close on the returned Client.
func NewClient(conn *grpc.ClientConn, opts Options) *Client {
return &Client{
conn: conn,
raw: pb.NewMxAccessGatewayClient(conn),
opts: opts,
}
}
// RawClient returns the generated gRPC client for command-specific parity tests.
func (c *Client) RawClient() RawGatewayClient {
return c.raw
}
// OpenSession creates a gateway-backed MXAccess session.
func (c *Client) OpenSession(ctx context.Context, opts OpenSessionOptions) (*Session, error) {
reply, err := c.OpenSessionRaw(ctx, opts.Request())
if err != nil {
return nil, err
}
return newSession(c, reply), nil
}
// OpenSessionRaw sends a raw OpenSession request and validates protocol status.
func (c *Client) OpenSessionRaw(ctx context.Context, req *OpenSessionRequest) (*OpenSessionReply, error) {
if req == nil {
return nil, errors.New("mxgateway: open session request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.OpenSession(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "open session", Err: err}
}
if err := EnsureProtocolSuccess("open session", reply.GetProtocolStatus(), nil); err != nil {
return reply, err
}
return reply, nil
}
// Invoke sends a raw MXAccess command request and validates protocol and
// MXAccess status fields while preserving the raw reply on typed errors.
func (c *Client) Invoke(ctx context.Context, req *MxCommandRequest) (*MxCommandReply, error) {
if req == nil {
return nil, errors.New("mxgateway: command request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.Invoke(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "invoke", Err: err}
}
if err := EnsureProtocolSuccess("invoke", reply.GetProtocolStatus(), reply); err != nil {
return reply, err
}
if err := EnsureMxAccessSuccess("invoke", reply); err != nil {
return reply, err
}
return reply, nil
}
// CloseSessionRaw sends a raw CloseSession request and validates protocol
// status.
func (c *Client) CloseSessionRaw(ctx context.Context, req *CloseSessionRequest) (*CloseSessionReply, error) {
if req == nil {
return nil, errors.New("mxgateway: close session request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.CloseSession(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "close session", Err: err}
}
if err := EnsureProtocolSuccess("close session", reply.GetProtocolStatus(), nil); err != nil {
return reply, err
}
return reply, nil
}
// StreamEventsRaw starts the generated event stream for callers that need direct
// control over Recv.
func (c *Client) StreamEventsRaw(ctx context.Context, req *StreamEventsRequest) (RawEventStream, error) {
if req == nil {
return nil, errors.New("mxgateway: stream events request is required")
}
stream, err := c.raw.StreamEvents(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "stream events", Err: err}
}
return stream, nil
}
// Close closes the underlying gRPC connection.
func (c *Client) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
}
func resolveTransportCredentials(opts Options) (credentials.TransportCredentials, error) {
if opts.TransportCredentials != nil {
return opts.TransportCredentials, nil
}
if opts.Plaintext {
return insecure.NewCredentials(), nil
}
if opts.CACertFile != "" {
return credentials.NewClientTLSFromFile(opts.CACertFile, opts.ServerNameOverride)
}
if opts.TLSConfig != nil {
cfg := opts.TLSConfig.Clone()
if opts.ServerNameOverride != "" {
cfg.ServerName = opts.ServerNameOverride
}
return credentials.NewTLS(cfg), nil
}
return credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: opts.ServerNameOverride,
}), nil
}
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
type OpenSessionOptions struct {
RequestedBackend string
ClientSessionName string
ClientCorrelationID string
CommandTimeout time.Duration
}
// Request returns the raw protobuf OpenSessionRequest for these options.
func (o OpenSessionOptions) Request() *OpenSessionRequest {
req := &OpenSessionRequest{
RequestedBackend: o.RequestedBackend,
ClientSessionName: o.ClientSessionName,
ClientCorrelationId: o.ClientCorrelationID,
}
if o.CommandTimeout > 0 {
req.CommandTimeout = durationpb.New(o.CommandTimeout)
}
return req
}
+385
View File
@@ -0,0 +1,385 @@
package mxgateway
import (
"context"
"errors"
"io"
"net"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
func TestDialAttachesAuthMetadataToUnaryCalls(t *testing.T) {
fake := &fakeGatewayServer{
openReply: &pb.OpenSessionReply{
SessionId: "session-1",
GatewayProtocolVersion: GatewayProtocolVersion,
WorkerProtocolVersion: WorkerProtocolVersion,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
_, err := client.OpenSession(context.Background(), OpenSessionOptions{ClientSessionName: "fixture"})
if err != nil {
t.Fatalf("OpenSession() error = %v", err)
}
if got := fake.openAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
}
func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
ctx, cancel := context.WithCancel(context.Background())
events, err := session.Events(ctx)
if err != nil {
t.Fatalf("Events() error = %v", err)
}
<-fake.streamStarted
first := <-events
if first.Err != nil {
t.Fatalf("first event error = %v", first.Err)
}
if first.Event.GetWorkerSequence() != 1 {
t.Fatalf("worker sequence = %d, want 1", first.Event.GetWorkerSequence())
}
if got := fake.streamAuth; got != "Bearer test-api-key" {
t.Fatalf("stream authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
cancel()
select {
case _, ok := <-events:
if ok {
t.Fatal("events channel produced an extra item after cancellation")
}
case <-time.After(2 * time.Second):
t.Fatal("events channel did not close after cancellation")
}
}
func TestEventSubscriptionCloseStopsStream(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamDone: make(chan struct{}),
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
subscription, err := session.SubscribeEvents(context.Background())
if err != nil {
t.Fatalf("SubscribeEvents() error = %v", err)
}
<-fake.streamStarted
first := <-subscription.Events()
if first.Err != nil {
t.Fatalf("first event error = %v", first.Err)
}
subscription.Close()
select {
case <-fake.streamDone:
case <-time.After(2 * time.Second):
t.Fatal("event stream did not stop after subscription close")
}
select {
case _, ok := <-subscription.Events():
if ok {
t.Fatal("subscription channel remained open after close")
}
case <-time.After(2 * time.Second):
t.Fatal("subscription channel did not close")
}
}
func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamDone: make(chan struct{}),
streamEventCount: 64,
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
events, err := session.EventsAfter(context.Background(), 0)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
select {
case <-fake.streamDone:
case <-time.After(2 * time.Second):
t.Fatal("compatibility event stream did not stop after result channel filled")
}
for {
select {
case _, ok := <-events:
if !ok {
return
}
case <-time.After(2 * time.Second):
t.Fatal("compatibility event channel did not close")
}
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_AddItem2{
AddItem2: &pb.AddItem2Reply{ItemHandle: 42},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
itemHandle, err := session.AddItem2(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
if err != nil {
t.Fatalf("AddItem2() error = %v", err)
}
if itemHandle != 42 {
t.Fatalf("item handle = %d, want 42", itemHandle)
}
req := fake.invokeRequest
if req.GetSessionId() != "session-1" {
t.Fatalf("session id = %q, want session-1", req.GetSessionId())
}
if req.GetClientCorrelationId() == "" {
t.Fatal("client correlation id is empty")
}
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2 {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if req.GetCommand().GetAddItem2().GetItemContext() != "runtime" {
t.Fatalf("item context = %q, want runtime", req.GetCommand().GetAddItem2().GetItemContext())
}
}
func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_SubscribeBulk{
SubscribeBulk: &pb.BulkSubscribeReply{
Results: []*pb.SubscribeResult{
{
ServerHandle: 12,
TagAddress: "Area001.Pump001.Speed",
ItemHandle: 34,
WasSuccessful: true,
},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.SubscribeBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"})
if err != nil {
t.Fatalf("SubscribeBulk() error = %v", err)
}
if len(results) != 1 || results[0].GetItemHandle() != 34 {
t.Fatalf("results = %#v, want item handle 34", results)
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetSubscribeBulk().GetTagAddresses(); len(got) != 1 || got[0] != "Area001.Pump001.Speed" {
t.Fatalf("tag addresses = %#v", got)
}
}
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
hresult := int32(-2147467259)
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
Hresult: &hresult,
DiagnosticMessage: "native failure",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, Message: "MXAccess failed"},
Statuses: []*pb.MxStatusProxy{{Success: 0, DiagnosticText: "failed"}},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
err := session.Advise(context.Background(), 12, 34)
var mxErr *MxAccessError
if !errors.As(err, &mxErr) {
t.Fatalf("error %T does not support errors.As(*MxAccessError)", err)
}
if mxErr.Reply.GetHresult() != hresult {
t.Fatalf("raw reply HRESULT = %d, want %d", mxErr.Reply.GetHresult(), hresult)
}
var commandErr *CommandError
if !errors.As(err, &commandErr) {
t.Fatalf("error %T does not support errors.As(*CommandError)", err)
}
if commandErr.Reply.GetDiagnosticMessage() != "native failure" {
t.Fatalf("raw diagnostic = %q", commandErr.Reply.GetDiagnosticMessage())
}
}
func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterMxAccessGatewayServer(server, fake)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("bufconn server failed: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := Dial(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
grpc.WithContextDialer(dialer),
},
})
if err != nil {
t.Fatalf("Dial() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
type fakeGatewayServer struct {
pb.UnimplementedMxAccessGatewayServer
openReply *pb.OpenSessionReply
openAuth string
streamAuth string
streamStarted chan struct{}
streamDone chan struct{}
streamEventCount int
invokeReply *pb.MxCommandReply
invokeRequest *pb.MxCommandRequest
}
func (s *fakeGatewayServer) OpenSession(ctx context.Context, req *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
s.openAuth = authorizationFromContext(ctx)
if s.openReply != nil {
return s.openReply, nil
}
return &pb.OpenSessionReply{
SessionId: "session-1",
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayServer) CloseSession(ctx context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
return &pb.CloseSessionReply{
SessionId: req.GetSessionId(),
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
s.invokeRequest = req
if s.invokeReply != nil {
return s.invokeReply, nil
}
return &pb.MxCommandReply{
SessionId: req.GetSessionId(),
Kind: req.GetCommand().GetKind(),
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grpc.ServerStreamingServer[pb.MxEvent]) error {
s.streamAuth = authorizationFromContext(stream.Context())
if s.streamDone != nil {
defer close(s.streamDone)
}
if s.streamStarted != nil {
close(s.streamStarted)
}
eventCount := s.streamEventCount
if eventCount == 0 {
eventCount = 1
}
for sequence := 1; sequence <= eventCount; sequence++ {
if err := stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
WorkerSequence: uint64(sequence),
}); err != nil {
return err
}
}
<-stream.Context().Done()
return io.EOF
}
func authorizationFromContext(ctx context.Context) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ""
}
values := md.Get(authorizationHeader)
if len(values) == 0 {
return ""
}
return values[0]
}
+73
View File
@@ -0,0 +1,73 @@
package mxgateway
import (
"encoding/json"
"os"
"path/filepath"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/protobuf/encoding/protojson"
)
func TestValueConversionFixtures(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "values", "value-conversion-cases.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture struct {
Cases []struct {
ID string `json:"id"`
ExpectedKind string `json:"expectedKind"`
Value json.RawMessage `json:"value"`
} `json:"cases"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("parse fixture manifest: %v", err)
}
for _, tc := range fixture.Cases {
t.Run(tc.ID, func(t *testing.T) {
var value pb.MxValue
if err := protojson.Unmarshal(tc.Value, &value); err != nil {
t.Fatalf("parse value: %v", err)
}
if _, err := NativeValue(&value); err != nil {
t.Fatalf("NativeValue() error = %v", err)
}
if got := value.ProtoReflect().WhichOneof(value.ProtoReflect().Descriptor().Oneofs().ByName("kind")).JSONName(); got != tc.ExpectedKind {
t.Fatalf("kind = %q, want %q", got, tc.ExpectedKind)
}
})
}
}
func TestStatusConversionFixtures(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "statuses", "status-conversion-cases.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture struct {
Cases []struct {
ID string `json:"id"`
Status json.RawMessage `json:"status"`
} `json:"cases"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("parse fixture manifest: %v", err)
}
for _, tc := range fixture.Cases {
t.Run(tc.ID, func(t *testing.T) {
var status pb.MxStatusProxy
if err := protojson.Unmarshal(tc.Status, &status); err != nil {
t.Fatalf("parse status: %v", err)
}
if got, want := StatusSucceeded(&status), status.GetSuccess() != 0; got != want {
t.Fatalf("StatusSucceeded() = %v, want %v", got, want)
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package mxgateway
import (
"fmt"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
)
// GatewayError wraps transport-level gRPC failures.
type GatewayError struct {
Op string
Err error
}
func (e *GatewayError) Error() string {
if e == nil {
return ""
}
if e.Op == "" {
return fmt.Sprintf("mxgateway: %v", e.Err)
}
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
}
func (e *GatewayError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// CommandError reports a non-OK gateway protocol status and keeps the raw
// command reply when one exists.
type CommandError struct {
Op string
Status *ProtocolStatus
Reply *MxCommandReply
}
func (e *CommandError) Error() string {
if e == nil {
return ""
}
status := e.Status
if status == nil {
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
}
if status.GetMessage() == "" {
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
}
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
}
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
type MxAccessError struct {
Command *CommandError
Reply *MxCommandReply
}
func (e *MxAccessError) Error() string {
if e == nil {
return ""
}
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
return e.Command.Error()
}
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
}
if e.Reply != nil && e.Reply.Hresult != nil {
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
}
return "mxgateway: MXAccess command failed"
}
func (e *MxAccessError) Unwrap() error {
if e == nil {
return nil
}
return e.Command
}
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
return nil
}
commandError := &CommandError{
Op: op,
Status: status,
Reply: reply,
}
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
return &MxAccessError{
Command: commandError,
Reply: reply,
}
}
return commandError
}
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
// MXSTATUS_PROXY entries.
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
if reply == nil {
return nil
}
if reply.Hresult != nil && reply.GetHresult() != 0 {
return &MxAccessError{Reply: reply}
}
for _, status := range reply.GetStatuses() {
if !StatusSucceeded(status) {
return &MxAccessError{Reply: reply}
}
}
return nil
}
+270
View File
@@ -0,0 +1,270 @@
package mxgateway
import (
"context"
"errors"
"fmt"
"io"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
const discoverHierarchyPageSize = 5000
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract
// access.
type RawGalaxyRepositoryClient = pb.GalaxyRepositoryClient
// Generated protobuf aliases for Galaxy Repository messages.
type (
TestConnectionRequest = pb.TestConnectionRequest
TestConnectionReply = pb.TestConnectionReply
GetLastDeployTimeRequest = pb.GetLastDeployTimeRequest
GetLastDeployTimeReply = pb.GetLastDeployTimeReply
DiscoverHierarchyRequest = pb.DiscoverHierarchyRequest
DiscoverHierarchyReply = pb.DiscoverHierarchyReply
GalaxyObject = pb.GalaxyObject
GalaxyAttribute = pb.GalaxyAttribute
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
DeployEvent = pb.DeployEvent
)
// RawDeployEventStream is the generated WatchDeployEvents client stream.
type RawDeployEventStream = grpc.ServerStreamingClient[pb.DeployEvent]
// GalaxyClient owns a gateway gRPC connection and exposes Galaxy Repository
// browse helpers. It mirrors the structure of Client and uses the same
// connection-management conventions.
type GalaxyClient struct {
conn *grpc.ClientConn
raw pb.GalaxyRepositoryClient
opts Options
}
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
// service. It applies the same authentication metadata, transport security,
// and dial-timeout behavior as Dial.
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil {
return nil, err
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(resolveMaxGrpcMessageBytes(opts)),
grpc.MaxCallSendMsgSize(resolveMaxGrpcMessageBytes(opts)),
),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewGalaxyClient(conn, opts), nil
}
// NewGalaxyClient wraps an existing gRPC connection for Galaxy Repository
// access. The caller owns closing conn unless it calls Close on the returned
// GalaxyClient.
func NewGalaxyClient(conn *grpc.ClientConn, opts Options) *GalaxyClient {
return &GalaxyClient{
conn: conn,
raw: pb.NewGalaxyRepositoryClient(conn),
opts: opts,
}
}
// RawClient returns the generated gRPC client for command-specific parity
// tests.
func (c *GalaxyClient) RawClient() RawGalaxyRepositoryClient {
return c.raw
}
// TestConnection probes the Galaxy Repository service. It returns the server's
// reported ok flag and a non-nil error only when the RPC itself fails.
func (c *GalaxyClient) TestConnection(ctx context.Context) (bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.TestConnection(callCtx, &pb.TestConnectionRequest{})
if err != nil {
return false, &GatewayError{Op: "galaxy test connection", Err: err}
}
return reply.GetOk(), nil
}
// GetLastDeployTime returns the Galaxy's last deploy timestamp. When the server
// reports present=false (no deploy recorded yet) the call returns
// (time.Time{}, false, nil). When present=true the timestamp is returned in
// UTC with present=true.
func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.GetLastDeployTime(callCtx, &pb.GetLastDeployTimeRequest{})
if err != nil {
return time.Time{}, false, &GatewayError{Op: "galaxy get last deploy time", Err: err}
}
if !reply.GetPresent() {
return time.Time{}, false, nil
}
ts := reply.GetTimeOfLastDeploy()
if ts == nil {
return time.Time{}, false, nil
}
return ts.AsTime(), true, nil
}
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
// object's dynamic attributes. The objects are returned in the order supplied
// by the server.
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
var objects []*GalaxyObject
seenPageTokens := make(map[string]struct{})
pageToken := ""
for {
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
PageSize: discoverHierarchyPageSize,
PageToken: pageToken,
})
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
objects = append(objects, reply.GetObjects()...)
pageToken = reply.GetNextPageToken()
if pageToken == "" {
break
}
if _, seen := seenPageTokens[pageToken]; seen {
return nil, fmt.Errorf("mxgateway: galaxy discover hierarchy returned repeated page token %q", pageToken)
}
seenPageTokens[pageToken] = struct{}{}
}
return objects, nil
}
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
// that want direct control over Recv. The caller owns the returned stream's
// lifetime via ctx cancellation.
func (c *GalaxyClient) WatchDeployEventsRaw(ctx context.Context, req *WatchDeployEventsRequest) (RawDeployEventStream, error) {
if req == nil {
req = &pb.WatchDeployEventsRequest{}
}
stream, err := c.raw.WatchDeployEvents(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy watch deploy events", Err: err}
}
return stream, nil
}
// WatchDeployEvents subscribes to Galaxy deploy events. The server emits a
// bootstrap event with the current state immediately on subscribe, then one
// event per new deploy. When lastSeenDeployTime is non-nil it is forwarded to
// the server to suppress the bootstrap event.
//
// The returned event channel is closed when the server completes the stream
// (io.EOF), when ctx is cancelled, or after a terminal error has been
// delivered on the error channel. The error channel is also closed once the
// stream tears down. Surfaced errors are wrapped in *GatewayError.
//
// Cancel ctx to tear the stream down cleanly.
func (c *GalaxyClient) WatchDeployEvents(
ctx context.Context,
lastSeenDeployTime *time.Time,
) (<-chan *DeployEvent, <-chan error, error) {
req := &pb.WatchDeployEventsRequest{}
if lastSeenDeployTime != nil {
req.LastSeenDeployTime = timestamppb.New(*lastSeenDeployTime)
}
stream, err := c.WatchDeployEventsRaw(ctx, req)
if err != nil {
return nil, nil, err
}
events := make(chan *DeployEvent, 16)
errs := make(chan error, 1)
go func() {
defer close(events)
defer close(errs)
for {
event, recvErr := stream.Recv()
if recvErr == nil {
select {
case events <- event:
case <-ctx.Done():
return
}
continue
}
if recvErr == io.EOF {
return
}
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
return
}
select {
case errs <- &GatewayError{Op: "galaxy watch deploy events", Err: recvErr}:
case <-ctx.Done():
}
return
}
}()
return events, errs, nil
}
// Close closes the underlying gRPC connection.
func (c *GalaxyClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
}
+470
View File
@@ -0,0 +1,470 @@
package mxgateway
import (
"context"
"errors"
"net"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) {
fake := &fakeGalaxyServer{
testReply: &pb.TestConnectionReply{Ok: true},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ok, err := client.TestConnection(context.Background())
if err != nil {
t.Fatalf("TestConnection() error = %v", err)
}
if !ok {
t.Fatalf("TestConnection() ok = false, want true")
}
if got := fake.testAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: false},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{
Present: true,
TimeOfLastDeploy: timestamppb.New(want),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if !present {
t.Fatalf("present = false, want true")
}
if !got.Equal(want) {
t.Fatalf("time = %v, want %v", got, want)
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false (nil timestamp)")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
fake := &fakeGalaxyServer{
discoverReplies: []*pb.DiscoverHierarchyReply{{
NextPageToken: "page-2",
TotalObjectCount: 2,
Objects: []*pb.GalaxyObject{
{
GobjectId: 1,
TagName: "TestMachine_001",
ContainedName: "TestMachine_001",
BrowseName: "TestMachine_001",
IsArea: false,
CategoryId: 7,
TemplateChain: []string{"$Object", "$AppObject"},
Attributes: []*pb.GalaxyAttribute{
{
AttributeName: "DownloadPath",
FullTagReference: "TestMachine_001.DownloadPath",
MxDataType: 8,
DataTypeName: "String",
},
},
},
},
}, {
TotalObjectCount: 2,
Objects: []*pb.GalaxyObject{
{
GobjectId: 2,
TagName: "TestMachine_002",
ContainedName: "TestMachine_002",
ParentGobjectId: 1,
},
},
}},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objects, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy() error = %v", err)
}
if len(objects) != 2 {
t.Fatalf("len(objects) = %d, want 2", len(objects))
}
if len(fake.discoverRequests) != 2 {
t.Fatalf("len(discoverRequests) = %d, want 2", len(fake.discoverRequests))
}
if fake.discoverRequests[0].GetPageSize() != 5000 || fake.discoverRequests[0].GetPageToken() != "" {
t.Fatalf("first request = %+v", fake.discoverRequests[0])
}
if fake.discoverRequests[1].GetPageToken() != "page-2" {
t.Fatalf("second page_token = %q, want page-2", fake.discoverRequests[1].GetPageToken())
}
if objects[0].GetTagName() != "TestMachine_001" {
t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName())
}
if len(objects[0].GetAttributes()) != 1 {
t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes()))
}
if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" {
t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference())
}
}
func TestGalaxyDiscoverHierarchyRejectsRepeatedPageToken(t *testing.T) {
fake := &fakeGalaxyServer{
discoverReplies: []*pb.DiscoverHierarchyReply{
{NextPageToken: "7:1"},
{NextPageToken: "7:1"},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.DiscoverHierarchy(context.Background())
if err == nil {
t.Fatal("DiscoverHierarchy() error = nil, want repeated token error")
}
if !strings.Contains(err.Error(), "repeated page token") {
t.Fatalf("error = %v, want repeated page token", err)
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.TestConnection(context.Background())
if err == nil {
t.Fatal("TestConnection() error = nil, want error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error %T does not support errors.As(*GatewayError)", err)
}
if gwErr.Op != "galaxy test connection" {
t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection")
}
}
func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) {
bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC)
deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC)
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{
Sequence: 1,
ObservedAt: timestamppb.New(bootstrap),
TimeOfLastDeploy: timestamppb.New(deploy1),
TimeOfLastDeployPresent: true,
ObjectCount: 10,
AttributeCount: 42,
},
{
Sequence: 2,
ObservedAt: timestamppb.New(deploy2),
TimeOfLastDeploy: timestamppb.New(deploy2),
TimeOfLastDeployPresent: true,
ObjectCount: 11,
AttributeCount: 44,
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events, errs, err := client.WatchDeployEvents(ctx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
got := make([]*DeployEvent, 0, 2)
loop:
for {
select {
case ev, ok := <-events:
if !ok {
break loop
}
got = append(got, ev)
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout waiting for events; got %d", len(got))
}
}
if len(got) != 2 {
t.Fatalf("len(events) = %d, want 2", len(got))
}
if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 {
t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence())
}
if !got[0].GetTimeOfLastDeployPresent() {
t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true")
}
if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 {
t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount())
}
if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) {
t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1)
}
}
func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 7},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC)
events, errs, err := client.WatchDeployEvents(ctx, &lastSeen)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Drain everything.
loop:
for {
select {
case _, ok := <-events:
if !ok {
break loop
}
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout draining events")
}
}
if fake.watchRequest == nil {
t.Fatalf("server did not receive a request")
}
gotTs := fake.watchRequest.GetLastSeenDeployTime()
if gotTs == nil {
t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen)
}
if !gotTs.AsTime().Equal(lastSeen) {
t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen)
}
}
func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 1},
},
watchHoldOpen: true,
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
streamCtx, cancelStream := context.WithCancel(context.Background())
events, errs, err := client.WatchDeployEvents(streamCtx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Wait for the bootstrap event to arrive.
select {
case ev, ok := <-events:
if !ok {
t.Fatalf("events channel closed before delivering bootstrap")
}
if ev.GetSequence() != 1 {
t.Fatalf("got seq=%d, want 1", ev.GetSequence())
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for bootstrap event")
}
// Cancel the stream; both channels must close cleanly without delivering an error.
cancelStream()
deadline := time.After(2 * time.Second)
for events != nil || errs != nil {
select {
case _, ok := <-events:
if !ok {
events = nil
}
case errVal, ok := <-errs:
if !ok {
errs = nil
continue
}
if errVal != nil {
t.Fatalf("error after cancel: %v", errVal)
}
case <-deadline:
t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil)
}
}
}
func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterGalaxyRepositoryServer(server, fake)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("bufconn server failed: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := DialGalaxy(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
grpc.WithContextDialer(dialer),
},
})
if err != nil {
t.Fatalf("DialGalaxy() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
discoverReplies []*pb.DiscoverHierarchyReply
discoverRequests []*pb.DiscoverHierarchyRequest
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
s.testAuth = authorizationFromContext(ctx)
if s.failTest {
return nil, errors.New("simulated failure")
}
if s.testReply != nil {
return s.testReply, nil
}
return &pb.TestConnectionReply{Ok: true}, nil
}
func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) {
if s.deployReply != nil {
return s.deployReply, nil
}
return &pb.GetLastDeployTimeReply{Present: false}, nil
}
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
s.discoverRequests = append(s.discoverRequests, req)
if len(s.discoverReplies) > 0 {
reply := s.discoverReplies[0]
s.discoverReplies = s.discoverReplies[1:]
return reply, nil
}
if s.discoverReply != nil {
return s.discoverReply, nil
}
return &pb.DiscoverHierarchyReply{}, nil
}
func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error {
s.watchRequest = req
for _, event := range s.watchEvents {
if err := stream.Send(event); err != nil {
return err
}
if s.watchSendInterval > 0 {
select {
case <-time.After(s.watchSendInterval):
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
if s.watchHoldOpen {
<-stream.Context().Done()
}
return nil
}
+20 -7
View File
@@ -1,14 +1,27 @@
package mxgateway
import "strings"
import (
"crypto/tls"
"strings"
"time"
// Options configures future gateway connections.
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Options configures gateway connections.
type Options struct {
Endpoint string
APIKey string
Plaintext bool
CACertFile string
ServerNameOverride string
Endpoint string
APIKey string
Plaintext bool
CACertFile string
ServerNameOverride string
DialTimeout time.Duration
CallTimeout time.Duration
MaxGrpcMessageBytes int
TLSConfig *tls.Config
TransportCredentials credentials.TransportCredentials
DialOptions []grpc.DialOption
}
// RedactedAPIKey returns a display-safe representation of the configured API
+530
View File
@@ -0,0 +1,530 @@
package mxgateway
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"sync"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const maxBulkItems = 1000
// EventResult carries either the next ordered event or a terminal stream error.
type EventResult struct {
Event *MxEvent
Err error
}
// EventSubscription owns a running gateway event stream.
type EventSubscription struct {
results <-chan EventResult
cancel context.CancelFunc
done <-chan struct{}
once sync.Once
}
// Events returns the stream results channel.
func (s *EventSubscription) Events() <-chan EventResult {
return s.results
}
// Close cancels the stream and waits for the receive goroutine to stop.
func (s *EventSubscription) Close() {
if s == nil {
return
}
s.once.Do(func() {
s.cancel()
<-s.done
})
}
// Session represents one gateway-backed MXAccess session.
type Session struct {
client *Client
openReply *OpenSessionReply
closeMu sync.Mutex
closeReply *CloseSessionReply
}
func newSession(client *Client, openReply *OpenSessionReply) *Session {
return &Session{
client: client,
openReply: openReply,
}
}
// NewSessionForID creates a session wrapper for commands against an existing
// gateway session id.
func NewSessionForID(client *Client, sessionID string) *Session {
return newSession(client, &pb.OpenSessionReply{SessionId: sessionID})
}
// ID returns the gateway session identifier.
func (s *Session) ID() string {
return s.openReply.GetSessionId()
}
// OpenReply returns the raw OpenSession reply.
func (s *Session) OpenReply() *OpenSessionReply {
return s.openReply
}
// Close closes the gateway session once and returns the raw close reply.
func (s *Session) Close(ctx context.Context) (*CloseSessionReply, error) {
s.closeMu.Lock()
defer s.closeMu.Unlock()
if s.closeReply != nil {
return s.closeReply, nil
}
reply, err := s.client.CloseSessionRaw(ctx, &pb.CloseSessionRequest{SessionId: s.ID()})
if err != nil {
return reply, err
}
s.closeReply = reply
return reply, nil
}
// Register invokes MXAccess Register and returns the server handle.
func (s *Session) Register(ctx context.Context, clientName string) (int32, error) {
reply, err := s.RegisterRaw(ctx, clientName)
if err != nil {
return 0, err
}
if reply.GetRegister() != nil {
return reply.GetRegister().GetServerHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// RegisterRaw invokes MXAccess Register and returns the raw reply.
func (s *Session) RegisterRaw(ctx context.Context, clientName string) (*MxCommandReply, error) {
if clientName == "" {
return nil, errors.New("mxgateway: client name is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REGISTER,
Payload: &pb.MxCommand_Register{
Register: &pb.RegisterCommand{ClientName: clientName},
},
})
}
// Unregister invokes MXAccess Unregister.
func (s *Session) Unregister(ctx context.Context, serverHandle int32) error {
_, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER,
Payload: &pb.MxCommand_Unregister{
Unregister: &pb.UnregisterCommand{ServerHandle: serverHandle},
},
})
return err
}
// RemoveItem invokes MXAccess RemoveItem.
func (s *Session) RemoveItem(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.RemoveItemRaw(ctx, serverHandle, itemHandle)
return err
}
// RemoveItemRaw invokes MXAccess RemoveItem and returns the raw reply.
func (s *Session) RemoveItemRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM,
Payload: &pb.MxCommand_RemoveItem{
RemoveItem: &pb.RemoveItemCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// AddItem invokes MXAccess AddItem and returns the item handle.
func (s *Session) AddItem(ctx context.Context, serverHandle int32, itemDefinition string) (int32, error) {
reply, err := s.AddItemRaw(ctx, serverHandle, itemDefinition)
if err != nil {
return 0, err
}
if reply.GetAddItem() != nil {
return reply.GetAddItem().GetItemHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// AddItemRaw invokes MXAccess AddItem and returns the raw reply.
func (s *Session) AddItemRaw(ctx context.Context, serverHandle int32, itemDefinition string) (*MxCommandReply, error) {
if itemDefinition == "" {
return nil, errors.New("mxgateway: item definition is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM,
Payload: &pb.MxCommand_AddItem{
AddItem: &pb.AddItemCommand{
ServerHandle: serverHandle,
ItemDefinition: itemDefinition,
},
},
})
}
// AddItem2 invokes MXAccess AddItem2 and returns the item handle.
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
reply, err := s.AddItem2Raw(ctx, serverHandle, itemDefinition, itemContext)
if err != nil {
return 0, err
}
if reply.GetAddItem2() != nil {
return reply.GetAddItem2().GetItemHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// AddItem2Raw invokes MXAccess AddItem2 and returns the raw reply.
func (s *Session) AddItem2Raw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
if itemDefinition == "" {
return nil, errors.New("mxgateway: item definition is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
Payload: &pb.MxCommand_AddItem2{
AddItem2: &pb.AddItem2Command{
ServerHandle: serverHandle,
ItemDefinition: itemDefinition,
ItemContext: itemContext,
},
},
})
}
// Advise invokes MXAccess Advise.
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.AdviseRaw(ctx, serverHandle, itemHandle)
return err
}
// AdviseRaw invokes MXAccess Advise and returns the raw reply.
func (s *Session) AdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
Payload: &pb.MxCommand_Advise{
Advise: &pb.AdviseCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// UnAdvise invokes MXAccess UnAdvise.
func (s *Session) UnAdvise(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.UnAdviseRaw(ctx, serverHandle, itemHandle)
return err
}
// UnAdviseRaw invokes MXAccess UnAdvise and returns the raw reply.
func (s *Session) UnAdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE,
Payload: &pb.MxCommand_UnAdvise{
UnAdvise: &pb.UnAdviseCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// AddItemBulk invokes MXAccess AddItem for each tag inside one gateway command.
func (s *Session) AddItemBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*SubscribeResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
}
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM_BULK,
Payload: &pb.MxCommand_AddItemBulk{
AddItemBulk: &pb.AddItemBulkCommand{
ServerHandle: serverHandle,
TagAddresses: tagAddresses,
},
},
})
if err != nil {
return nil, err
}
return reply.GetAddItemBulk().GetResults(), nil
}
// AdviseItemBulk invokes MXAccess Advise for each item handle inside one gateway command.
func (s *Session) AdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_ITEM_BULK,
Payload: &pb.MxCommand_AdviseItemBulk{
AdviseItemBulk: &pb.AdviseItemBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetAdviseItemBulk().GetResults(), nil
}
// RemoveItemBulk invokes MXAccess RemoveItem for each item handle inside one gateway command.
func (s *Session) RemoveItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM_BULK,
Payload: &pb.MxCommand_RemoveItemBulk{
RemoveItemBulk: &pb.RemoveItemBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetRemoveItemBulk().GetResults(), nil
}
// UnAdviseItemBulk invokes MXAccess UnAdvise for each item handle inside one gateway command.
func (s *Session) UnAdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK,
Payload: &pb.MxCommand_UnAdviseItemBulk{
UnAdviseItemBulk: &pb.UnAdviseItemBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetUnAdviseItemBulk().GetResults(), nil
}
// SubscribeBulk invokes AddItem and Advise for each tag inside one gateway command.
func (s *Session) SubscribeBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*SubscribeResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
}
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK,
Payload: &pb.MxCommand_SubscribeBulk{
SubscribeBulk: &pb.SubscribeBulkCommand{
ServerHandle: serverHandle,
TagAddresses: tagAddresses,
},
},
})
if err != nil {
return nil, err
}
return reply.GetSubscribeBulk().GetResults(), nil
}
// UnsubscribeBulk invokes UnAdvise and RemoveItem for each item handle inside one gateway command.
func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK,
Payload: &pb.MxCommand_UnsubscribeBulk{
UnsubscribeBulk: &pb.UnsubscribeBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetUnsubscribeBulk().GetResults(), nil
}
// Write invokes MXAccess Write.
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
return err
}
// WriteRaw invokes MXAccess Write and returns the raw reply.
func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) (*MxCommandReply, error) {
if value == nil {
return nil, errors.New("mxgateway: write value is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE,
Payload: &pb.MxCommand_Write{
Write: &pb.WriteCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
Value: value,
UserId: userID,
},
},
})
}
// Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent.
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
return s.EventsAfter(ctx, 0)
}
// EventsAfter streams ordered session events after the given worker sequence.
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
subscription, err := s.subscribeEventsAfter(ctx, afterWorkerSequence, true)
if err != nil {
return nil, err
}
return subscription.Events(), nil
}
// SubscribeEvents starts an owned event subscription.
func (s *Session) SubscribeEvents(ctx context.Context) (*EventSubscription, error) {
return s.SubscribeEventsAfter(ctx, 0)
}
// SubscribeEventsAfter starts an owned event subscription after the given worker sequence.
func (s *Session) SubscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64) (*EventSubscription, error) {
return s.subscribeEventsAfter(ctx, afterWorkerSequence, false)
}
func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64, cancelWhenResultBufferFull bool) (*EventSubscription, error) {
streamCtx, cancel := context.WithCancel(ctx)
stream, err := s.client.StreamEventsRaw(streamCtx, &pb.StreamEventsRequest{
SessionId: s.ID(),
AfterWorkerSequence: afterWorkerSequence,
})
if err != nil {
cancel()
return nil, err
}
results := make(chan EventResult, 16)
done := make(chan struct{})
go func() {
defer close(results)
defer close(done)
for {
event, err := stream.Recv()
if err == nil {
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
return
}
continue
}
if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
return
}
sendEventResult(
streamCtx,
results,
EventResult{Err: &GatewayError{Op: "stream events", Err: err}},
cancelWhenResultBufferFull,
cancel)
return
}
}()
return &EventSubscription{
results: results,
cancel: cancel,
done: done,
}, nil
}
func ensureBulkSize(name string, length int) error {
if length > maxBulkItems {
return fmt.Errorf("mxgateway: %s bulk commands are limited to %d item(s)", name, maxBulkItems)
}
return nil
}
func sendEventResult(
ctx context.Context,
results chan<- EventResult,
result EventResult,
cancelWhenBufferFull bool,
cancel context.CancelFunc,
) bool {
if cancelWhenBufferFull {
select {
case results <- result:
return true
case <-ctx.Done():
return false
default:
cancel()
return false
}
}
select {
case results <- result:
return true
case <-ctx.Done():
return false
}
}
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
return s.client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: s.ID(),
ClientCorrelationId: newCorrelationID(),
Command: command,
})
}
func newCorrelationID() string {
var buffer [16]byte
if _, err := rand.Read(buffer[:]); err != nil {
return ""
}
return hex.EncodeToString(buffer[:])
}
+6
View File
@@ -0,0 +1,6 @@
package mxgateway
// StatusSucceeded reports whether an MXSTATUS_PROXY entry represents success.
func StatusSucceeded(status *MxStatusProxy) bool {
return status == nil || status.GetSuccess() != 0
}
+88
View File
@@ -0,0 +1,88 @@
package mxgateway
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
// RawGatewayClient is the generated gRPC client interface exposed for callers
// that need direct contract access.
type RawGatewayClient = pb.MxAccessGatewayClient
// RawEventStream is the generated StreamEvents client stream.
type RawEventStream = pb.MxAccessGateway_StreamEventsClient
// Generated protobuf aliases keep raw contract access available from the public
// mxgateway package while generated code remains under internal/generated.
type (
OpenSessionRequest = pb.OpenSessionRequest
OpenSessionReply = pb.OpenSessionReply
CloseSessionRequest = pb.CloseSessionRequest
CloseSessionReply = pb.CloseSessionReply
StreamEventsRequest = pb.StreamEventsRequest
MxCommandRequest = pb.MxCommandRequest
MxCommandReply = pb.MxCommandReply
MxCommand = pb.MxCommand
MxEvent = pb.MxEvent
MxValue = pb.MxValue
Value = pb.MxValue
MxArray = pb.MxArray
MxStatusProxy = pb.MxStatusProxy
ProtocolStatus = pb.ProtocolStatus
RegisterCommand = pb.RegisterCommand
UnregisterCommand = pb.UnregisterCommand
AddItemCommand = pb.AddItemCommand
AddItem2Command = pb.AddItem2Command
RemoveItemCommand = pb.RemoveItemCommand
AdviseCommand = pb.AdviseCommand
UnAdviseCommand = pb.UnAdviseCommand
AddItemBulkCommand = pb.AddItemBulkCommand
AdviseItemBulkCommand = pb.AdviseItemBulkCommand
RemoveItemBulkCommand = pb.RemoveItemBulkCommand
UnAdviseItemBulkCommand = pb.UnAdviseItemBulkCommand
SubscribeBulkCommand = pb.SubscribeBulkCommand
UnsubscribeBulkCommand = pb.UnsubscribeBulkCommand
WriteCommand = pb.WriteCommand
Write2Command = pb.Write2Command
RegisterReply = pb.RegisterReply
AddItemReply = pb.AddItemReply
AddItem2Reply = pb.AddItem2Reply
SubscribeResult = pb.SubscribeResult
BulkSubscribeReply = pb.BulkSubscribeReply
)
type (
MxCommandKind = pb.MxCommandKind
MxDataType = pb.MxDataType
MxEventFamily = pb.MxEventFamily
MxStatusCategory = pb.MxStatusCategory
MxStatusSource = pb.MxStatusSource
ProtocolStatusCode = pb.ProtocolStatusCode
SessionState = pb.SessionState
)
const (
CommandKindRegister = pb.MxCommandKind_MX_COMMAND_KIND_REGISTER
CommandKindUnregister = pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER
CommandKindAddItem = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM
CommandKindAddItem2 = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2
CommandKindRemoveItem = pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM
CommandKindAdvise = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE
CommandKindUnAdvise = pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE
CommandKindAddItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM_BULK
CommandKindAdviseItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_ITEM_BULK
CommandKindRemoveItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM_BULK
CommandKindUnAdviseItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK
CommandKindSubscribeBulk = pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK
CommandKindUnsubscribeBulk = pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
DataTypeBoolean = pb.MxDataType_MX_DATA_TYPE_BOOLEAN
DataTypeInteger = pb.MxDataType_MX_DATA_TYPE_INTEGER
DataTypeFloat = pb.MxDataType_MX_DATA_TYPE_FLOAT
DataTypeDouble = pb.MxDataType_MX_DATA_TYPE_DOUBLE
DataTypeString = pb.MxDataType_MX_DATA_TYPE_STRING
DataTypeTime = pb.MxDataType_MX_DATA_TYPE_TIME
ProtocolStatusOK = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK
ProtocolStatusMxAccessFailure = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE
)
+148
View File
@@ -0,0 +1,148 @@
package mxgateway
import (
"fmt"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/protobuf/types/known/timestamppb"
)
// BoolValue builds an MXAccess Boolean value.
func BoolValue(value bool) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_BOOLEAN,
VariantType: "VT_BOOL",
Kind: &pb.MxValue_BoolValue{BoolValue: value},
}
}
// Int32Value builds an MXAccess Int32 value.
func Int32Value(value int32) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
VariantType: "VT_I4",
Kind: &pb.MxValue_Int32Value{Int32Value: value},
}
}
// Int64Value builds an MXAccess Int64 value.
func Int64Value(value int64) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
VariantType: "VT_I8",
Kind: &pb.MxValue_Int64Value{Int64Value: value},
}
}
// FloatValue builds an MXAccess Float value.
func FloatValue(value float32) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_FLOAT,
VariantType: "VT_R4",
Kind: &pb.MxValue_FloatValue{FloatValue: value},
}
}
// DoubleValue builds an MXAccess Double value.
func DoubleValue(value float64) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_DOUBLE,
VariantType: "VT_R8",
Kind: &pb.MxValue_DoubleValue{DoubleValue: value},
}
}
// StringValue builds an MXAccess String value.
func StringValue(value string) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_STRING,
VariantType: "VT_BSTR",
Kind: &pb.MxValue_StringValue{StringValue: value},
}
}
// TimestampValue builds an MXAccess timestamp value from a Go time.
func TimestampValue(value time.Time) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_TIME,
VariantType: "VT_DATE",
Kind: &pb.MxValue_TimestampValue{TimestampValue: timestamppb.New(value)},
}
}
// NativeValue converts a protobuf MxValue to the closest Go representation
// without discarding raw fallback data.
func NativeValue(value *MxValue) (any, error) {
if value == nil || value.GetIsNull() {
return nil, nil
}
switch kind := value.GetKind().(type) {
case *pb.MxValue_BoolValue:
return kind.BoolValue, nil
case *pb.MxValue_Int32Value:
return kind.Int32Value, nil
case *pb.MxValue_Int64Value:
return kind.Int64Value, nil
case *pb.MxValue_FloatValue:
return kind.FloatValue, nil
case *pb.MxValue_DoubleValue:
return kind.DoubleValue, nil
case *pb.MxValue_StringValue:
return kind.StringValue, nil
case *pb.MxValue_TimestampValue:
if kind.TimestampValue == nil {
return nil, nil
}
return kind.TimestampValue.AsTime(), nil
case *pb.MxValue_ArrayValue:
return NativeArray(kind.ArrayValue)
case *pb.MxValue_RawValue:
return append([]byte(nil), kind.RawValue...), nil
default:
return nil, fmt.Errorf("mxgateway: unsupported value kind %T", kind)
}
}
// NativeArray converts a protobuf MxArray to the closest Go slice
// representation.
func NativeArray(array *MxArray) (any, error) {
if array == nil {
return nil, nil
}
switch values := array.GetValues().(type) {
case *pb.MxArray_BoolValues:
return append([]bool(nil), values.BoolValues.GetValues()...), nil
case *pb.MxArray_Int32Values:
return append([]int32(nil), values.Int32Values.GetValues()...), nil
case *pb.MxArray_Int64Values:
return append([]int64(nil), values.Int64Values.GetValues()...), nil
case *pb.MxArray_FloatValues:
return append([]float32(nil), values.FloatValues.GetValues()...), nil
case *pb.MxArray_DoubleValues:
return append([]float64(nil), values.DoubleValues.GetValues()...), nil
case *pb.MxArray_StringValues:
return append([]string(nil), values.StringValues.GetValues()...), nil
case *pb.MxArray_TimestampValues:
result := make([]time.Time, 0, len(values.TimestampValues.GetValues()))
for _, value := range values.TimestampValues.GetValues() {
if value == nil {
result = append(result, time.Time{})
continue
}
result = append(result, value.AsTime())
}
return result, nil
case *pb.MxArray_RawValues:
rawValues := values.RawValues.GetValues()
result := make([][]byte, 0, len(rawValues))
for _, value := range rawValues {
result = append(result, append([]byte(nil), value...))
}
return result, nil
default:
return nil, fmt.Errorf("mxgateway: unsupported array value kind %T", values)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ const (
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
// in the shared .NET contracts.
GatewayProtocolVersion uint32 = 1
GatewayProtocolVersion uint32 = 2
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
// and is exposed for fake-worker and parity tests.
+228
View File
@@ -0,0 +1,228 @@
# Java Client
The Java client workspace contains the MXAccess Gateway client library,
generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
## Layout
```text
clients/java/
settings.gradle
build.gradle
src/main/generated/
mxgateway-client/
mxgateway-cli/
```
`mxgateway-client` generates Java protobuf and gRPC sources from
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
generated sources under `src/main/generated`, which matches the client proto
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests.
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
application entry point. The CLI supports version, session, command, event
streaming, write, and smoke-test commands with deterministic JSON output.
## Regenerating Protobuf Bindings
Run generation from `clients/java` after the shared `.proto` files or Java
output path changes:
```powershell
gradle :mxgateway-client:generateProto
```
## Client Usage
Create a client with explicit transport and auth options:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (MxGatewayClient client = MxGatewayClient.connect(options);
MxGatewaySession session = client.openSession("java-client")) {
int serverHandle = session.register("java-client");
int itemHandle = session.addItem(serverHandle, "TestObject.TestInt");
session.advise(serverHandle, itemHandle);
session.write(serverHandle, itemHandle, MxValues.int32Value(123), 0);
}
```
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
underlying protobuf messages. `MxGatewayCommandException` and
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
data-bearing MXAccess failure.
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA.
## Galaxy Repository Browse
The Galaxy Repository service is a separate metadata-only gRPC service exposed
by the gateway. It lets clients enumerate the deployed Galaxy object hierarchy
and the dynamic attributes on each object so they know which tag references to
subscribe to via the MXAccess Gateway service. It uses the same API-key auth as
the gateway and requires the `metadata:read` scope.
`GalaxyRepositoryClient` mirrors the `MxGatewayClient` pattern (caller-managed
or owned channel, `MxGatewayClientOptions`, blocking + async variants). Three
RPCs are exposed:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
boolean ok = galaxy.testConnection();
Optional<Instant> lastDeploy = galaxy.getLastDeployTime();
List<GalaxyObject> hierarchy = galaxy.discoverHierarchy();
}
```
`getLastDeployTime` returns `Optional.empty()` when the server reports
`present=false`. `discoverHierarchy` returns the generated `GalaxyObject` proto
messages directly so callers can read all fields (including the nested
`GalaxyAttribute` list) without an extra DTO layer.
The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`,
`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`,
`--timeout`, and `--json` options as the gateway commands.
```powershell
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
sends a bootstrap `DeployEvent` immediately on subscribe and then one event
each time it observes a new `galaxy.time_of_last_deploy`. The `sequence` field
is monotonic per server start; gaps mean the per-subscriber buffer dropped
older events because the consumer was too slow.
The client exposes both an iterator-style adaptor over the async stub and an
observer-callback variant. Both honour the channel-level `streamTimeout`.
```java
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options);
DeployEventStream events = galaxy.watchDeployEvents(/* lastSeenDeployTime */ null)) {
while (events.hasNext()) {
DeployEvent event = events.next();
// event.getSequence(), event.getObservedAt(),
// event.getTimeOfLastDeploy() / getTimeOfLastDeployPresent(),
// event.getObjectCount(), event.getAttributeCount()
}
}
```
Pass an `Instant` for `lastSeenDeployTime` to suppress the bootstrap event when
the cached deploy time matches what the caller already has. `DeployEventStream`
implements `Iterator<DeployEvent>` and `AutoCloseable`; closing it cancels the
underlying gRPC call.
For callback delivery (e.g. when the consumer wants to drive a queue or
reactive pipeline), use the async variant:
```java
DeployEventSubscription subscription = galaxy.watchDeployEventsAsync(
lastSeen,
new StreamObserver<>() {
@Override public void onNext(DeployEvent value) { /* ... */ }
@Override public void onError(Throwable t) { /* ... */ }
@Override public void onCompleted() { /* ... */ }
});
// later:
subscription.cancel(); // or subscription.close()
```
The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with `--json`:
```powershell
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
```
## CLI Usage
Run the CLI through Gradle:
```powershell
gradle :mxgateway-cli:run --args="version --json"
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
```
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
`--server-name-override`, `--timeout`, and `--json` on gateway commands. JSON
output redacts API keys.
Use TLS options for a secured gateway:
```powershell
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
```
## Build And Test
Run the Java checks from `clients/java`:
```powershell
gradle test
```
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
## Packaging
Create local library and CLI artifacts from `clients/java`:
```powershell
gradle :mxgateway-client:jar :mxgateway-cli:installDist
```
The library jar is under `mxgateway-client/build/libs`. The installed CLI
distribution is under `mxgateway-cli/build/install/mxgateway-cli`.
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
```
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/client-proto-generation.md)
- [Java Client Detailed Design](../../docs/clients-java-design.md)
- [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md)
+40
View File
@@ -0,0 +1,40 @@
plugins {
id 'base'
}
ext {
guavaVersion = '33.5.0-jre'
gsonVersion = '2.13.2'
grpcVersion = '1.76.0'
junitVersion = '5.14.1'
picocliVersion = '4.7.7'
protobufVersion = '4.33.1'
}
subprojects {
group = 'com.dohertylan.mxgateway'
version = '0.1.0'
pluginManager.withPlugin('java') {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 21
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
dependencies {
testImplementation platform("org.junit:junit-bom:${junitVersion}")
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
}
}
+13
View File
@@ -0,0 +1,13 @@
plugins {
id 'application'
}
dependencies {
implementation project(':mxgateway-client')
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "info.picocli:picocli:${picocliVersion}"
}
application {
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli'
}
@@ -0,0 +1,961 @@
package com.dohertylan.mxgateway.cli;
import com.dohertylan.mxgateway.client.DeployEventStream;
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
import com.dohertylan.mxgateway.client.MxEventStream;
import com.dohertylan.mxgateway.client.MxGatewayClient;
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
import com.dohertylan.mxgateway.client.MxGatewaySecrets;
import com.dohertylan.mxgateway.client.MxGatewaySession;
import com.dohertylan.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;
@Command(
name = "mxgw-java",
mixinStandardHelpOptions = true,
description = "MXAccess Gateway Java test CLI.")
public final class MxGatewayCli implements Callable<Integer> {
private final MxGatewayCliClientFactory clientFactory;
@Spec
private CommandSpec spec;
public MxGatewayCli() {
this(new GrpcMxGatewayCliClientFactory());
}
MxGatewayCli(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
public static void main(String[] args) {
int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
System.exit(exitCode);
}
public static int execute(PrintWriter out, PrintWriter err, String... args) {
return execute(new GrpcMxGatewayCliClientFactory(), out, err, args);
}
static int execute(MxGatewayCliClientFactory clientFactory, PrintWriter out, PrintWriter err, String... args) {
CommandLine commandLine = commandLine(clientFactory);
commandLine.setOut(out);
commandLine.setErr(err);
return commandLine.execute(args);
}
@Override
public Integer call() {
spec.commandLine().usage(spec.commandLine().getOut());
return 0;
}
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
commandLine.addSubcommand("version", new VersionCommand());
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory));
commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
return commandLine;
}
abstract static class GalaxyCommand implements Callable<Integer> {
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GalaxyRepositoryClient connect() {
return GalaxyRepositoryClient.connect(common.resolved().toClientOptions());
}
}
@Command(name = "galaxy-test", description = "Calls GalaxyRepository.TestConnection.")
static final class GalaxyTestConnectionCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
boolean ok = client.testConnection();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-test");
output.put("options", common.redactedJsonMap());
output.put("ok", ok);
out.println(jsonObject(output));
} else {
out.println(ok);
}
}
return 0;
}
}
@Command(name = "galaxy-deploy-time", description = "Calls GalaxyRepository.GetLastDeployTime.")
static final class GalaxyDeployTimeCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
Optional<Instant> result = client.getLastDeployTime();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-deploy-time");
output.put("options", common.redactedJsonMap());
output.put("present", result.isPresent());
output.put("timeOfLastDeploy", result.map(Instant::toString).orElse(""));
out.println(jsonObject(output));
} else if (result.isPresent()) {
out.println(result.get());
} else {
out.println("(none)");
}
}
return 0;
}
}
@Command(name = "galaxy-discover", description = "Calls GalaxyRepository.DiscoverHierarchy.")
static final class GalaxyDiscoverCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
List<GalaxyObject> objects = client.discoverHierarchy();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-discover");
output.put("options", common.redactedJsonMap());
output.put("objects", objects.stream().map(MxGatewayCli::galaxyObjectMap).toList());
out.println(jsonObject(output));
} else {
out.printf("count=%d%n", objects.size());
for (GalaxyObject obj : objects) {
out.printf(" %s [%s] attrs=%d%n",
obj.getTagName(), obj.getBrowseName(), obj.getAttributesCount());
}
}
}
return 0;
}
}
@Command(
name = "galaxy-watch",
description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.")
static final class GalaxyWatchCommand extends GalaxyCommand {
@Option(
names = "--last-seen-deploy-time",
description =
"Optional ISO-8601 instant. When supplied, the bootstrap event is suppressed if the cached"
+ " deploy time matches.")
String lastSeenDeployTime;
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print before exiting.")
int limit;
@Override
public Integer call() {
Instant after = parseInstant(lastSeenDeployTime);
try (GalaxyRepositoryClient client = connect();
DeployEventStream events = client.watchDeployEvents(after)) {
PrintWriter out = common.spec.commandLine().getOut();
Thread shutdownHook = new Thread(events::close, "galaxy-watch-shutdown");
Runtime.getRuntime().addShutdownHook(shutdownHook);
try {
int count = 0;
while (events.hasNext()) {
DeployEvent event = events.next();
if (json) {
out.println(protoJson(event));
} else {
out.printf(
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
event.getSequence(),
formatTimestamp(event.getObservedAt()),
event.getTimeOfLastDeployPresent()
? formatTimestamp(event.getTimeOfLastDeploy())
: "(none)",
event.getObjectCount(),
event.getAttributeCount());
}
out.flush();
count++;
if (limit > 0 && count >= limit) {
events.close();
break;
}
}
} finally {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// JVM is already shutting down.
}
}
}
return 0;
}
private static Instant parseInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Instant.parse(value);
}
private static String formatTimestamp(com.google.protobuf.Timestamp ts) {
return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).toString();
}
}
private static Map<String, Object> galaxyObjectMap(GalaxyObject obj) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("gobjectId", obj.getGobjectId());
values.put("tagName", obj.getTagName());
values.put("containedName", obj.getContainedName());
values.put("browseName", obj.getBrowseName());
values.put("parentGobjectId", obj.getParentGobjectId());
values.put("isArea", obj.getIsArea());
values.put("categoryId", obj.getCategoryId());
values.put("hostedByGobjectId", obj.getHostedByGobjectId());
values.put("templateChain", new ArrayList<>(obj.getTemplateChainList()));
List<Map<String, Object>> attrs = new ArrayList<>();
for (GalaxyAttribute attr : obj.getAttributesList()) {
Map<String, Object> attrMap = new LinkedHashMap<>();
attrMap.put("attributeName", attr.getAttributeName());
attrMap.put("fullTagReference", attr.getFullTagReference());
attrMap.put("mxDataType", attr.getMxDataType());
attrMap.put("dataTypeName", attr.getDataTypeName());
attrMap.put("isArray", attr.getIsArray());
attrMap.put("arrayDimension", attr.getArrayDimension());
attrMap.put("arrayDimensionPresent", attr.getArrayDimensionPresent());
attrMap.put("mxAttributeCategory", attr.getMxAttributeCategory());
attrMap.put("securityClassification", attr.getSecurityClassification());
attrMap.put("isHistorized", attr.getIsHistorized());
attrMap.put("isAlarm", attr.getIsAlarm());
attrs.add(attrMap);
}
values.put("attributes", attrs);
return values;
}
@Command(name = "version", description = "Prints the Java client version.")
public static final class VersionCommand implements Callable<Integer> {
@Spec
private CommandSpec spec;
@Option(names = "--json", description = "Write JSON output.")
private boolean json;
@Override
public Integer call() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("clientVersion", MxGatewayClientVersion.clientVersion());
values.put("gatewayProtocolVersion", MxGatewayClientVersion.gatewayProtocolVersion());
values.put("workerProtocolVersion", MxGatewayClientVersion.workerProtocolVersion());
if (json) {
spec.commandLine().getOut().println(jsonObject(values));
return 0;
}
spec.commandLine()
.getOut()
.printf(
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
MxGatewayClientVersion.clientVersion(),
MxGatewayClientVersion.gatewayProtocolVersion(),
MxGatewayClientVersion.workerProtocolVersion());
return 0;
}
}
abstract static class GatewayCommand implements Callable<Integer> {
final MxGatewayCliClientFactory clientFactory;
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GatewayCommand(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
}
@Command(name = "open-session", description = "Opens a gateway session.")
static final class OpenSessionCommand extends GatewayCommand {
@Option(names = "--client-session-name", description = "Client session name.")
String clientSessionName = "";
@Option(names = "--backend", description = "Requested gateway backend.")
String backend = "";
OpenSessionCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var reply = client.openSession(OpenSessionRequest.newBuilder()
.setClientSessionName(clientSessionName)
.setRequestedBackend(backend)
.build());
writeOutput("open-session", common, json, reply, () -> reply.getSessionId());
}
return 0;
}
}
@Command(name = "close-session", description = "Closes a gateway session.")
static final class CloseSessionCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
CloseSessionCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var reply = client.closeSession(CloseSessionRequest.newBuilder()
.setSessionId(sessionId)
.build());
writeOutput("close-session", common, json, reply, () -> reply.getFinalState().name());
}
return 0;
}
}
@Command(name = "register", description = "Invokes MXAccess Register.")
static final class RegisterCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--client-name", required = true, description = "MXAccess client name.")
String clientName;
RegisterCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).registerRaw(clientName);
writeOutput("register", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "add-item", description = "Invokes MXAccess AddItem.")
static final class AddItemCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item", required = true, description = "Item definition.")
String item;
AddItemCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).addItemRaw(serverHandle, item);
writeOutput("add-item", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "advise", description = "Invokes MXAccess Advise.")
static final class AdviseCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
AdviseCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).adviseRaw(serverHandle, itemHandle);
writeOutput("advise", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
static final class SubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--items", required = true, description = "Comma-separated item definitions.")
String items;
SubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<SubscribeResult> results =
client.session(sessionId).subscribeBulk(serverHandle, parseStringList(items));
writeBulkOutput("subscribe-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "unsubscribe-bulk", description = "Invokes MXAccess UnsubscribeBulk.")
static final class UnsubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
String itemHandles;
UnsubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<SubscribeResult> results =
client.session(sessionId).unsubscribeBulk(serverHandle, parseIntList(itemHandles));
writeBulkOutput("unsubscribe-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write", description = "Invokes MXAccess Write.")
static final class WriteCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
@Option(names = "--type", defaultValue = "string", description = "Value type.")
String type;
@Option(names = "--value", required = true, description = "Value text.")
String value;
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
int userId;
WriteCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply =
client.session(sessionId).writeRaw(serverHandle, itemHandle, parseValue(type, value), userId);
writeOutput("write", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "stream-events", description = "Streams gateway events.")
static final class StreamEventsCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--after-worker-sequence", defaultValue = "0", description = "Starting worker sequence.")
long afterWorkerSequence;
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print.")
int limit;
StreamEventsCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved());
MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
int count = 0;
while (events.hasNext()) {
MxEvent event = events.next();
if (json) {
client.out().println(protoJson(event));
} else {
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
}
count++;
if (limit > 0 && count >= limit) {
events.close();
break;
}
}
}
return 0;
}
}
@Command(name = "smoke", description = "Runs a bounded open/register/add/advise flow.")
static final class SmokeCommand extends GatewayCommand {
@Option(names = "--client-name", defaultValue = "mxgw-java-smoke", description = "MXAccess client name.")
String clientName;
@Option(names = "--item", required = true, description = "Item definition.")
String item;
SmokeCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var session = client.openSession(OpenSessionRequest.newBuilder()
.setClientSessionName(clientName)
.build());
try {
MxGatewayCliSession cliSession = client.session(session.getSessionId());
int serverHandle = cliSession.register(clientName);
int itemHandle = cliSession.addItem(serverHandle, item);
cliSession.advise(serverHandle, itemHandle);
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "smoke");
output.put("options", common.redactedJsonMap());
output.put("sessionId", session.getSessionId());
output.put("serverHandle", serverHandle);
output.put("itemHandle", itemHandle);
client.out().println(jsonObject(output));
} else {
client.out().printf(
"session=%s server=%d item=%d%n", session.getSessionId(), serverHandle, itemHandle);
}
} finally {
client.closeSession(CloseSessionRequest.newBuilder()
.setSessionId(session.getSessionId())
.build());
}
}
return 0;
}
}
static final class CommonOptions {
@Spec
CommandSpec spec;
@Option(names = "--endpoint", defaultValue = "localhost:5000", description = "Gateway endpoint.")
String endpoint;
@Option(names = "--api-key", description = "Gateway API key.")
String apiKey = "";
@Option(names = "--api-key-env", defaultValue = "MXGATEWAY_API_KEY", description = "API key environment variable.")
String apiKeyEnv;
@Option(names = "--plaintext", description = "Use plaintext transport.")
boolean plaintext;
@Option(names = "--ca-file", description = "CA certificate file.")
Path caFile;
@Option(names = "--server-name-override", description = "TLS server name override.")
String serverNameOverride = "";
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
String timeout;
private String resolvedApiKey = "";
private Duration resolvedTimeout = Duration.ofSeconds(30);
CommonOptions resolved() {
resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
if (resolvedApiKey == null) {
resolvedApiKey = "";
}
resolvedTimeout = parseDuration(timeout);
return this;
}
MxGatewayClientOptions toClientOptions() {
return MxGatewayClientOptions.builder()
.endpoint(endpoint)
.apiKey(resolvedApiKey)
.plaintext(plaintext)
.caCertificatePath(caFile)
.serverNameOverride(serverNameOverride)
.callTimeout(resolvedTimeout)
.build();
}
Map<String, Object> redactedJsonMap() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("endpoint", endpoint);
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
values.put("apiKeyEnv", apiKeyEnv);
values.put("plaintext", plaintext);
values.put("caFile", caFile == null ? "" : caFile.toString());
values.put("serverNameOverride", serverNameOverride);
values.put("timeout", timeout);
return values;
}
}
interface MxGatewayCliClientFactory {
MxGatewayCliClient connect(CommonOptions options);
}
interface MxGatewayCliClient extends AutoCloseable {
PrintWriter out();
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request);
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request);
MxGatewayCliSession session(String sessionId);
@Override
void close();
}
interface MxGatewayCliSession {
int register(String clientName);
MxCommandReply registerRaw(String clientName);
int addItem(int serverHandle, String itemDefinition);
MxCommandReply addItemRaw(int serverHandle, String itemDefinition);
void advise(int serverHandle, int itemHandle);
MxCommandReply adviseRaw(int serverHandle, int itemHandle);
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
MxEventStream streamEventsAfter(long afterWorkerSequence);
}
static final class GrpcMxGatewayCliClientFactory implements MxGatewayCliClientFactory {
@Override
public MxGatewayCliClient connect(CommonOptions options) {
return new GrpcMxGatewayCliClient(MxGatewayClient.connect(options.toClientOptions()), options.spec.commandLine().getOut());
}
}
static final class GrpcMxGatewayCliClient implements MxGatewayCliClient {
private final MxGatewayClient client;
private final PrintWriter out;
GrpcMxGatewayCliClient(MxGatewayClient client, PrintWriter out) {
this.client = client;
this.out = out;
}
@Override
public PrintWriter out() {
return out;
}
@Override
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request) {
return client.openSessionRaw(request);
}
@Override
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request) {
return client.closeSessionRaw(request);
}
@Override
public MxGatewayCliSession session(String sessionId) {
return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
}
@Override
public void close() {
client.close();
}
}
record GrpcMxGatewayCliSession(MxGatewaySession session) implements MxGatewayCliSession {
@Override
public int register(String clientName) {
return session.register(clientName);
}
@Override
public MxCommandReply registerRaw(String clientName) {
return session.registerRaw(clientName);
}
@Override
public int addItem(int serverHandle, String itemDefinition) {
return session.addItem(serverHandle, itemDefinition);
}
@Override
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
return session.addItemRaw(serverHandle, itemDefinition);
}
@Override
public void advise(int serverHandle, int itemHandle) {
session.advise(serverHandle, itemHandle);
}
@Override
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
return session.adviseRaw(serverHandle, itemHandle);
}
@Override
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
return session.writeRaw(serverHandle, itemHandle, value, userId);
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
return session.subscribeBulk(serverHandle, items);
}
@Override
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
return session.unsubscribeBulk(serverHandle, itemHandles);
}
@Override
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
return session.streamEventsAfter(afterWorkerSequence);
}
}
interface TextSupplier {
String get();
}
private static void writeOutput(
String command, CommonOptions common, boolean json, Message reply, TextSupplier textSupplier) {
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", command);
output.put("options", common.redactedJsonMap());
output.put("reply", new RawJson(protoJson(reply)));
out.println(jsonObject(output));
return;
}
out.println(textSupplier.get());
}
private static void writeBulkOutput(
String command, CommonOptions common, boolean json, List<SubscribeResult> results) {
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", command);
output.put("options", common.redactedJsonMap());
output.put("results", results.stream().map(MxGatewayCli::subscribeResultMap).toList());
out.println(jsonObject(output));
return;
}
out.println(results.size());
}
private static Map<String, Object> subscribeResultMap(SubscribeResult result) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("serverHandle", result.getServerHandle());
values.put("tagAddress", result.getTagAddress());
values.put("itemHandle", result.getItemHandle());
values.put("wasSuccessful", result.getWasSuccessful());
values.put("errorMessage", result.getErrorMessage());
return values;
}
private static MxValue parseValue(String type, String text) {
return switch (type) {
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
case "int32" -> MxValues.int32Value(Integer.parseInt(text));
case "int64" -> MxValues.int64Value(Long.parseLong(text));
case "float" -> MxValues.floatValue(Float.parseFloat(text));
case "double" -> MxValues.doubleValue(Double.parseDouble(text));
case "string" -> MxValues.stringValue(text);
default -> throw new IllegalArgumentException("unsupported value type " + type);
};
}
private static List<String> parseStringList(String value) {
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(item -> !item.isBlank())
.toList();
}
private static List<Integer> parseIntList(String value) {
return parseStringList(value).stream().map(Integer::parseInt).toList();
}
private static Duration parseDuration(String value) {
if (value == null || value.isBlank()) {
return Duration.ofSeconds(30);
}
if (value.startsWith("P")) {
return Duration.parse(value);
}
if (value.endsWith("ms")) {
return Duration.ofMillis(Long.parseLong(value.substring(0, value.length() - 2)));
}
if (value.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(value.substring(0, value.length() - 1)));
}
if (value.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(value.substring(0, value.length() - 1)));
}
return Duration.parse(value);
}
private static String protoJson(Message message) {
try {
return JsonFormat.printer().omittingInsignificantWhitespace().print(message);
} catch (Exception error) {
throw new IllegalStateException("failed to write protobuf JSON", error);
}
}
private static String jsonObject(Map<String, Object> values) {
StringBuilder builder = new StringBuilder();
builder.append('{');
boolean first = true;
for (Map.Entry<String, Object> entry : values.entrySet()) {
if (!first) {
builder.append(',');
}
first = false;
builder.append(jsonString(entry.getKey())).append(':').append(jsonValue(entry.getValue()));
}
builder.append('}');
return builder.toString();
}
@SuppressWarnings("unchecked")
private static String jsonValue(Object value) {
if (value == null) {
return "null";
}
if (value instanceof RawJson rawJson) {
return rawJson.value();
}
if (value instanceof String string) {
return jsonString(string);
}
if (value instanceof Number || value instanceof Boolean) {
return value.toString();
}
if (value instanceof Map<?, ?> map) {
return jsonObject((Map<String, Object>) map);
}
if (value instanceof Iterable<?> iterable) {
StringBuilder builder = new StringBuilder();
builder.append('[');
boolean first = true;
for (Object item : iterable) {
if (!first) {
builder.append(',');
}
first = false;
builder.append(jsonValue(item));
}
builder.append(']');
return builder.toString();
}
return jsonString(value.toString());
}
private static String jsonString(String value) {
return '"'
+ value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r", "\\r")
.replace("\n", "\\n")
+ '"';
}
private record RawJson(String value) {
}
}
@@ -0,0 +1,309 @@
package com.dohertylan.mxgateway.cli;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import org.junit.jupiter.api.Test;
final class MxGatewayCliTests {
@Test
void versionCommandPrintsProtocolVersions() {
CliRun run = execute(new FakeClientFactory(), "version");
assertEquals(0, run.exitCode());
assertEquals("", run.errors());
assertTrue(run.output().contains("mxgateway-java 0.1.0"));
assertTrue(run.output().contains("gatewayProtocolVersion=2"));
assertTrue(run.output().contains("workerProtocolVersion=1"));
}
@Test
void versionCommandPrintsJson() {
CliRun run = execute(new FakeClientFactory(), "version", "--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\""));
assertTrue(run.output().contains("\"gatewayProtocolVersion\":2"));
}
@Test
void openSessionJsonRedactsApiKey() {
CliRun run = execute(
new FakeClientFactory(),
"open-session",
"--endpoint",
"localhost:5000",
"--api-key",
"mxgw_visible_secret",
"--plaintext",
"--client-session-name",
"java-cli",
"--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"open-session\""));
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
assertTrue(run.output().contains("mxgw***********cret"));
assertFalse(run.output().contains("visible_secret"));
}
@Test
void writeBuildsTypedValueFromParserOptions() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write",
"--session-id",
"session-cli",
"--server-handle",
"12",
"--item-handle",
"34",
"--type",
"int32",
"--value",
"123",
"--json");
assertEquals(0, run.exitCode());
assertEquals(123, factory.client.session.lastWriteValue.getInt32Value());
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE\""));
}
@Test
void smokeCommandRunsOpenRegisterAddAdviseAndClose() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(factory, "smoke", "--item", "TestObject.TestInt", "--json");
assertEquals(0, run.exitCode());
assertTrue(factory.client.session.registerCalled);
assertTrue(factory.client.session.addItemCalled);
assertTrue(factory.client.session.adviseCalled);
assertTrue(factory.client.closeCalled);
assertTrue(run.output().contains("\"serverHandle\":42"));
assertTrue(run.output().contains("\"itemHandle\":7"));
}
@Test
void subscribeBulkCommandPrintsResults() {
CliRun run = execute(
new FakeClientFactory(),
"subscribe-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--items",
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
"--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"subscribe-bulk\""));
assertTrue(run.output().contains("\"itemHandle\":100"));
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
}
@Test
void unsubscribeBulkCommandPrintsResults() {
CliRun run = execute(
new FakeClientFactory(),
"unsubscribe-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--item-handles",
"100,101",
"--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"unsubscribe-bulk\""));
assertTrue(run.output().contains("\"itemHandle\":101"));
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
StringWriter output = new StringWriter();
StringWriter errors = new StringWriter();
int exitCode = MxGatewayCli.execute(
factory,
new PrintWriter(output, true),
new PrintWriter(errors, true),
args);
return new CliRun(exitCode, output.toString(), errors.toString());
}
private record CliRun(int exitCode, String output, String errors) {
}
private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
private FakeClient client;
@Override
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
client = new FakeClient(options.spec.commandLine().getOut());
return client;
}
}
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
private final PrintWriter out;
private final FakeSession session = new FakeSession();
private boolean closeCalled;
private FakeClient(PrintWriter out) {
this.out = out;
}
@Override
public PrintWriter out() {
return out;
}
@Override
public OpenSessionReply openSession(OpenSessionRequest request) {
return OpenSessionReply.newBuilder()
.setSessionId("session-cli")
.setProtocolStatus(ok())
.build();
}
@Override
public CloseSessionReply closeSession(CloseSessionRequest request) {
closeCalled = true;
return CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setFinalState(SessionState.SESSION_STATE_CLOSED)
.setProtocolStatus(ok())
.build();
}
@Override
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
return session;
}
@Override
public void close() {
}
}
private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
private boolean registerCalled;
private boolean addItemCalled;
private boolean adviseCalled;
private MxValue lastWriteValue;
@Override
public int register(String clientName) {
registerCalled = true;
return 42;
}
@Override
public MxCommandReply registerRaw(String clientName) {
registerCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
.setProtocolStatus(ok())
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
.build();
}
@Override
public int addItem(int serverHandle, String itemDefinition) {
addItemCalled = true;
return 7;
}
@Override
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
addItemCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
.setProtocolStatus(ok())
.setAddItem(AddItemReply.newBuilder().setItemHandle(7))
.build();
}
@Override
public void advise(int serverHandle, int itemHandle) {
adviseCalled = true;
}
@Override
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
adviseCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
.setProtocolStatus(ok())
.build();
}
@Override
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
lastWriteValue = value;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
.setProtocolStatus(ok())
.build();
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
List<SubscribeResult> results = new ArrayList<>();
for (int index = 0; index < items.size(); index++) {
results.add(SubscribeResult.newBuilder()
.setServerHandle(serverHandle)
.setTagAddress(items.get(index))
.setItemHandle(100 + index)
.setWasSuccessful(true)
.build());
}
return results;
}
@Override
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
List<SubscribeResult> results = new ArrayList<>();
for (Integer itemHandle : itemHandles) {
results.add(SubscribeResult.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setWasSuccessful(true)
.build());
}
return results;
}
@Override
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
throw new UnsupportedOperationException("stream-events is covered by client tests");
}
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
}
@@ -0,0 +1,53 @@
plugins {
id 'java-library'
id 'com.google.protobuf'
}
dependencies {
api "com.google.protobuf:protobuf-java-util:${protobufVersion}"
api "com.google.protobuf:protobuf-java:${protobufVersion}"
api "io.grpc:grpc-protobuf:${grpcVersion}"
api "io.grpc:grpc-stub:${grpcVersion}"
implementation "com.google.guava:guava:${guavaVersion}"
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
compileOnly 'javax.annotation:javax.annotation-api:1.3.2'
testImplementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "io.grpc:grpc-inprocess:${grpcVersion}"
testImplementation "io.grpc:grpc-testing:${grpcVersion}"
}
sourceSets {
main {
proto {
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
include 'mxaccess_gateway.proto'
include 'mxaccess_worker.proto'
include 'galaxy_repository.proto'
}
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:${protobufVersion}"
}
plugins {
grpc {
artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
}
}
generatedFilesBaseDir = rootProject.file('src/main/generated').absolutePath
generateProtoTasks {
all().configureEach {
plugins {
grpc {}
}
}
}
}
@@ -0,0 +1,136 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
* and are buffered in a bounded blocking queue; the iterator drains them.
* Closing the stream cancels the underlying gRPC call.
*/
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private final AtomicBoolean closed = new AtomicBoolean();
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
private Object next;
DeployEventStream(int capacity) {
queue = new ArrayBlockingQueue<>(capacity);
}
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer() {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
DeployEventStream.this.requestStream = requestStream;
if (closed.get()) {
requestStream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void onNext(DeployEvent value) {
offer(value);
}
@Override
public void onError(Throwable error) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed.get()) {
offer(END);
return;
}
offer(error);
}
@Override
public void onCompleted() {
offer(END);
}
};
}
@Override
public boolean hasNext() {
if (next == END) {
return false;
}
if (next == null) {
next = take();
}
if (next instanceof RuntimeException runtimeException) {
next = END;
throw runtimeException;
}
if (next instanceof Throwable throwable) {
next = END;
throw new MxGatewayException(
"galaxy watch deploy events failed: " + throwable.getMessage(), throwable);
}
return next != END;
}
@Override
public DeployEvent next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Object value = next;
next = null;
return (DeployEvent) value;
}
@Override
public void close() {
closed.set(true);
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
offer(END);
}
private Object take() {
while (true) {
try {
return queue.take();
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new StatusRuntimeException(
Status.CANCELLED.withDescription("interrupted while reading deploy events"));
}
}
}
private void offer(Object value) {
Objects.requireNonNull(value, "value");
if (value == END) {
if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
return;
}
if (!queue.offer(value)) {
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client deploy event stream queue overflowed", null);
}
queue.clear();
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
queue.offer(END);
}
}
}
@@ -0,0 +1,60 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
* deploy-event stream.
*/
public final class DeployEventSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void onNext(DeployEvent value) {
observer.onNext(value);
}
@Override
public void onError(Throwable error) {
observer.onError(error);
}
@Override
public void onCompleted() {
observer.onCompleted();
}
};
}
public void cancel() {
cancelled.set(true);
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -0,0 +1,323 @@
package com.dohertylan.mxgateway.client;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import com.google.protobuf.Timestamp;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.stub.StreamObserver;
import java.time.Instant;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
/**
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
* exposes the three metadata-only RPCs of the Galaxy Repository service in
* idiomatic Java types. Mirrors the constructor and option-handling style of
* {@link MxGatewayClient}.
*/
public final class GalaxyRepositoryClient implements AutoCloseable {
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options;
private final GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub blockingStub;
private final GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub futureStub;
private final GalaxyRepositoryGrpc.GalaxyRepositoryStub asyncStub;
private GalaxyRepositoryClient(ManagedChannel channel, MxGatewayClientOptions options) {
this.ownedChannel = channel;
this.options = options;
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
}
/**
* Construct a client over a caller-managed {@link Channel}. The caller owns
* channel lifecycle; {@link #close()} is a no-op for this constructor.
*/
public GalaxyRepositoryClient(Channel channel, MxGatewayClientOptions options) {
this.ownedChannel = null;
this.options = Objects.requireNonNull(options, "options");
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
}
/** Build a new client and own its channel; close shuts the channel down. */
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
return new GalaxyRepositoryClient(createChannel(options), options);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
return withDeadline(blockingStub);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
return withDeadline(futureStub);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryStub rawAsyncStub() {
return asyncStub;
}
/**
* Invoke the {@code TestConnection} RPC and return the {@code ok} flag.
*/
public boolean testConnection() {
try {
TestConnectionReply reply = rawBlockingStub().testConnection(TestConnectionRequest.getDefaultInstance());
return reply.getOk();
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy test connection", error);
}
}
public CompletableFuture<Boolean> testConnectionAsync() {
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
.thenApply(TestConnectionReply::getOk);
}
/**
* Invoke the {@code GetLastDeployTime} RPC. Returns {@link Optional#empty()}
* when the server reports {@code present=false}.
*/
public Optional<Instant> getLastDeployTime() {
try {
GetLastDeployTimeReply reply =
rawBlockingStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance());
return mapDeployTime(reply);
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy get last deploy time", error);
}
}
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
.thenApply(GalaxyRepositoryClient::mapDeployTime);
}
/**
* Invoke the {@code DiscoverHierarchy} RPC and return the generated
* {@link GalaxyObject} messages directly. Callers can read every field of
* the proto message without an extra DTO layer.
*/
public List<GalaxyObject> discoverHierarchy() {
try {
java.util.ArrayList<GalaxyObject> objects = new java.util.ArrayList<>();
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
String pageToken = "";
do {
DiscoverHierarchyReply reply = rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.newBuilder()
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
.setPageToken(pageToken)
.build());
objects.addAll(reply.getObjectsList());
pageToken = reply.getNextPageToken();
if (!pageToken.isBlank() && !seenPageTokens.add(pageToken)) {
throw new MxGatewayException(
"galaxy discover hierarchy returned repeated page token: " + pageToken);
}
} while (!pageToken.isBlank());
return objects;
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy discover hierarchy", error);
}
}
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
}
/**
* Subscribe to {@code WatchDeployEvents} via the async stub and consume
* results through a blocking iterator. Closing the returned stream cancels
* the underlying gRPC call.
*
* @param lastSeenDeployTime optional. When non-null, the bootstrap event is
* suppressed if the cached deploy time matches.
*/
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
DeployEventStream stream = new DeployEventStream(16);
withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
return stream;
}
/**
* Iterator-style alias for {@link #watchDeployEvents(Instant)} matching the
* task-spec signature.
*/
public Iterator<DeployEvent> watchDeployEventsIterator(Instant lastSeenDeployTime) {
return watchDeployEvents(lastSeenDeployTime);
}
/**
* Subscribe to {@code WatchDeployEvents} via the async stub, dispatching
* each event to {@code observer}. The returned subscription is cancellable
* and {@link AutoCloseable}.
*/
public DeployEventSubscription watchDeployEventsAsync(
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
Objects.requireNonNull(observer, "observer");
DeployEventSubscription subscription = new DeployEventSubscription();
withStreamDeadline(rawAsyncStub())
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
return subscription;
}
private static WatchDeployEventsRequest buildWatchRequest(Instant lastSeenDeployTime) {
WatchDeployEventsRequest.Builder builder = WatchDeployEventsRequest.newBuilder();
if (lastSeenDeployTime != null) {
builder.setLastSeenDeployTime(Timestamp.newBuilder()
.setSeconds(lastSeenDeployTime.getEpochSecond())
.setNanos(lastSeenDeployTime.getNano())
.build());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
@Override
public void close() {
if (ownedChannel != null) {
ownedChannel.shutdown();
}
}
public void closeAndAwaitTermination() throws InterruptedException {
if (ownedChannel != null) {
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
}
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
if (!reply.getPresent()) {
return Optional.empty();
}
Timestamp ts = reply.getTimeOfLastDeploy();
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
}
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
.maxInboundMessageSize(options.maxGrpcMessageBytes());
if (!options.connectTimeout().isNegative()) {
builder.withOption(
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(options.connectTimeout().toMillis()));
}
if (options.plaintext()) {
builder.usePlaintext();
} else if (options.caCertificatePath() != null) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(options.caCertificatePath().toFile())
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure galaxy repository TLS", error);
}
} else {
builder.useTransportSecurity();
}
if (!options.serverNameOverride().isBlank()) {
builder.overrideAuthority(options.serverNameOverride());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
private CompletableFuture<List<GalaxyObject>> discoverHierarchyPageAsync(
String pageToken, java.util.ArrayList<GalaxyObject> objects, java.util.HashSet<String> seenPageTokens) {
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
.setPageToken(pageToken)
.build();
return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> {
objects.addAll(reply.getObjectsList());
if (reply.getNextPageToken().isBlank()) {
return CompletableFuture.completedFuture(objects);
}
if (!seenPageTokens.add(reply.getNextPageToken())) {
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
failed.completeExceptionally(new MxGatewayException(
"galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
return failed;
}
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
});
}
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
target.complete(result);
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc("galaxy async call", runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
target.whenComplete((ignoredResult, ignoredError) -> {
if (target.isCancelled()) {
source.cancel(true);
}
});
return target;
}
}
@@ -0,0 +1,14 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public final class MxAccessException extends MxGatewayCommandException {
public MxAccessException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
super(operation, protocolStatus, reply);
}
public MxAccessException(String operation, MxCommandReply reply) {
super(operation, reply == null ? null : reply.getProtocolStatus(), reply);
}
}
@@ -0,0 +1,124 @@
package com.dohertylan.mxgateway.client;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
private volatile boolean closed;
private Object next;
MxEventStream(int capacity) {
queue = new ArrayBlockingQueue<>(capacity);
}
ClientResponseObserver<StreamEventsRequest, MxEvent> observer() {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) {
MxEventStream.this.requestStream = requestStream;
}
@Override
public void onNext(MxEvent value) {
offer(value);
}
@Override
public void onError(Throwable error) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
offer(END);
return;
}
offer(error);
}
@Override
public void onCompleted() {
offer(END);
}
};
}
@Override
public boolean hasNext() {
if (next == END) {
return false;
}
if (next == null) {
next = take();
}
if (next instanceof RuntimeException runtimeException) {
next = END;
throw runtimeException;
}
if (next instanceof Throwable throwable) {
next = END;
throw new MxGatewayException("gateway stream events failed: " + throwable.getMessage(), throwable);
}
return next != END;
}
@Override
public MxEvent next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Object value = next;
next = null;
return (MxEvent) value;
}
@Override
public void close() {
closed = true;
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client cancelled event stream", null);
}
offer(END);
}
private Object take() {
while (true) {
try {
return queue.take();
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new StatusRuntimeException(Status.CANCELLED.withDescription("interrupted while reading events"));
}
}
}
private void offer(Object value) {
Objects.requireNonNull(value, "value");
if (value == END) {
if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
return;
}
if (!queue.offer(value)) {
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client event stream queue overflowed", null);
}
queue.clear();
queue.offer(new MxGatewayException("gateway stream events queue overflowed"));
queue.offer(END);
}
}
}
@@ -0,0 +1,37 @@
package com.dohertylan.mxgateway.client;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
public final class MxGatewayAuthInterceptor implements ClientInterceptor {
static final Metadata.Key<String> AUTHORIZATION_HEADER =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final String apiKey;
public MxGatewayAuthInterceptor(String apiKey) {
this.apiKey = apiKey == null ? "" : apiKey;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);
if (apiKey.isBlank()) {
return call;
}
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
headers.put(AUTHORIZATION_HEADER, "Bearer " + apiKey);
super.start(responseListener, headers);
}
};
}
}
@@ -0,0 +1,7 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewayAuthenticationException extends MxGatewayException {
public MxGatewayAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,7 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewayAuthorizationException extends MxGatewayException {
public MxGatewayAuthorizationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,242 @@
package com.dohertylan.mxgateway.client;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.protobuf.Duration;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.stub.StreamObserver;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
public final class MxGatewayClient implements AutoCloseable {
private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options;
private final MxAccessGatewayGrpc.MxAccessGatewayBlockingStub blockingStub;
private final MxAccessGatewayGrpc.MxAccessGatewayFutureStub futureStub;
private final MxAccessGatewayGrpc.MxAccessGatewayStub asyncStub;
private MxGatewayClient(ManagedChannel channel, MxGatewayClientOptions options) {
this.ownedChannel = channel;
this.options = options;
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = MxAccessGatewayGrpc.newBlockingStub(intercepted);
futureStub = MxAccessGatewayGrpc.newFutureStub(intercepted);
asyncStub = MxAccessGatewayGrpc.newStub(intercepted);
}
public MxGatewayClient(Channel channel, MxGatewayClientOptions options) {
this.ownedChannel = null;
this.options = Objects.requireNonNull(options, "options");
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = MxAccessGatewayGrpc.newBlockingStub(intercepted);
futureStub = MxAccessGatewayGrpc.newFutureStub(intercepted);
asyncStub = MxAccessGatewayGrpc.newStub(intercepted);
}
public static MxGatewayClient connect(MxGatewayClientOptions options) {
return new MxGatewayClient(createChannel(options), options);
}
public MxAccessGatewayGrpc.MxAccessGatewayBlockingStub rawBlockingStub() {
return withDeadline(blockingStub);
}
public MxAccessGatewayGrpc.MxAccessGatewayFutureStub rawFutureStub() {
return withDeadline(futureStub);
}
public MxAccessGatewayGrpc.MxAccessGatewayStub rawAsyncStub() {
return asyncStub;
}
public MxGatewaySession openSession(OpenSessionRequest request) {
OpenSessionReply reply = openSessionRaw(request);
return new MxGatewaySession(this, reply);
}
public MxGatewaySession openSession(String clientSessionName) {
return openSession(OpenSessionRequest.newBuilder()
.setClientSessionName(clientSessionName)
.setCommandTimeout(Duration.newBuilder()
.setSeconds(options.callTimeout().toSeconds())
.setNanos(options.callTimeout().toNanosPart())
.build())
.build());
}
public OpenSessionReply openSessionRaw(OpenSessionRequest request) {
try {
OpenSessionReply reply = rawBlockingStub().openSession(request);
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
return reply;
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("open session", error);
}
}
public CompletableFuture<OpenSessionReply> openSessionAsync(OpenSessionRequest request) {
CompletableFuture<OpenSessionReply> future = toCompletable(rawFutureStub().openSession(request));
return future.thenApply(reply -> {
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
return reply;
});
}
public MxCommandReply invoke(MxCommandRequest request) {
try {
MxCommandReply reply = rawBlockingStub().invoke(request);
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
MxGatewayErrors.ensureMxAccessSuccess("invoke", reply);
return reply;
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("invoke", error);
}
}
public CompletableFuture<MxCommandReply> invokeAsync(MxCommandRequest request) {
CompletableFuture<MxCommandReply> future = toCompletable(rawFutureStub().invoke(request));
return future.thenApply(reply -> {
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
MxGatewayErrors.ensureMxAccessSuccess("invoke", reply);
return reply;
});
}
public CloseSessionReply closeSessionRaw(CloseSessionRequest request) {
try {
CloseSessionReply reply = rawBlockingStub().closeSession(request);
MxGatewayErrors.ensureProtocolSuccess("close session", reply.getProtocolStatus(), null);
return reply;
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("close session", error);
}
}
public MxEventStream streamEvents(StreamEventsRequest request) {
MxEventStream stream = new MxEventStream(16);
withStreamDeadline(rawAsyncStub()).streamEvents(request, stream.observer());
return stream;
}
public MxGatewayEventSubscription streamEventsAsync(
StreamEventsRequest request, StreamObserver<MxEvent> observer) {
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
withStreamDeadline(rawAsyncStub()).streamEvents(request, subscription.wrap(observer));
return subscription;
}
@Override
public void close() {
if (ownedChannel != null) {
ownedChannel.shutdown();
}
}
public void closeAndAwaitTermination() throws InterruptedException {
if (ownedChannel != null) {
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
}
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
.maxInboundMessageSize(options.maxGrpcMessageBytes());
if (!options.connectTimeout().isNegative()) {
builder.withOption(
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(options.connectTimeout().toMillis()));
}
if (options.plaintext()) {
builder.usePlaintext();
} else if (options.caCertificatePath() != null) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(options.caCertificatePath().toFile())
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure gateway TLS", error);
}
} else {
builder.useTransportSecurity();
}
if (!options.serverNameOverride().isBlank()) {
builder.overrideAuthority(options.serverNameOverride());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
target.complete(result);
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc("async call", runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
target.whenComplete((ignoredResult, ignoredError) -> {
if (target.isCancelled()) {
source.cancel(true);
}
});
return target;
}
static ProtocolStatusCode okStatusCode() {
return ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK;
}
}
@@ -0,0 +1,177 @@
package com.dohertylan.mxgateway.client;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
public final class MxGatewayClientOptions {
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofSeconds(30);
private static final int DEFAULT_MAX_GRPC_MESSAGE_BYTES = 16 * 1024 * 1024;
private final String endpoint;
private final String apiKey;
private final boolean plaintext;
private final Path caCertificatePath;
private final String serverNameOverride;
private final Duration connectTimeout;
private final Duration callTimeout;
private final Duration streamTimeout;
private final int maxGrpcMessageBytes;
private MxGatewayClientOptions(Builder builder) {
endpoint = requireText(builder.endpoint, "endpoint");
apiKey = builder.apiKey == null ? "" : builder.apiKey;
plaintext = builder.plaintext;
caCertificatePath = builder.caCertificatePath;
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
streamTimeout = builder.streamTimeout;
maxGrpcMessageBytes = builder.maxGrpcMessageBytes <= 0
? DEFAULT_MAX_GRPC_MESSAGE_BYTES
: builder.maxGrpcMessageBytes;
}
public static Builder builder() {
return new Builder();
}
public String endpoint() {
return endpoint;
}
public String apiKey() {
return apiKey;
}
public String redactedApiKey() {
return MxGatewaySecrets.redactApiKey(apiKey);
}
public boolean plaintext() {
return plaintext;
}
public Path caCertificatePath() {
return caCertificatePath;
}
public String serverNameOverride() {
return serverNameOverride;
}
public Duration connectTimeout() {
return connectTimeout;
}
public Duration callTimeout() {
return callTimeout;
}
public Duration streamTimeout() {
return streamTimeout;
}
public int maxGrpcMessageBytes() {
return maxGrpcMessageBytes;
}
@Override
public String toString() {
return "MxGatewayClientOptions{"
+ "endpoint='"
+ endpoint
+ '\''
+ ", apiKey='"
+ redactedApiKey()
+ '\''
+ ", plaintext="
+ plaintext
+ ", caCertificatePath="
+ caCertificatePath
+ ", serverNameOverride='"
+ serverNameOverride
+ '\''
+ ", connectTimeout="
+ connectTimeout
+ ", callTimeout="
+ callTimeout
+ ", streamTimeout="
+ streamTimeout
+ ", maxGrpcMessageBytes="
+ maxGrpcMessageBytes
+ '}';
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " is required");
}
return value;
}
public static final class Builder {
private String endpoint;
private String apiKey;
private boolean plaintext;
private Path caCertificatePath;
private String serverNameOverride;
private Duration connectTimeout;
private Duration callTimeout;
private Duration streamTimeout;
private int maxGrpcMessageBytes;
private Builder() {
}
public Builder endpoint(String value) {
endpoint = value;
return this;
}
public Builder apiKey(String value) {
apiKey = value;
return this;
}
public Builder plaintext(boolean value) {
plaintext = value;
return this;
}
public Builder caCertificatePath(Path value) {
caCertificatePath = value;
return this;
}
public Builder serverNameOverride(String value) {
serverNameOverride = value;
return this;
}
public Builder connectTimeout(Duration value) {
connectTimeout = Objects.requireNonNull(value, "connectTimeout");
return this;
}
public Builder callTimeout(Duration value) {
callTimeout = Objects.requireNonNull(value, "callTimeout");
return this;
}
public Builder streamTimeout(Duration value) {
streamTimeout = Objects.requireNonNull(value, "streamTimeout");
return this;
}
public Builder maxGrpcMessageBytes(int value) {
maxGrpcMessageBytes = value;
return this;
}
public MxGatewayClientOptions build() {
return new MxGatewayClientOptions(this);
}
}
}
@@ -0,0 +1,22 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewayClientVersion {
private static final int GATEWAY_PROTOCOL_VERSION = 2;
private static final int WORKER_PROTOCOL_VERSION = 1;
private static final String CLIENT_VERSION = "0.1.0";
private MxGatewayClientVersion() {
}
public static String clientVersion() {
return CLIENT_VERSION;
}
public static int gatewayProtocolVersion() {
return GATEWAY_PROTOCOL_VERSION;
}
public static int workerProtocolVersion() {
return WORKER_PROTOCOL_VERSION;
}
}
@@ -0,0 +1,23 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public class MxGatewayCommandException extends MxGatewayException {
private final ProtocolStatus protocolStatus;
private final MxCommandReply reply;
public MxGatewayCommandException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
this.protocolStatus = protocolStatus;
this.reply = reply;
}
public ProtocolStatus protocolStatus() {
return protocolStatus;
}
public MxCommandReply reply() {
return reply;
}
}
@@ -0,0 +1,72 @@
package com.dohertylan.mxgateway.client;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
final class MxGatewayErrors {
private MxGatewayErrors() {
}
static RuntimeException fromGrpc(String operation, RuntimeException error) {
if (error instanceof StatusRuntimeException statusError) {
Status status = statusError.getStatus();
String message = MxGatewaySecrets.redactCredentials(status.getDescription());
return switch (status.getCode()) {
case UNAUTHENTICATED -> new MxGatewayAuthenticationException(
"authentication failed: " + message, statusError);
case PERMISSION_DENIED -> new MxGatewayAuthorizationException(
"authorization failed: " + message, statusError);
case DEADLINE_EXCEEDED -> new MxGatewayException("gateway call timed out: " + message, statusError);
case CANCELLED -> new MxGatewayException("gateway call cancelled: " + message, statusError);
default -> new MxGatewayException("gateway " + operation + " failed: " + message, statusError);
};
}
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
}
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
return;
}
throw switch (status.getCode()) {
case PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND, PROTOCOL_STATUS_CODE_SESSION_NOT_READY ->
new MxGatewaySessionException(operation, status);
case PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE, PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION ->
new MxGatewayWorkerException(operation, status);
case PROTOCOL_STATUS_CODE_MXACCESS_FAILURE -> new MxAccessException(operation, status, reply);
default -> new MxGatewayCommandException(operation, status, reply);
};
}
static void ensureMxAccessSuccess(String operation, MxCommandReply reply) {
if (reply == null) {
return;
}
if (reply.hasHresult() && reply.getHresult() != 0) {
throw new MxAccessException(operation, reply);
}
for (var status : reply.getStatusesList()) {
if (!MxStatuses.succeeded(status)) {
throw new MxAccessException(operation, reply);
}
}
}
static String protocolStatusMessage(String operation, ProtocolStatus status) {
if (status == null) {
return "mxgateway " + operation + " failed with missing protocol status";
}
if (status.getMessage().isBlank()) {
return "mxgateway " + operation + " failed with protocol status " + status.getCode();
}
return "mxgateway " + operation + " failed with protocol status "
+ status.getCode()
+ ": "
+ status.getMessage();
}
}
@@ -0,0 +1,54 @@
package com.dohertylan.mxgateway.client;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicBoolean;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
public final class MxGatewayEventSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<StreamEventsRequest>> requestStream = new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<StreamEventsRequest, MxEvent> wrap(StreamObserver<MxEvent> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled event stream", null);
}
}
@Override
public void onNext(MxEvent value) {
observer.onNext(value);
}
@Override
public void onError(Throwable error) {
observer.onError(error);
}
@Override
public void onCompleted() {
observer.onCompleted();
}
};
}
public void cancel() {
cancelled.set(true);
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled event stream", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -0,0 +1,11 @@
package com.dohertylan.mxgateway.client;
public class MxGatewayException extends RuntimeException {
public MxGatewayException(String message) {
super(message);
}
public MxGatewayException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,33 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewaySecrets {
private MxGatewaySecrets() {
}
public static String redactApiKey(String apiKey) {
if (apiKey == null || apiKey.isEmpty()) {
return "";
}
if (apiKey.length() <= 8) {
return "<redacted>";
}
return apiKey.substring(0, 4)
+ "*".repeat(apiKey.length() - 8)
+ apiKey.substring(apiKey.length() - 4);
}
public static String redactCredentials(String value) {
if (value == null || value.isBlank()) {
return value == null ? "" : value;
}
String[] parts = value.split("\\s+");
for (int index = 0; index < parts.length; index++) {
if (parts[index].startsWith("mxgw_") || parts[index].equalsIgnoreCase("bearer")) {
parts[index] = "<redacted>";
}
}
return String.join(" ", parts);
}
}
@@ -0,0 +1,286 @@
package com.dohertylan.mxgateway.client;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
public final class MxGatewaySession implements AutoCloseable {
private static final SecureRandom RANDOM = new SecureRandom();
private final MxGatewayClient client;
private final OpenSessionReply openReply;
private CloseSessionReply closeReply;
MxGatewaySession(MxGatewayClient client, OpenSessionReply openReply) {
this.client = Objects.requireNonNull(client, "client");
this.openReply = Objects.requireNonNull(openReply, "openReply");
}
public static MxGatewaySession forSessionId(MxGatewayClient client, String sessionId) {
return new MxGatewaySession(
client, OpenSessionReply.newBuilder().setSessionId(sessionId).build());
}
public String sessionId() {
return openReply.getSessionId();
}
public OpenSessionReply openReply() {
return openReply;
}
public synchronized CloseSessionReply closeRaw() {
if (closeReply == null) {
closeReply = client.closeSessionRaw(CloseSessionRequest.newBuilder()
.setSessionId(sessionId())
.setClientCorrelationId(newCorrelationId())
.build());
}
return closeReply;
}
@Override
public void close() {
closeRaw();
}
public int register(String clientName) {
MxCommandReply reply = registerRaw(clientName);
if (reply.hasRegister()) {
return reply.getRegister().getServerHandle();
}
return reply.getReturnValue().getInt32Value();
}
public MxCommandReply registerRaw(String clientName) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
.setRegister(RegisterCommand.newBuilder().setClientName(clientName))
.build());
}
public void unregister(int serverHandle) {
invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_UNREGISTER)
.setUnregister(UnregisterCommand.newBuilder().setServerHandle(serverHandle))
.build());
}
public int addItem(int serverHandle, String itemDefinition) {
MxCommandReply reply = addItemRaw(serverHandle, itemDefinition);
if (reply.hasAddItem()) {
return reply.getAddItem().getItemHandle();
}
return reply.getReturnValue().getInt32Value();
}
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
.setAddItem(AddItemCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemDefinition(itemDefinition))
.build());
}
public int addItem2(int serverHandle, String itemDefinition, String itemContext) {
MxCommandReply reply = addItem2Raw(serverHandle, itemDefinition, itemContext);
if (reply.hasAddItem2()) {
return reply.getAddItem2().getItemHandle();
}
return reply.getReturnValue().getInt32Value();
}
public MxCommandReply addItem2Raw(int serverHandle, String itemDefinition, String itemContext) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM2)
.setAddItem2(AddItem2Command.newBuilder()
.setServerHandle(serverHandle)
.setItemDefinition(itemDefinition)
.setItemContext(itemContext))
.build());
}
public void removeItem(int serverHandle, int itemHandle) {
removeItemRaw(serverHandle, itemHandle);
}
public MxCommandReply removeItemRaw(int serverHandle, int itemHandle) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REMOVE_ITEM)
.setRemoveItem(RemoveItemCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
}
public void advise(int serverHandle, int itemHandle) {
adviseRaw(serverHandle, itemHandle);
}
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
.setAdvise(AdviseCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
}
public void unAdvise(int serverHandle, int itemHandle) {
unAdviseRaw(serverHandle, itemHandle);
}
public MxCommandReply unAdviseRaw(int serverHandle, int itemHandle) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_UN_ADVISE)
.setUnAdvise(UnAdviseCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
}
public List<SubscribeResult> addItemBulk(int serverHandle, List<String> tagAddresses) {
Objects.requireNonNull(tagAddresses, "tagAddresses");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM_BULK)
.setAddItemBulk(AddItemBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllTagAddresses(tagAddresses))
.build());
return reply.getAddItemBulk().getResultsList();
}
public List<SubscribeResult> adviseItemBulk(int serverHandle, List<Integer> itemHandles) {
Objects.requireNonNull(itemHandles, "itemHandles");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_ITEM_BULK)
.setAdviseItemBulk(AdviseItemBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllItemHandles(itemHandles))
.build());
return reply.getAdviseItemBulk().getResultsList();
}
public List<SubscribeResult> removeItemBulk(int serverHandle, List<Integer> itemHandles) {
Objects.requireNonNull(itemHandles, "itemHandles");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REMOVE_ITEM_BULK)
.setRemoveItemBulk(RemoveItemBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllItemHandles(itemHandles))
.build());
return reply.getRemoveItemBulk().getResultsList();
}
public List<SubscribeResult> unAdviseItemBulk(int serverHandle, List<Integer> itemHandles) {
Objects.requireNonNull(itemHandles, "itemHandles");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK)
.setUnAdviseItemBulk(UnAdviseItemBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllItemHandles(itemHandles))
.build());
return reply.getUnAdviseItemBulk().getResultsList();
}
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> tagAddresses) {
Objects.requireNonNull(tagAddresses, "tagAddresses");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_SUBSCRIBE_BULK)
.setSubscribeBulk(SubscribeBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllTagAddresses(tagAddresses))
.build());
return reply.getSubscribeBulk().getResultsList();
}
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
Objects.requireNonNull(itemHandles, "itemHandles");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_UNSUBSCRIBE_BULK)
.setUnsubscribeBulk(UnsubscribeBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllItemHandles(itemHandles))
.build());
return reply.getUnsubscribeBulk().getResultsList();
}
public void write(int serverHandle, int itemHandle, MxValue value, int userId) {
writeRaw(serverHandle, itemHandle, value, userId);
}
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
.setWrite(WriteCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setValue(value)
.setUserId(userId))
.build());
}
public void write2(int serverHandle, int itemHandle, MxValue value, MxValue timestampValue, int userId) {
invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2)
.setWrite2(Write2Command.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setValue(value)
.setTimestampValue(timestampValue)
.setUserId(userId))
.build());
}
public MxEventStream streamEvents() {
return streamEventsAfter(0);
}
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
return client.streamEvents(StreamEventsRequest.newBuilder()
.setSessionId(sessionId())
.setAfterWorkerSequence(afterWorkerSequence)
.build());
}
public MxCommandReply invokeCommand(MxCommand command) {
return client.invoke(MxCommandRequest.newBuilder()
.setSessionId(sessionId())
.setClientCorrelationId(newCorrelationId())
.setCommand(command)
.build());
}
private static String newCorrelationId() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
}
@@ -0,0 +1,16 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public final class MxGatewaySessionException extends MxGatewayException {
private final ProtocolStatus protocolStatus;
public MxGatewaySessionException(String operation, ProtocolStatus protocolStatus) {
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
this.protocolStatus = protocolStatus;
}
public ProtocolStatus protocolStatus() {
return protocolStatus;
}
}
@@ -0,0 +1,16 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public final class MxGatewayWorkerException extends MxGatewayException {
private final ProtocolStatus protocolStatus;
public MxGatewayWorkerException(String operation, ProtocolStatus protocolStatus) {
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
this.protocolStatus = protocolStatus;
}
public ProtocolStatus protocolStatus() {
return protocolStatus;
}
}
@@ -0,0 +1,48 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusSource;
public final class MxStatuses {
private MxStatuses() {
}
public static boolean succeeded(MxStatusProxy status) {
return status == null || status.getSuccess() != 0;
}
public static MxStatusView view(MxStatusProxy status) {
return new MxStatusView(status);
}
public record MxStatusView(MxStatusProxy raw) {
public int success() {
return raw.getSuccess();
}
public MxStatusCategory category() {
return raw.getCategory();
}
public MxStatusSource detectedBy() {
return raw.getDetectedBy();
}
public int detail() {
return raw.getDetail();
}
public int rawCategory() {
return raw.getRawCategory();
}
public int rawDetectedBy() {
return raw.getRawDetectedBy();
}
public String diagnosticText() {
return raw.getDiagnosticText();
}
}
}
@@ -0,0 +1,170 @@
package com.dohertylan.mxgateway.client;
import com.google.protobuf.ByteString;
import com.google.protobuf.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.BoolArray;
import mxaccess_gateway.v1.MxaccessGateway.DoubleArray;
import mxaccess_gateway.v1.MxaccessGateway.FloatArray;
import mxaccess_gateway.v1.MxaccessGateway.Int32Array;
import mxaccess_gateway.v1.MxaccessGateway.Int64Array;
import mxaccess_gateway.v1.MxaccessGateway.MxArray;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.RawArray;
import mxaccess_gateway.v1.MxaccessGateway.StringArray;
import mxaccess_gateway.v1.MxaccessGateway.TimestampArray;
public final class MxValues {
private MxValues() {
}
public static MxValue boolValue(boolean value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_BOOLEAN)
.setVariantType("VT_BOOL")
.setBoolValue(value)
.build();
}
public static MxValue int32Value(int value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
.setVariantType("VT_I4")
.setInt32Value(value)
.build();
}
public static MxValue int64Value(long value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
.setVariantType("VT_I8")
.setInt64Value(value)
.build();
}
public static MxValue floatValue(float value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_FLOAT)
.setVariantType("VT_R4")
.setFloatValue(value)
.build();
}
public static MxValue doubleValue(double value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_DOUBLE)
.setVariantType("VT_R8")
.setDoubleValue(value)
.build();
}
public static MxValue stringValue(String value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_STRING)
.setVariantType("VT_BSTR")
.setStringValue(value)
.build();
}
public static MxValue timestampValue(Instant value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_TIME)
.setVariantType("VT_DATE")
.setTimestampValue(Timestamp.newBuilder()
.setSeconds(value.getEpochSecond())
.setNanos(value.getNano())
.build())
.build();
}
public static Object nativeValue(MxValue value) {
if (value == null || value.getIsNull()) {
return null;
}
return switch (value.getKindCase()) {
case BOOL_VALUE -> value.getBoolValue();
case INT32_VALUE -> value.getInt32Value();
case INT64_VALUE -> value.getInt64Value();
case FLOAT_VALUE -> value.getFloatValue();
case DOUBLE_VALUE -> value.getDoubleValue();
case STRING_VALUE -> value.getStringValue();
case TIMESTAMP_VALUE -> instant(value.getTimestampValue());
case ARRAY_VALUE -> nativeArray(value.getArrayValue());
case RAW_VALUE -> value.getRawValue().toByteArray();
case KIND_NOT_SET -> null;
};
}
public static Object nativeArray(MxArray array) {
if (array == null) {
return null;
}
return switch (array.getValuesCase()) {
case BOOL_VALUES -> List.copyOf(array.getBoolValues().getValuesList());
case INT32_VALUES -> List.copyOf(array.getInt32Values().getValuesList());
case INT64_VALUES -> List.copyOf(array.getInt64Values().getValuesList());
case FLOAT_VALUES -> List.copyOf(array.getFloatValues().getValuesList());
case DOUBLE_VALUES -> List.copyOf(array.getDoubleValues().getValuesList());
case STRING_VALUES -> List.copyOf(array.getStringValues().getValuesList());
case TIMESTAMP_VALUES -> timestampValues(array.getTimestampValues());
case RAW_VALUES -> rawValues(array.getRawValues());
case VALUES_NOT_SET -> List.of();
};
}
public static MxArray stringArray(List<String> values) {
return MxArray.newBuilder()
.setElementDataType(MxDataType.MX_DATA_TYPE_STRING)
.setVariantType("VT_ARRAY|VT_BSTR")
.addDimensions(values.size())
.setStringValues(StringArray.newBuilder().addAllValues(values))
.build();
}
public static MxArray int32Array(List<Integer> values) {
return MxArray.newBuilder()
.setElementDataType(MxDataType.MX_DATA_TYPE_INTEGER)
.setVariantType("VT_ARRAY|VT_I4")
.addDimensions(values.size())
.setInt32Values(Int32Array.newBuilder().addAllValues(values))
.build();
}
public static String kindName(MxValue value) {
return value == null ? "KIND_NOT_SET" : value.getKindCase().name();
}
private static Instant instant(Timestamp timestamp) {
return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos());
}
private static List<Instant> timestampValues(TimestampArray array) {
List<Instant> values = new ArrayList<>();
for (Timestamp timestamp : array.getValuesList()) {
values.add(instant(timestamp));
}
return values;
}
private static List<byte[]> rawValues(RawArray array) {
List<byte[]> values = new ArrayList<>();
for (ByteString rawValue : array.getValuesList()) {
values.add(rawValue.toByteArray());
}
return values;
}
@SuppressWarnings("unused")
private static void generatedTypeReferences(
BoolArray boolArray,
Int64Array int64Array,
FloatArray floatArray,
DoubleArray doubleArray) {
// Keeps generated repeated-value imports visible for javadocs and IDE navigation.
}
}
@@ -0,0 +1,426 @@
package com.dohertylan.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.Timestamp;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.Server;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
final class GalaxyRepositoryClientTests {
@Test
void testConnectionReturnsOkAndSendsAuthMetadata() throws Exception {
AtomicReference<String> authorization = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void testConnection(
TestConnectionRequest request, StreamObserver<TestConnectionReply> responseObserver) {
responseObserver.onNext(TestConnectionReply.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, authorization);
GalaxyRepositoryClient client = g.client("mxgw_galaxy_secret")) {
assertTrue(client.testConnection());
assertEquals("Bearer mxgw_galaxy_secret", authorization.get());
}
}
@Test
void getLastDeployTimeReturnsEmptyWhenPresentFalse() throws Exception {
TestService service = new TestService() {
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(
GetLastDeployTimeReply.newBuilder().setPresent(false).build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
Optional<Instant> result = client.getLastDeployTime();
assertFalse(result.isPresent());
}
}
@Test
void getLastDeployTimeReturnsInstantWhenPresent() throws Exception {
Timestamp expected = Timestamp.newBuilder().setSeconds(1_700_000_000L).setNanos(123_000_000).build();
TestService service = new TestService() {
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(GetLastDeployTimeReply.newBuilder()
.setPresent(true)
.setTimeOfLastDeploy(expected)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
Optional<Instant> result = client.getLastDeployTime();
assertTrue(result.isPresent());
assertEquals(Instant.ofEpochSecond(1_700_000_000L, 123_000_000), result.get());
}
}
@Test
void discoverHierarchyReturnsObjectsAndAttributes() throws Exception {
AtomicReference<DiscoverHierarchyRequest> firstRequest = new AtomicReference<>();
AtomicReference<DiscoverHierarchyRequest> secondRequest = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
if (request.getPageToken().isEmpty()) {
firstRequest.set(request);
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
.setNextPageToken("page-2")
.setTotalObjectCount(2)
.addObjects(GalaxyObject.newBuilder()
.setGobjectId(7)
.setTagName("Pump_001")
.setContainedName("Pump")
.setBrowseName("Pump")
.setParentGobjectId(1)
.setIsArea(false)
.setCategoryId(3)
.setHostedByGobjectId(0)
.addTemplateChain("$Pump")
.addAttributes(GalaxyAttribute.newBuilder()
.setAttributeName("Speed")
.setFullTagReference("Pump_001.Speed")
.setMxDataType(5)
.setDataTypeName("MxFloat")
.setIsArray(false)
.setIsHistorized(true)))
.build());
} else {
secondRequest.set(request);
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
.setTotalObjectCount(2)
.addObjects(GalaxyObject.newBuilder()
.setGobjectId(8)
.setTagName("Pump_002"))
.build());
}
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<GalaxyObject> objects = client.discoverHierarchy();
assertEquals(2, objects.size());
assertEquals(5000, firstRequest.get().getPageSize());
assertEquals("", firstRequest.get().getPageToken());
assertEquals("page-2", secondRequest.get().getPageToken());
GalaxyObject only = objects.get(0);
assertEquals(7, only.getGobjectId());
assertEquals("Pump_001", only.getTagName());
assertEquals(1, only.getAttributesCount());
assertEquals("Pump_001.Speed", only.getAttributes(0).getFullTagReference());
assertTrue(only.getAttributes(0).getIsHistorized());
}
}
@Test
void deployEventStreamCloseBeforeBeforeStartCancelsStream() {
DeployEventStream stream = new DeployEventStream(4);
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer = stream.observer();
RecordingClientCallStreamObserver requestStream = new RecordingClientCallStreamObserver();
stream.close();
observer.beforeStart(requestStream);
assertTrue(requestStream.cancelled);
assertEquals("client cancelled deploy event stream", requestStream.cancelMessage);
assertFalse(stream.hasNext());
}
@Test
void discoverHierarchyRejectsRepeatedPageToken() throws Exception {
TestService service = new TestService() {
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
.setNextPageToken("7:1")
.build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
MxGatewayException error = assertThrows(MxGatewayException.class, client::discoverHierarchy);
assertTrue(error.getMessage().contains("repeated page token"));
}
}
@Test
void watchDeployEventsReceivesEventsInOrder() throws Exception {
DeployEvent first = DeployEvent.newBuilder()
.setSequence(1)
.setObservedAt(Timestamp.newBuilder().setSeconds(1_700_000_000L).build())
.setTimeOfLastDeploy(Timestamp.newBuilder().setSeconds(1_699_999_000L).build())
.setTimeOfLastDeployPresent(true)
.setObjectCount(42)
.setAttributeCount(123)
.build();
DeployEvent second = DeployEvent.newBuilder()
.setSequence(2)
.setObservedAt(Timestamp.newBuilder().setSeconds(1_700_000_100L).build())
.setTimeOfLastDeployPresent(false)
.setObjectCount(43)
.setAttributeCount(125)
.build();
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
responseObserver.onNext(first);
responseObserver.onNext(second);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
try (DeployEventStream stream = client.watchDeployEvents(null)) {
assertTrue(stream.hasNext());
DeployEvent event1 = stream.next();
assertEquals(1L, event1.getSequence());
assertEquals(42, event1.getObjectCount());
assertTrue(event1.getTimeOfLastDeployPresent());
assertTrue(stream.hasNext());
DeployEvent event2 = stream.next();
assertEquals(2L, event2.getSequence());
assertFalse(event2.getTimeOfLastDeployPresent());
assertFalse(stream.hasNext());
}
}
}
@Test
void watchDeployEventsPropagatesLastSeenDeployTime() throws Exception {
AtomicReference<WatchDeployEventsRequest> seen = new AtomicReference<>();
Instant lastSeen = Instant.ofEpochSecond(1_700_000_000L, 250_000_000);
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
seen.set(request);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
try (DeployEventStream stream = client.watchDeployEvents(lastSeen)) {
assertFalse(stream.hasNext());
}
}
WatchDeployEventsRequest request = seen.get();
assertNotNull(request);
Timestamp expected = request.getLastSeenDeployTime();
assertEquals(lastSeen.getEpochSecond(), expected.getSeconds());
assertEquals(lastSeen.getNano(), expected.getNanos());
}
@Test
void watchDeployEventsClientCancellationTearsDownCleanly() throws Exception {
CountDownLatch cancelObserved = new CountDownLatch(1);
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
io.grpc.stub.ServerCallStreamObserver<DeployEvent> serverObserver =
(io.grpc.stub.ServerCallStreamObserver<DeployEvent>) responseObserver;
serverObserver.setOnCancelHandler(cancelObserved::countDown);
DeployEvent bootstrap = DeployEvent.newBuilder()
.setSequence(1)
.setObservedAt(Timestamp.newBuilder().setSeconds(1L).build())
.build();
responseObserver.onNext(bootstrap);
// Server holds the stream open; cancellation must come from the client.
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
DeployEventStream stream = client.watchDeployEvents(null);
assertTrue(stream.hasNext());
assertEquals(1L, stream.next().getSequence());
stream.close();
assertTrue(
cancelObserved.await(5, TimeUnit.SECONDS),
"server should observe client-side cancellation");
assertFalse(stream.hasNext());
}
}
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
@Override
public void testConnection(
TestConnectionRequest request, StreamObserver<TestConnectionReply> responseObserver) {
responseObserver.onNext(TestConnectionReply.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(GetLastDeployTimeReply.newBuilder().setPresent(false).build());
responseObserver.onCompleted();
}
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
responseObserver.onNext(DiscoverHierarchyReply.getDefaultInstance());
responseObserver.onCompleted();
}
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
responseObserver.onCompleted();
}
}
private static final class RecordingClientCallStreamObserver
extends ClientCallStreamObserver<WatchDeployEventsRequest> {
private boolean cancelled;
private String cancelMessage;
@Override
public boolean isReady() {
return true;
}
@Override
public void setOnReadyHandler(Runnable onReadyHandler) {
}
@Override
public void disableAutoInboundFlowControl() {
}
@Override
public void request(int count) {
}
@Override
public void setMessageCompression(boolean enable) {
}
@Override
public void cancel(String message, Throwable cause) {
cancelled = true;
cancelMessage = message;
}
@Override
public void onNext(WatchDeployEventsRequest value) {
}
@Override
public void onError(Throwable error) {
}
@Override
public void onCompleted() {
}
}
private record InProcessGalaxy(Server server, ManagedChannel channel) implements AutoCloseable {
static InProcessGalaxy start(
GalaxyRepositoryGrpc.GalaxyRepositoryImplBase service, AtomicReference<String> authorization)
throws Exception {
String serverName = "mxgw-galaxy-java-" + UUID.randomUUID();
ServerInterceptor interceptor = new ServerInterceptor() {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
return next.startCall(call, headers);
}
};
Server server = InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
.build()
.start();
ManagedChannel channel = InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build();
return new InProcessGalaxy(server, channel);
}
GalaxyRepositoryClient client(String apiKey) {
return new GalaxyRepositoryClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey(apiKey)
.plaintext(true)
.callTimeout(Duration.ofSeconds(5))
.build());
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
}
}
@@ -0,0 +1,29 @@
package com.dohertylan.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_worker.v1.MxaccessWorker.WorkerEnvelope;
import org.junit.jupiter.api.Test;
final class GeneratedContractSmokeTests {
@Test
void generatedGatewayAndWorkerContractsCompile() {
OpenSessionRequest request = OpenSessionRequest.newBuilder()
.setClientSessionName("junit")
.build();
WorkerEnvelope envelope = WorkerEnvelope.newBuilder()
.setProtocolVersion(MxGatewayClientVersion.workerProtocolVersion())
.build();
assertEquals("junit", request.getClientSessionName());
assertEquals("mxaccess_gateway.v1.MxAccessGateway", MxAccessGatewayGrpc.SERVICE_NAME);
assertEquals(1, envelope.getProtocolVersion());
}
@Test
void javaTwentyOneToolchainRunsTests() {
assertEquals(21, Runtime.version().feature());
}
}
@@ -0,0 +1,282 @@
package com.dohertylan.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.grpc.Context;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.Server;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.ServerCallStreamObserver;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import org.junit.jupiter.api.Test;
final class MxGatewayClientSessionTests {
@Test
void unaryCallsCarryAuthMetadataAndDeadline() throws Exception {
AtomicReference<String> authorization = new AtomicReference<>();
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
AtomicReference<Boolean> deadlineSeen = new AtomicReference<>(false);
TestGatewayService service = new TestGatewayService() {
@Override
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
deadlineSeen.set(Context.current().getDeadline() != null);
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("session-java")
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
commandRequest.set(request);
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, authorization);
MxGatewayClient client = gateway.client("mxgw_visible_secret", Duration.ofSeconds(5))) {
MxGatewaySession session = client.openSession("junit-session");
int serverHandle = session.register("java-test-client");
assertEquals(42, serverHandle);
assertEquals("Bearer mxgw_visible_secret", authorization.get());
assertEquals("session-java", commandRequest.get().getSessionId());
assertEquals(MxCommandKind.MX_COMMAND_KIND_REGISTER, commandRequest.get().getCommand().getKind());
assertTrue(deadlineSeen.get());
}
}
@Test
void methodHelpersReturnTypedHandlesAndRawReplies() throws Exception {
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
MxCommandReply.Builder reply = MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok());
if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ADD_ITEM) {
reply.setAddItem(AddItemReply.newBuilder().setItemHandle(7));
}
responseObserver.onNext(reply.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
int itemHandle = session.addItem(12, "TestObject.TestInt");
MxCommandReply raw = session.adviseRaw(12, itemHandle);
assertEquals(7, itemHandle);
assertEquals(MxCommandKind.MX_COMMAND_KIND_ADVISE, raw.getKind());
}
}
@Test
void subscribeBulkBuildsOneBulkCommandAndReturnsResults() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
commandRequest.set(request);
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.setSubscribeBulk(BulkSubscribeReply.newBuilder()
.addResults(SubscribeResult.newBuilder()
.setServerHandle(12)
.setTagAddress("Area001.Pump001.Speed")
.setItemHandle(34)
.setWasSuccessful(true)))
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
List<SubscribeResult> results = session.subscribeBulk(12, List.of("Area001.Pump001.Speed"));
assertEquals(34, results.get(0).getItemHandle());
assertEquals(MxCommandKind.MX_COMMAND_KIND_SUBSCRIBE_BULK, commandRequest.get().getCommand().getKind());
assertEquals(
List.of("Area001.Pump001.Speed"),
commandRequest.get().getCommand().getSubscribeBulk().getTagAddressesList());
}
}
@Test
void streamCancellationCancelsServerCall() throws Exception {
CountDownLatch cancelled = new CountDownLatch(1);
TestGatewayService service = new TestGatewayService() {
@Override
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
ServerCallStreamObserver<MxEvent> serverObserver =
(ServerCallStreamObserver<MxEvent>) responseObserver;
serverObserver.setOnCancelHandler(cancelled::countDown);
responseObserver.onNext(MxEvent.newBuilder()
.setSessionId(request.getSessionId())
.setWorkerSequence(1)
.build());
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxEventStream events = MxGatewaySession.forSessionId(client, "stream-session").streamEvents();
assertTrue(events.hasNext());
assertEquals(1, events.next().getWorkerSequence());
events.close();
assertTrue(cancelled.await(5, TimeUnit.SECONDS));
}
}
@Test
void commandFailureKeepsRawReply() throws Exception {
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
.setMessage("MXAccess rejected the write."))
.setHresult(-2147220992)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "failure-session");
MxAccessException error = assertThrows(
MxAccessException.class,
() -> session.write(1, 2, MxValues.int32Value(123), 0));
assertNotNull(error.reply());
assertEquals(-2147220992, error.reply().getHresult());
}
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
private abstract static class TestGatewayService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
@Override
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("session-java")
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
@Override
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
responseObserver.onNext(CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setFinalState(SessionState.SESSION_STATE_CLOSED)
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
}
private record InProcessGateway(Server server, ManagedChannel channel) implements AutoCloseable {
static InProcessGateway start(
MxAccessGatewayGrpc.MxAccessGatewayImplBase service, AtomicReference<String> authorization)
throws Exception {
String serverName = "mxgw-java-" + UUID.randomUUID();
ServerInterceptor interceptor = new ServerInterceptor() {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
return next.startCall(call, headers);
}
};
Server server = InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
.build()
.start();
ManagedChannel channel = InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build();
return new InProcessGateway(server, channel);
}
MxGatewayClient client(String apiKey, Duration callTimeout) {
return new MxGatewayClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey(apiKey)
.plaintext(true)
.callTimeout(callTimeout)
.build());
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
}
}
@@ -0,0 +1,136 @@
package com.dohertylan.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.protobuf.util.JsonFormat;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import org.junit.jupiter.api.Test;
final class MxGatewayFixtureTests {
@Test
void valueFixtureCasesExposeNativeProjectionAndRawMetadata() throws Exception {
JsonArray cases = readFixture("values/value-conversion-cases.json").getAsJsonArray("cases");
for (var element : cases) {
JsonObject testCase = element.getAsJsonObject();
MxValue.Builder builder = MxValue.newBuilder();
JsonFormat.parser().merge(testCase.getAsJsonObject("value").toString(), builder);
MxValue value = builder.build();
assertEquals(testCase.get("expectedKind").getAsString(), lowerCamelKind(value));
if ("timestamp.utc".equals(testCase.get("id").getAsString())) {
assertEquals(Instant.parse("2026-01-01T00:00:04Z"), MxValues.nativeValue(value));
}
if ("string-array".equals(testCase.get("id").getAsString())) {
assertEquals(List.of("alpha", "beta"), MxValues.nativeValue(value));
}
if ("raw-fallback.variant".equals(testCase.get("id").getAsString())) {
assertEquals("No lossless typed projection exists for this VARIANT.", value.getRawDiagnostic());
assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, (byte[]) MxValues.nativeValue(value));
}
}
}
@Test
void statusFixtureCasesPreserveRawFields() throws Exception {
JsonArray cases = readFixture("statuses/status-conversion-cases.json").getAsJsonArray("cases");
for (var element : cases) {
JsonObject testCase = element.getAsJsonObject();
MxStatusProxy.Builder builder = MxStatusProxy.newBuilder();
JsonFormat.parser().merge(testCase.getAsJsonObject("status").toString(), builder);
MxStatusProxy status = builder.build();
assertEquals(
testCase.getAsJsonObject("status").get("rawCategory").getAsInt(),
status.getRawCategory());
if ("ok.responding-lmx".equals(testCase.get("id").getAsString())) {
assertTrue(MxStatuses.succeeded(status));
}
if ("security-error.requesting-lmx".equals(testCase.get("id").getAsString())) {
assertFalse(MxStatuses.succeeded(status));
assertEquals(MxStatusCategory.MX_STATUS_CATEGORY_SECURITY_ERROR, MxStatuses.view(status).category());
}
}
}
@Test
void mxAccessFailureFixtureMapsToRichCommandException() throws Exception {
MxCommandReply.Builder builder = MxCommandReply.newBuilder();
JsonFormat.parser().merge(
Files.readString(fixtureRoot().resolve("command-replies/write.mxaccess-failure.reply.json")),
builder);
MxCommandReply reply = builder.build();
try {
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
} catch (MxAccessException error) {
assertEquals(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, error.protocolStatus().getCode());
assertEquals(-2147220992, error.reply().getHresult());
assertEquals(2, error.reply().getStatusesCount());
return;
}
throw new AssertionError("expected MxAccessException");
}
@Test
void grpcAuthErrorsAreClassifiedAndRedacted() {
RuntimeException authError = MxGatewayErrors.fromGrpc(
"open session",
new io.grpc.StatusRuntimeException(io.grpc.Status.UNAUTHENTICATED.withDescription(
"invalid API key mxgw_visible_secret")));
RuntimeException permissionError = MxGatewayErrors.fromGrpc(
"write",
new io.grpc.StatusRuntimeException(io.grpc.Status.PERMISSION_DENIED.withDescription(
"missing scope mxaccess.write")));
assertInstanceOf(MxGatewayAuthenticationException.class, authError);
assertInstanceOf(MxGatewayAuthorizationException.class, permissionError);
assertTrue(authError.getMessage().contains("<redacted>"));
assertFalse(authError.getMessage().contains("visible_secret"));
}
private static JsonObject readFixture(String relativePath) throws Exception {
return JsonParser.parseString(Files.readString(fixtureRoot().resolve(relativePath))).getAsJsonObject();
}
private static Path fixtureRoot() {
Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
for (Path path = current; path != null; path = path.getParent()) {
Path candidate = path.resolve("clients/proto/fixtures/behavior");
if (Files.exists(candidate)) {
return candidate;
}
candidate = path.resolve("../proto/fixtures/behavior").normalize();
if (Files.exists(candidate)) {
return candidate;
}
}
throw new IllegalStateException("could not locate behavior fixtures from " + current);
}
private static String lowerCamelKind(MxValue value) {
String[] parts = value.getKindCase().name().toLowerCase().split("_");
StringBuilder result = new StringBuilder(parts[0]);
for (int index = 1; index < parts.length; index++) {
result.append(Character.toUpperCase(parts[index].charAt(0))).append(parts[index].substring(1));
}
return result.toString();
}
}
+22
View File
@@ -0,0 +1,22 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
plugins {
id 'com.google.protobuf' version '0.9.5'
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = 'mxaccessgw-java'
include 'mxgateway-client'
include 'mxgateway-cli'
@@ -0,0 +1,641 @@
package galaxy_repository.v1;
import static io.grpc.MethodDescriptor.generateFullMethodName;
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
@io.grpc.stub.annotations.GrpcGenerated
public final class GalaxyRepositoryGrpc {
private GalaxyRepositoryGrpc() {}
public static final java.lang.String SERVICE_NAME = "galaxy_repository.v1.GalaxyRepository";
// Static method descriptors that strictly reflect the proto.
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "TestConnection",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
GalaxyRepositoryGrpc.getTestConnectionMethod = getTestConnectionMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "TestConnection"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("TestConnection"))
.build();
}
}
}
return getTestConnectionMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "GetLastDeployTime",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
GalaxyRepositoryGrpc.getGetLastDeployTimeMethod = getGetLastDeployTimeMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "GetLastDeployTime"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("GetLastDeployTime"))
.build();
}
}
}
return getGetLastDeployTimeMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "DiscoverHierarchy",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
GalaxyRepositoryGrpc.getDiscoverHierarchyMethod = getDiscoverHierarchyMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "DiscoverHierarchy"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("DiscoverHierarchy"))
.build();
}
}
}
return getDiscoverHierarchyMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "WatchDeployEvents",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
GalaxyRepositoryGrpc.getWatchDeployEventsMethod = getWatchDeployEventsMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "WatchDeployEvents"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("WatchDeployEvents"))
.build();
}
}
}
return getWatchDeployEventsMethod;
}
/**
* Creates a new async stub that supports all call types for the service
*/
public static GalaxyRepositoryStub newStub(io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub>() {
@java.lang.Override
public GalaxyRepositoryStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
};
return GalaxyRepositoryStub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports all types of calls on the service
*/
public static GalaxyRepositoryBlockingV2Stub newBlockingV2Stub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub>() {
@java.lang.Override
public GalaxyRepositoryBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingV2Stub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static GalaxyRepositoryBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub>() {
@java.lang.Override
public GalaxyRepositoryBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingStub.newStub(factory, channel);
}
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static GalaxyRepositoryFutureStub newFutureStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub>() {
@java.lang.Override
public GalaxyRepositoryFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
};
return GalaxyRepositoryFutureStub.newStub(factory, channel);
}
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public interface AsyncService {
/**
*/
default void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getTestConnectionMethod(), responseObserver);
}
/**
*/
default void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getGetLastDeployTimeMethod(), responseObserver);
}
/**
*/
default void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getDiscoverHierarchyMethod(), responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
default void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
}
}
/**
* Base class for the server implementation of the service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static abstract class GalaxyRepositoryImplBase
implements io.grpc.BindableService, AsyncService {
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return GalaxyRepositoryGrpc.bindService(this);
}
}
/**
* A stub to allow clients to do asynchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryStub
extends io.grpc.stub.AbstractAsyncStub<GalaxyRepositoryStub> {
private GalaxyRepositoryStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
/**
*/
public void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request, responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
}
}
/**
* A stub to allow clients to do synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingV2Stub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingV2Stub> {
private GalaxyRepositoryBlockingV2Stub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingV2Stub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
public io.grpc.stub.BlockingClientCall<?, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>
watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do limited synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingStub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingStub> {
private GalaxyRepositoryBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public java.util.Iterator<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> watchDeployEvents(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do ListenableFuture-style rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryFutureStub
extends io.grpc.stub.AbstractFutureStub<GalaxyRepositoryFutureStub> {
private GalaxyRepositoryFutureStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryFutureStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> testConnection(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getLastDeployTime(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> discoverHierarchy(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
}
}
private static final int METHODID_TEST_CONNECTION = 0;
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
private static final int METHODID_DISCOVER_HIERARCHY = 2;
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final AsyncService serviceImpl;
private final int methodId;
MethodHandlers(AsyncService serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_TEST_CONNECTION:
serviceImpl.testConnection((galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>) responseObserver);
break;
case METHODID_GET_LAST_DEPLOY_TIME:
serviceImpl.getLastDeployTime((galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>) responseObserver);
break;
case METHODID_DISCOVER_HIERARCHY:
serviceImpl.discoverHierarchy((galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>) responseObserver);
break;
case METHODID_WATCH_DEPLOY_EVENTS:
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
break;
default:
throw new AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
default:
throw new AssertionError();
}
}
}
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
getTestConnectionMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>(
service, METHODID_TEST_CONNECTION)))
.addMethod(
getGetLastDeployTimeMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>(
service, METHODID_GET_LAST_DEPLOY_TIME)))
.addMethod(
getDiscoverHierarchyMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>(
service, METHODID_DISCOVER_HIERARCHY)))
.addMethod(
getWatchDeployEventsMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
service, METHODID_WATCH_DEPLOY_EVENTS)))
.build();
}
private static abstract class GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
GalaxyRepositoryBaseDescriptorSupplier() {}
@java.lang.Override
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
return galaxy_repository.v1.GalaxyRepositoryOuterClass.getDescriptor();
}
@java.lang.Override
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
return getFileDescriptor().findServiceByName("GalaxyRepository");
}
}
private static final class GalaxyRepositoryFileDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier {
GalaxyRepositoryFileDescriptorSupplier() {}
}
private static final class GalaxyRepositoryMethodDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
private final java.lang.String methodName;
GalaxyRepositoryMethodDescriptorSupplier(java.lang.String methodName) {
this.methodName = methodName;
}
@java.lang.Override
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
return getServiceDescriptor().findMethodByName(methodName);
}
}
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
io.grpc.ServiceDescriptor result = serviceDescriptor;
if (result == null) {
synchronized (GalaxyRepositoryGrpc.class) {
result = serviceDescriptor;
if (result == null) {
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
.setSchemaDescriptor(new GalaxyRepositoryFileDescriptorSupplier())
.addMethod(getTestConnectionMethod())
.addMethod(getGetLastDeployTimeMethod())
.addMethod(getDiscoverHierarchyMethod())
.addMethod(getWatchDeployEventsMethod())
.build();
}
}
}
return result;
}
}
@@ -0,0 +1,588 @@
package mxaccess_gateway.v1;
import static io.grpc.MethodDescriptor.generateFullMethodName;
/**
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
@io.grpc.stub.annotations.GrpcGenerated
public final class MxAccessGatewayGrpc {
private MxAccessGatewayGrpc() {}
public static final java.lang.String SERVICE_NAME = "mxaccess_gateway.v1.MxAccessGateway";
// Static method descriptors that strictly reflect the proto.
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest,
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> getOpenSessionMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "OpenSession",
requestType = mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.class,
responseType = mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest,
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> getOpenSessionMethod() {
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest, mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> getOpenSessionMethod;
if ((getOpenSessionMethod = MxAccessGatewayGrpc.getOpenSessionMethod) == null) {
synchronized (MxAccessGatewayGrpc.class) {
if ((getOpenSessionMethod = MxAccessGatewayGrpc.getOpenSessionMethod) == null) {
MxAccessGatewayGrpc.getOpenSessionMethod = getOpenSessionMethod =
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest, mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "OpenSession"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply.getDefaultInstance()))
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("OpenSession"))
.build();
}
}
}
return getOpenSessionMethod;
}
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest,
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> getCloseSessionMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "CloseSession",
requestType = mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest.class,
responseType = mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest,
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> getCloseSessionMethod() {
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest, mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> getCloseSessionMethod;
if ((getCloseSessionMethod = MxAccessGatewayGrpc.getCloseSessionMethod) == null) {
synchronized (MxAccessGatewayGrpc.class) {
if ((getCloseSessionMethod = MxAccessGatewayGrpc.getCloseSessionMethod) == null) {
MxAccessGatewayGrpc.getCloseSessionMethod = getCloseSessionMethod =
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest, mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "CloseSession"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply.getDefaultInstance()))
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("CloseSession"))
.build();
}
}
}
return getCloseSessionMethod;
}
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest,
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> getInvokeMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "Invoke",
requestType = mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest.class,
responseType = mxaccess_gateway.v1.MxaccessGateway.MxCommandReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest,
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> getInvokeMethod() {
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest, mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> getInvokeMethod;
if ((getInvokeMethod = MxAccessGatewayGrpc.getInvokeMethod) == null) {
synchronized (MxAccessGatewayGrpc.class) {
if ((getInvokeMethod = MxAccessGatewayGrpc.getInvokeMethod) == null) {
MxAccessGatewayGrpc.getInvokeMethod = getInvokeMethod =
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest, mxaccess_gateway.v1.MxaccessGateway.MxCommandReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "Invoke"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply.getDefaultInstance()))
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("Invoke"))
.build();
}
}
}
return getInvokeMethod;
}
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
mxaccess_gateway.v1.MxaccessGateway.MxEvent> getStreamEventsMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "StreamEvents",
requestType = mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest.class,
responseType = mxaccess_gateway.v1.MxaccessGateway.MxEvent.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
mxaccess_gateway.v1.MxaccessGateway.MxEvent> getStreamEventsMethod() {
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest, mxaccess_gateway.v1.MxaccessGateway.MxEvent> getStreamEventsMethod;
if ((getStreamEventsMethod = MxAccessGatewayGrpc.getStreamEventsMethod) == null) {
synchronized (MxAccessGatewayGrpc.class) {
if ((getStreamEventsMethod = MxAccessGatewayGrpc.getStreamEventsMethod) == null) {
MxAccessGatewayGrpc.getStreamEventsMethod = getStreamEventsMethod =
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest, mxaccess_gateway.v1.MxaccessGateway.MxEvent>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamEvents"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.MxEvent.getDefaultInstance()))
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("StreamEvents"))
.build();
}
}
}
return getStreamEventsMethod;
}
/**
* Creates a new async stub that supports all call types for the service
*/
public static MxAccessGatewayStub newStub(io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayStub>() {
@java.lang.Override
public MxAccessGatewayStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayStub(channel, callOptions);
}
};
return MxAccessGatewayStub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports all types of calls on the service
*/
public static MxAccessGatewayBlockingV2Stub newBlockingV2Stub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingV2Stub> factory =
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingV2Stub>() {
@java.lang.Override
public MxAccessGatewayBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayBlockingV2Stub(channel, callOptions);
}
};
return MxAccessGatewayBlockingV2Stub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static MxAccessGatewayBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingStub>() {
@java.lang.Override
public MxAccessGatewayBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayBlockingStub(channel, callOptions);
}
};
return MxAccessGatewayBlockingStub.newStub(factory, channel);
}
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static MxAccessGatewayFutureStub newFutureStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayFutureStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayFutureStub>() {
@java.lang.Override
public MxAccessGatewayFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayFutureStub(channel, callOptions);
}
};
return MxAccessGatewayFutureStub.newStub(factory, channel);
}
/**
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
public interface AsyncService {
/**
*/
default void openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getOpenSessionMethod(), responseObserver);
}
/**
*/
default void closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getCloseSessionMethod(), responseObserver);
}
/**
*/
default void invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getInvokeMethod(), responseObserver);
}
/**
*/
default void streamEvents(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamEventsMethod(), responseObserver);
}
}
/**
* Base class for the server implementation of the service MxAccessGateway.
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
public static abstract class MxAccessGatewayImplBase
implements io.grpc.BindableService, AsyncService {
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return MxAccessGatewayGrpc.bindService(this);
}
}
/**
* A stub to allow clients to do asynchronous rpc calls to service MxAccessGateway.
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
public static final class MxAccessGatewayStub
extends io.grpc.stub.AbstractAsyncStub<MxAccessGatewayStub> {
private MxAccessGatewayStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected MxAccessGatewayStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayStub(channel, callOptions);
}
/**
*/
public void openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getOpenSessionMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getCloseSessionMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getInvokeMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void streamEvents(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getStreamEventsMethod(), getCallOptions()), request, responseObserver);
}
}
/**
* A stub to allow clients to do synchronous rpc calls to service MxAccessGateway.
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
public static final class MxAccessGatewayBlockingV2Stub
extends io.grpc.stub.AbstractBlockingStub<MxAccessGatewayBlockingV2Stub> {
private MxAccessGatewayBlockingV2Stub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected MxAccessGatewayBlockingV2Stub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayBlockingV2Stub(channel, callOptions);
}
/**
*/
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getOpenSessionMethod(), getCallOptions(), request);
}
/**
*/
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getCloseSessionMethod(), getCallOptions(), request);
}
/**
*/
public mxaccess_gateway.v1.MxaccessGateway.MxCommandReply invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getInvokeMethod(), getCallOptions(), request);
}
/**
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.MxEvent>
streamEvents(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do limited synchronous rpc calls to service MxAccessGateway.
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
public static final class MxAccessGatewayBlockingStub
extends io.grpc.stub.AbstractBlockingStub<MxAccessGatewayBlockingStub> {
private MxAccessGatewayBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected MxAccessGatewayBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayBlockingStub(channel, callOptions);
}
/**
*/
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getOpenSessionMethod(), getCallOptions(), request);
}
/**
*/
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getCloseSessionMethod(), getCallOptions(), request);
}
/**
*/
public mxaccess_gateway.v1.MxaccessGateway.MxCommandReply invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getInvokeMethod(), getCallOptions(), request);
}
/**
*/
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.MxEvent> streamEvents(
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do ListenableFuture-style rpc calls to service MxAccessGateway.
* <pre>
* Public client API for MXAccess sessions hosted by the gateway.
* </pre>
*/
public static final class MxAccessGatewayFutureStub
extends io.grpc.stub.AbstractFutureStub<MxAccessGatewayFutureStub> {
private MxAccessGatewayFutureStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected MxAccessGatewayFutureStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MxAccessGatewayFutureStub(channel, callOptions);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> openSession(
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getOpenSessionMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> closeSession(
mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getCloseSessionMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> invoke(
mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getInvokeMethod(), getCallOptions()), request);
}
}
private static final int METHODID_OPEN_SESSION = 0;
private static final int METHODID_CLOSE_SESSION = 1;
private static final int METHODID_INVOKE = 2;
private static final int METHODID_STREAM_EVENTS = 3;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final AsyncService serviceImpl;
private final int methodId;
MethodHandlers(AsyncService serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_OPEN_SESSION:
serviceImpl.openSession((mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest) request,
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply>) responseObserver);
break;
case METHODID_CLOSE_SESSION:
serviceImpl.closeSession((mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest) request,
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply>) responseObserver);
break;
case METHODID_INVOKE:
serviceImpl.invoke((mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest) request,
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply>) responseObserver);
break;
case METHODID_STREAM_EVENTS:
serviceImpl.streamEvents((mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest) request,
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent>) responseObserver);
break;
default:
throw new AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
default:
throw new AssertionError();
}
}
}
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
getOpenSessionMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest,
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply>(
service, METHODID_OPEN_SESSION)))
.addMethod(
getCloseSessionMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest,
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply>(
service, METHODID_CLOSE_SESSION)))
.addMethod(
getInvokeMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest,
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply>(
service, METHODID_INVOKE)))
.addMethod(
getStreamEventsMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
mxaccess_gateway.v1.MxaccessGateway.MxEvent>(
service, METHODID_STREAM_EVENTS)))
.build();
}
private static abstract class MxAccessGatewayBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
MxAccessGatewayBaseDescriptorSupplier() {}
@java.lang.Override
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
return mxaccess_gateway.v1.MxaccessGateway.getDescriptor();
}
@java.lang.Override
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
return getFileDescriptor().findServiceByName("MxAccessGateway");
}
}
private static final class MxAccessGatewayFileDescriptorSupplier
extends MxAccessGatewayBaseDescriptorSupplier {
MxAccessGatewayFileDescriptorSupplier() {}
}
private static final class MxAccessGatewayMethodDescriptorSupplier
extends MxAccessGatewayBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
private final java.lang.String methodName;
MxAccessGatewayMethodDescriptorSupplier(java.lang.String methodName) {
this.methodName = methodName;
}
@java.lang.Override
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
return getServiceDescriptor().findMethodByName(methodName);
}
}
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
io.grpc.ServiceDescriptor result = serviceDescriptor;
if (result == null) {
synchronized (MxAccessGatewayGrpc.class) {
result = serviceDescriptor;
if (result == null) {
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
.setSchemaDescriptor(new MxAccessGatewayFileDescriptorSupplier())
.addMethod(getOpenSessionMethod())
.addMethod(getCloseSessionMethod())
.addMethod(getInvokeMethod())
.addMethod(getStreamEventsMethod())
.build();
}
}
}
return result;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"fixtureSet": "mxaccess-gateway-client-behavior",
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 1,
"gatewayProtocolVersion": 2,
"workerProtocolVersion": 1,
"protoInputManifest": "clients/proto/proto-inputs.json",
"fixtures": [
@@ -3,7 +3,7 @@
"backendName": "mxaccess-worker",
"workerProcessId": 1234,
"workerProtocolVersion": 1,
"gatewayProtocolVersion": 1,
"gatewayProtocolVersion": 2,
"capabilities": [
"unary-open-session",
"unary-close-session",
@@ -0,0 +1,469 @@
{
"schemaVersion": 1,
"fixtureSet": "mxaccess-gateway-parity-fixture-matrix",
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 2,
"workerProtocolVersion": 1,
"sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures",
"sourceDocs": [
"C:/Users/dohertj2/Desktop/mxaccess/docs/MXAccess-Public-API.md",
"C:/Users/dohertj2/Desktop/mxaccess/docs/Current-Sprint-State.md"
],
"comparisonFormat": {
"description": "Each parity run records the same command against direct MXAccess and the gateway-backed worker, then compares raw parity fields instead of client wrapper behavior.",
"directMxAccess": {
"requiredFields": [
"method",
"arguments",
"returnedValue",
"hresult",
"exceptionType",
"statuses",
"events"
]
},
"gatewayResult": {
"requiredFields": [
"kind",
"protocolStatus",
"returnValue",
"hresult",
"statuses",
"diagnosticMessage",
"events"
]
},
"eventFields": [
"family",
"serverHandle",
"itemHandle",
"value",
"quality",
"sourceTimestamp",
"statuses",
"workerSequence",
"workerTimestamp",
"gatewayReceiveTimestamp",
"hresult",
"rawStatus"
],
"comparisonKeys": [
"hresult",
"exceptionType",
"returnedValue",
"statusArrayShape",
"statusRawFields",
"eventFamilyOrder",
"eventPayloadShape",
"valueProjection",
"rawFallbackMetadata"
]
},
"methodFixtures": [
{
"id": "method.register.basic",
"method": "Register",
"commandKind": "MX_COMMAND_KIND_REGISTER",
"status": "planned_fixture",
"captureReferences": [
"captures/001-register/harness.log",
"captures/047-frida-com-proxy-register/harness.log"
],
"assertions": [
"preserve returned server handle in returnValue and RegisterReply",
"preserve success HRESULT as 0",
"do not emit MXAccess events for register"
]
},
{
"id": "method.unregister.basic",
"method": "Unregister",
"commandKind": "MX_COMMAND_KIND_UNREGISTER",
"status": "planned_fixture",
"captureReferences": [
"captures/001-register/harness.log",
"captures/109-native-post-remove-errors/harness.log"
],
"assertions": [
"preserve void return shape with explicit protocol success",
"preserve HRESULT or COM exception details for invalid server handle",
"close registered handle only after MXAccess succeeds"
]
},
{
"id": "method.add-item.scalar",
"method": "AddItem",
"commandKind": "MX_COMMAND_KIND_ADD_ITEM",
"status": "planned_fixture",
"captureReferences": [
"captures/002-add-remove-scalar/harness.log",
"captures/006-add-invalid/harness.log"
],
"assertions": [
"preserve returned item handle in returnValue and AddItemReply",
"preserve invalid item reference HRESULT/status details",
"do not prevalidate item definition in the gateway"
]
},
{
"id": "method.add-item2.context",
"method": "AddItem2",
"commandKind": "MX_COMMAND_KIND_ADD_ITEM2",
"status": "planned_fixture",
"captureReferences": [
"captures/mxaccess-additem2-testint-context.log",
"captures/121-frida-buffered-history-testhistoryvalue-context/harness.log"
],
"assertions": [
"pass item_definition and item_context exactly as supplied",
"preserve returned item handle in returnValue and AddItem2Reply",
"compare context-bearing reference resolution against direct MXAccess"
]
},
{
"id": "method.remove-item.basic",
"method": "RemoveItem",
"commandKind": "MX_COMMAND_KIND_REMOVE_ITEM",
"status": "planned_fixture",
"captureReferences": [
"captures/002-add-remove-scalar/harness.log",
"captures/109-native-post-remove-errors/harness.log"
],
"assertions": [
"preserve void return shape with explicit protocol success",
"preserve post-remove and invalid-handle HRESULT/status behavior",
"remove diagnostic handle state only after MXAccess succeeds"
]
},
{
"id": "method.advise.supervisory-data-change",
"method": "Advise",
"commandKind": "MX_COMMAND_KIND_ADVISE",
"status": "planned_fixture",
"captureReferences": [
"captures/003-subscribe-scalars/harness.log",
"captures/058-frida-subscribe-testint/harness.log"
],
"assertions": [
"preserve successful command reply shape",
"forward OnDataChange with value, quality, timestamp, and status array",
"preserve per-worker event order"
]
},
{
"id": "method.unadvise.basic",
"method": "UnAdvise",
"commandKind": "MX_COMMAND_KIND_UN_ADVISE",
"status": "planned_fixture",
"captureReferences": [
"captures/058-frida-subscribe-testint/harness.log",
"captures/007-subscribe-invalid/harness.log"
],
"assertions": [
"preserve void return shape with explicit protocol success",
"preserve invalid item handle HRESULT/status behavior",
"do not distinguish plain and supervisory cleanup beyond MXAccess behavior"
]
},
{
"id": "method.advise-supervisory.basic",
"method": "AdviseSupervisory",
"commandKind": "MX_COMMAND_KIND_ADVISE_SUPERVISORY",
"status": "planned_fixture",
"captureReferences": [
"captures/058-frida-subscribe-testint/harness.log",
"captures/105-frida-advise-shortdesc-prebound-fixed/harness.log"
],
"assertions": [
"keep AdviseSupervisory distinct from plain Advise in command kind",
"forward native OnDataChange only when MXAccess emits it",
"compare supervisory item status arrays without normalization"
]
},
{
"id": "method.add-buffered-item.context",
"method": "AddBufferedItem",
"commandKind": "MX_COMMAND_KIND_ADD_BUFFERED_ITEM",
"status": "planned_fixture",
"captureReferences": [
"captures/079-frida-add-buffered-advise-testint/harness.log",
"captures/120-frida-buffered-history-testhistoryvalue/harness.log",
"captures/121-frida-buffered-history-testhistoryvalue-context/harness.log"
],
"assertions": [
"pass item_definition and item_context exactly as supplied",
"preserve returned buffered item handle in returnValue and AddBufferedItemReply",
"keep buffered registration distinct from normal AddItem2"
]
},
{
"id": "method.set-buffered-update-interval.basic",
"method": "SetBufferedUpdateInterval",
"commandKind": "MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL",
"status": "planned_fixture",
"captureReferences": [
"captures/mxaccess-set-buffered-interval-1000.log",
"captures/079-frida-add-buffered-advise-testint/harness.log"
],
"assertions": [
"preserve requested update interval without clamping in the gateway",
"preserve void return shape with explicit protocol success",
"compare buffered event cadence only in opt-in live runs"
]
},
{
"id": "method.suspend.scan-state",
"method": "Suspend",
"commandKind": "MX_COMMAND_KIND_SUSPEND",
"status": "planned_fixture",
"captureReferences": [
"captures/077-frida-suspend-advised-scanstate/harness.log",
"captures/118-frida-suspend-advised-scanstate-long/harness.log"
],
"assertions": [
"preserve out MxStatus in SuspendReply and repeated statuses",
"preserve HRESULT separately from status detail",
"do not synthesize OperationComplete if native MXAccess does not raise it"
]
},
{
"id": "method.activate.scan-state",
"method": "Activate",
"commandKind": "MX_COMMAND_KIND_ACTIVATE",
"status": "planned_fixture",
"captureReferences": [
"captures/078-frida-activate-advised-scanstate/harness.log",
"captures/119-frida-activate-advised-scanstate-long/harness.log"
],
"assertions": [
"preserve out MxStatus in ActivateReply and repeated statuses",
"preserve HRESULT separately from status detail",
"do not synthesize OperationComplete if native MXAccess does not raise it"
]
},
{
"id": "method.write.value-status-matrix",
"method": "Write",
"commandKind": "MX_COMMAND_KIND_WRITE",
"status": "planned_fixture",
"captureReferences": [
"captures/023-frida-write-test-int-sequence-109-111/harness.log",
"captures/024-frida-write-test-bool-sequence/harness.log",
"captures/089-frida-write-testint-wrong-type/harness.log",
"captures/090-frida-write-invalid-reference/harness.log",
"captures/107-native-write-testint-current/harness.log"
],
"assertions": [
"preserve scalar and array value projections plus raw fallback metadata",
"preserve wrong-type and invalid-reference HRESULT/status arrays",
"forward OnWriteComplete only when native MXAccess emits it"
]
},
{
"id": "method.write2.timestamped",
"method": "Write2",
"commandKind": "MX_COMMAND_KIND_WRITE2",
"status": "planned_fixture",
"captureReferences": [
"captures/042-frida-write2-test-int-timestamp/harness.log",
"captures/066-frida-write2-test-bool-timestamp/harness.log",
"captures/075-frida-write2-test-datetime-array-timestamp/harness.log"
],
"assertions": [
"preserve timestamp_value as an MXAccess VARIANT projection",
"preserve write value shape and HRESULT/status arrays",
"compare timestamped write completion events against direct MXAccess"
]
},
{
"id": "method.write-secured.rejection-gap",
"method": "WriteSecured",
"commandKind": "MX_COMMAND_KIND_WRITE_SECURED",
"status": "documented_gap",
"captureReferences": [
"captures/036-frida-write-secured-test-int/harness.log",
"captures/111-frida-write-secured-auth-protectedvalue/harness.log",
"captures/112-frida-write-secured-auth-verified-protectedvalue1/harness.log"
],
"assertions": [
"preserve observed 0x80004021 rejection before a value-bearing NMX body",
"preserve current_user_id and verifier_user_id only as command inputs, not logs",
"upgrade this gap to planned_fixture when a successful direct WriteSecured path is observed"
]
},
{
"id": "method.write-secured2.authenticated",
"method": "WriteSecured2",
"commandKind": "MX_COMMAND_KIND_WRITE_SECURED2",
"status": "planned_fixture",
"captureReferences": [
"captures/113-frida-write-secured2-auth-protectedvalue/harness.log",
"captures/116-frida-write-secured2-auth-verified-protectedvalue1/harness.log",
"captures/117-frida-write-secured2-auth-testint/harness.log"
],
"assertions": [
"preserve authenticated timestamped secured write body shape",
"preserve HRESULT/status arrays without logging credential-bearing values",
"do not synthesize OnWriteComplete when direct MXAccess does not emit it"
]
},
{
"id": "method.authenticate-user.basic",
"method": "AuthenticateUser",
"commandKind": "MX_COMMAND_KIND_AUTHENTICATE_USER",
"status": "planned_fixture",
"captureReferences": [
"captures/087-frida-authenticate-administrator-empty/harness.log",
"captures/088-frida-authenticate-invalid-empty/harness.log"
],
"assertions": [
"preserve returned user id in returnValue and AuthenticateUserReply",
"preserve invalid credential HRESULT/status behavior",
"redact verify_user_password from logs and diagnostics"
]
},
{
"id": "method.archestra-user-to-id.basic",
"method": "ArchestrAUserToId",
"commandKind": "MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID",
"status": "planned_fixture",
"captureReferences": [
"captures/mxaccess-user-map-administrator.log",
"captures/mxaccess-user-map-invalid.log"
],
"assertions": [
"preserve returned user id in returnValue and ArchestrAUserToIdReply",
"preserve invalid user GUID HRESULT/status behavior",
"compare raw mapping behavior without normalizing unknown users"
]
}
],
"eventFixtures": [
{
"id": "event.on-data-change.scalar",
"family": "MX_EVENT_FAMILY_ON_DATA_CHANGE",
"status": "planned_fixture",
"captureReferences": [
"captures/003-subscribe-scalars/harness.log",
"captures/106-native-subscribe-testint-current/harness.log"
],
"assertions": [
"preserve value, quality, timestamp, status array, and worker sequence"
]
},
{
"id": "event.on-write-complete.status",
"family": "MX_EVENT_FAMILY_ON_WRITE_COMPLETE",
"status": "planned_fixture",
"captureReferences": [
"captures/008-write-test-int-same-value/harness.log",
"captures/107-native-write-testint-current/harness.log"
],
"assertions": [
"preserve write-complete status array and optional HRESULT"
]
},
{
"id": "event.operation-complete.native-trigger-gap",
"family": "MX_EVENT_FAMILY_OPERATION_COMPLETE",
"status": "documented_gap",
"captureReferences": [
"captures/077-frida-suspend-advised-scanstate/harness.log",
"captures/118-frida-suspend-advised-scanstate-long/harness.log"
],
"assertions": [
"do not synthesize OperationComplete from Write or OnWriteComplete",
"upgrade this gap when a public MXAccess trigger emits event family 3"
]
},
{
"id": "event.on-buffered-data-change.batch-gap",
"family": "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE",
"status": "documented_gap",
"captureReferences": [
"captures/120-frida-buffered-history-testhistoryvalue/harness.log",
"captures/122-frida-buffered-history-testhistoryvalue-plainadvise/harness.log"
],
"assertions": [
"preserve raw buffered metadata until a public multi-sample event payload is observed",
"upgrade this gap when OnBufferedDataChange batches are captured from MXAccess"
]
}
],
"scenarioGroups": [
{
"id": "invalid_handles",
"description": "Invalid server, item, post-remove, and invalid-reference cases keep MXAccess-owned HRESULT and status behavior.",
"fixtureIds": [
"method.add-item.scalar",
"method.remove-item.basic",
"method.unadvise.basic",
"method.write.value-status-matrix",
"method.unregister.basic"
],
"captureReferences": [
"captures/006-add-invalid/harness.log",
"captures/007-subscribe-invalid/harness.log",
"captures/109-native-post-remove-errors/harness.log",
"captures/110-native-invalid-handle-errors/harness.log"
]
},
{
"id": "write_statuses",
"description": "Write success, wrong type, invalid reference, scalar arrays, and completion-status cases compare HRESULT, status array, value projection, and event shape.",
"fixtureIds": [
"method.write.value-status-matrix",
"method.write2.timestamped",
"event.on-write-complete.status"
],
"captureReferences": [
"captures/089-frida-write-testint-wrong-type/harness.log",
"captures/090-frida-write-invalid-reference/harness.log",
"captures/091-frida-write-testint-double-type/harness.log",
"captures/097-frida-write-bool-array-pattern/harness.log",
"captures/107-native-write-testint-current/harness.log"
]
},
{
"id": "secured_writes",
"description": "Secured writes include observed WriteSecured rejection and authenticated WriteSecured2 success paths without logging credential-bearing values.",
"fixtureIds": [
"method.write-secured.rejection-gap",
"method.write-secured2.authenticated",
"method.authenticate-user.basic"
],
"captureReferences": [
"captures/036-frida-write-secured-test-int/harness.log",
"captures/111-frida-write-secured-auth-protectedvalue/harness.log",
"captures/113-frida-write-secured2-auth-protectedvalue/harness.log",
"captures/117-frida-write-secured2-auth-testint/harness.log"
]
},
{
"id": "add_item_context",
"description": "Context-bearing item registration compares AddItem2 and buffered AddBufferedItem argument preservation.",
"fixtureIds": [
"method.add-item2.context",
"method.add-buffered-item.context"
],
"captureReferences": [
"captures/mxaccess-additem2-testint-context.log",
"captures/121-frida-buffered-history-testhistoryvalue-context/harness.log"
]
},
{
"id": "buffered_registration",
"description": "Buffered registration and interval setup are tracked separately from normal advice until a public buffered data-change batch is captured.",
"fixtureIds": [
"method.add-buffered-item.context",
"method.set-buffered-update-interval.basic",
"event.on-buffered-data-change.batch-gap"
],
"captureReferences": [
"captures/079-frida-add-buffered-advise-testint/harness.log",
"captures/120-frida-buffered-history-testhistoryvalue/harness.log",
"captures/122-frida-buffered-history-testhistoryvalue-plainadvise/harness.log"
]
}
]
}

Some files were not shown because too many files have changed in this diff Show More