Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf20142634 | |||
| b995c174eb | |||
| ac2787f619 | |||
| d543679044 | |||
| 133c83029b | |||
| 047d875fe6 | |||
| b0041c5d18 | |||
| 907aa49aea | |||
| 4fc355b357 | |||
| bd4a09a35e | |||
| d431ff9660 | |||
| 3d11ac3316 | |||
| daff16cfd2 | |||
| 6ce61a4f77 | |||
| 4ea2c4fd86 | |||
| 09e01de9c8 | |||
| 41a2d70f8f | |||
| 79f73e04fd | |||
| f2118f7028 | |||
| 9159f6f093 | |||
| d6939432f9 | |||
| 02143ef7e2 |
@@ -1,4 +1,5 @@
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Client.Cli;
|
||||
|
||||
@@ -19,4 +20,20 @@ public interface IMxGatewayCliClient : IAsyncDisposable
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,84 @@
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Client.Cli;
|
||||
|
||||
internal sealed class MxGatewayCliClientAdapter(MxGatewayClient client) : IMxGatewayCliClient
|
||||
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);
|
||||
return _client.OpenSessionRawAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<CloseSessionReply> CloseSessionAsync(
|
||||
CloseSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return client.CloseSessionRawAsync(request, cancellationToken);
|
||||
return _client.CloseSessionRawAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MxCommandReply> InvokeAsync(
|
||||
MxCommandRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return client.InvokeAsync(request, cancellationToken);
|
||||
return _client.InvokeAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||
StreamEventsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return client.StreamEventsAsync(request, cancellationToken);
|
||||
return _client.StreamEventsAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||
TestConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return client.DisposeAsync();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ using System.Text.Json;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Client.Cli;
|
||||
|
||||
public static class MxGatewayClientCli
|
||||
{
|
||||
private const uint MaxAggregateEvents = 10_000;
|
||||
|
||||
private static readonly JsonFormatter ProtobufJsonFormatter = JsonFormatter.Default;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -68,7 +71,7 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
await using IMxGatewayCliClient client = clientFactory(CreateOptions(arguments));
|
||||
using CancellationTokenSource cancellation = CreateCancellation(arguments);
|
||||
using CancellationTokenSource cancellation = CreateCancellation(arguments, command);
|
||||
|
||||
return command switch
|
||||
{
|
||||
@@ -84,6 +87,10 @@ public static class MxGatewayClientCli
|
||||
.ConfigureAwait(false),
|
||||
"advise" => await AdviseAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
@@ -92,6 +99,14 @@ public static class MxGatewayClientCli
|
||||
.ConfigureAwait(false),
|
||||
"smoke" => await SmokeAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"galaxy-test-connection" => await GalaxyTestConnectionAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"galaxy-last-deploy" => await GalaxyLastDeployAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"galaxy-discover" => await GalaxyDiscoverAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"galaxy-watch" => await GalaxyWatchAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
_ => WriteUnknownCommand(command, standardError),
|
||||
};
|
||||
}
|
||||
@@ -162,9 +177,18 @@ public static class MxGatewayClientCli
|
||||
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
|
||||
}
|
||||
|
||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments)
|
||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||
{
|
||||
var cancellation = new CancellationTokenSource();
|
||||
// Long-running streaming commands run until Ctrl+C / cancellation by default;
|
||||
// a caller-supplied --timeout still applies if present.
|
||||
bool isLongRunning = command is "galaxy-watch";
|
||||
string? rawTimeout = arguments.GetOptional("timeout");
|
||||
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
|
||||
{
|
||||
return cancellation;
|
||||
}
|
||||
|
||||
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
|
||||
cancellation.CancelAfter(timeout);
|
||||
return cancellation;
|
||||
@@ -287,6 +311,54 @@ public static class MxGatewayClientCli
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> SubscribeBulkAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SubscribeBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
};
|
||||
command.TagAddresses.Add(ParseStringList(arguments.GetRequired("items")));
|
||||
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SubscribeBulk,
|
||||
SubscribeBulk = command,
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> UnsubscribeBulkAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UnsubscribeBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
};
|
||||
command.ItemHandles.Add(ParseInt32List(arguments.GetRequired("item-handles")));
|
||||
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.UnsubscribeBulk,
|
||||
UnsubscribeBulk = command,
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> WriteAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
@@ -342,8 +414,22 @@ public static class MxGatewayClientCli
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = new List<MxEvent>();
|
||||
uint maxEvents = arguments.GetUInt32("max-events", 0);
|
||||
bool json = arguments.HasFlag("json");
|
||||
bool jsonLines = arguments.HasFlag("jsonl");
|
||||
if (json && !jsonLines && maxEvents is 0)
|
||||
{
|
||||
throw new ArgumentException("--json stream-events requires --max-events to bound aggregate output.");
|
||||
}
|
||||
|
||||
if (maxEvents > MaxAggregateEvents)
|
||||
{
|
||||
throw new ArgumentException($"--max-events cannot exceed {MaxAggregateEvents}.");
|
||||
}
|
||||
|
||||
var events = json && !jsonLines
|
||||
? new List<MxEvent>(checked((int)maxEvents))
|
||||
: [];
|
||||
uint eventCount = 0;
|
||||
var request = new StreamEventsRequest
|
||||
{
|
||||
@@ -355,7 +441,11 @@ public static class MxGatewayClientCli
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (arguments.HasFlag("json"))
|
||||
if (jsonLines)
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
}
|
||||
else if (json)
|
||||
{
|
||||
events.Add(gatewayEvent);
|
||||
}
|
||||
@@ -371,7 +461,7 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
}
|
||||
|
||||
if (arguments.HasFlag("json"))
|
||||
if (json && !jsonLines)
|
||||
{
|
||||
output.WriteLine(JsonSerializer.Serialize(
|
||||
new { events = events.Select(EventToJsonElement).ToArray() },
|
||||
@@ -694,6 +784,177 @@ public static class MxGatewayClientCli
|
||||
.ToMxValue();
|
||||
}
|
||||
|
||||
private static Task<int> GalaxyTestConnectionAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return WriteReplyAsync(
|
||||
client.GalaxyTestConnectionAsync(new TestConnectionRequest(), cancellationToken),
|
||||
arguments,
|
||||
output);
|
||||
}
|
||||
|
||||
private static Task<int> GalaxyLastDeployAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return WriteReplyAsync(
|
||||
client.GalaxyGetLastDeployTimeAsync(new GetLastDeployTimeRequest(), cancellationToken),
|
||||
arguments,
|
||||
output);
|
||||
}
|
||||
|
||||
private static async Task<int> GalaxyDiscoverAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DiscoverHierarchyReply reply = await DiscoverAllGalaxyHierarchyAsync(client, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (arguments.HasFlag("json"))
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(reply));
|
||||
return 0;
|
||||
}
|
||||
|
||||
output.WriteLine($"objects={reply.Objects.Count}");
|
||||
foreach (GalaxyObject galaxyObject in reply.Objects)
|
||||
{
|
||||
output.WriteLine($"- gobject_id={galaxyObject.GobjectId} tag_name={galaxyObject.TagName} contained_name={galaxyObject.ContainedName} parent={galaxyObject.ParentGobjectId} attributes={galaxyObject.Attributes.Count}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<DiscoverHierarchyReply> DiscoverAllGalaxyHierarchyAsync(
|
||||
IMxGatewayCliClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DiscoverHierarchyReply aggregate = new();
|
||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||
string pageToken = string.Empty;
|
||||
do
|
||||
{
|
||||
DiscoverHierarchyReply page = await client.GalaxyDiscoverHierarchyAsync(
|
||||
new DiscoverHierarchyRequest
|
||||
{
|
||||
PageSize = 5000,
|
||||
PageToken = pageToken,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
aggregate.Objects.Add(page.Objects);
|
||||
aggregate.TotalObjectCount = page.TotalObjectCount;
|
||||
pageToken = page.NextPageToken;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken)
|
||||
&& !seenPageTokens.Add(pageToken))
|
||||
{
|
||||
throw new MxGatewayException(
|
||||
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
|
||||
}
|
||||
}
|
||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
private static async Task<int> GalaxyWatchAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool json = arguments.HasFlag("json");
|
||||
uint maxEvents = arguments.GetUInt32("max-events", 0);
|
||||
if (maxEvents > MaxAggregateEvents)
|
||||
{
|
||||
throw new ArgumentException($"--max-events cannot exceed {MaxAggregateEvents}.");
|
||||
}
|
||||
|
||||
WatchDeployEventsRequest request = new();
|
||||
string? lastSeen = arguments.GetOptional("last-seen-deploy-time");
|
||||
if (!string.IsNullOrWhiteSpace(lastSeen))
|
||||
{
|
||||
DateTimeOffset parsed = DateTimeOffset.Parse(
|
||||
lastSeen,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
|
||||
request.LastSeenDeployTime = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(parsed);
|
||||
}
|
||||
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
ConsoleCancelEventHandler handler = (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
try
|
||||
{
|
||||
linked.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
};
|
||||
Console.CancelKeyPress += handler;
|
||||
|
||||
uint emitted = 0;
|
||||
try
|
||||
{
|
||||
await foreach (DeployEvent deployEvent in client
|
||||
.GalaxyWatchDeployEventsAsync(request, linked.Token)
|
||||
.WithCancellation(linked.Token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (json)
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(deployEvent));
|
||||
}
|
||||
else
|
||||
{
|
||||
output.WriteLine(FormatDeployEvent(deployEvent));
|
||||
}
|
||||
|
||||
emitted++;
|
||||
if (maxEvents > 0 && emitted >= maxEvents)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (linked.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Ctrl+C-driven cancellation is a clean exit.
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= handler;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string FormatDeployEvent(DeployEvent deployEvent)
|
||||
{
|
||||
string deployTime = deployEvent.TimeOfLastDeployPresent && deployEvent.TimeOfLastDeploy is not null
|
||||
? deployEvent.TimeOfLastDeploy
|
||||
.ToDateTimeOffset()
|
||||
.ToString("O", CultureInfo.InvariantCulture)
|
||||
: "<none>";
|
||||
string observed = deployEvent.ObservedAt is not null
|
||||
? deployEvent.ObservedAt
|
||||
.ToDateTimeOffset()
|
||||
.ToString("O", CultureInfo.InvariantCulture)
|
||||
: "<unknown>";
|
||||
|
||||
return $"sequence={deployEvent.Sequence} observed_at={observed} time_of_last_deploy={deployTime} objects={deployEvent.ObjectCount} attributes={deployEvent.AttributeCount}";
|
||||
}
|
||||
|
||||
private static int WriteUnknownCommand(string command, TextWriter standardError)
|
||||
{
|
||||
standardError.WriteLine($"Unknown command '{command}'.");
|
||||
@@ -716,10 +977,42 @@ public static class MxGatewayClientCli
|
||||
or "register"
|
||||
or "add-item"
|
||||
or "advise"
|
||||
or "subscribe-bulk"
|
||||
or "unsubscribe-bulk"
|
||||
or "stream-events"
|
||||
or "write"
|
||||
or "write2"
|
||||
or "smoke";
|
||||
or "smoke"
|
||||
or "galaxy-test-connection"
|
||||
or "galaxy-last-deploy"
|
||||
or "galaxy-discover"
|
||||
or "galaxy-watch";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseStringList(string value)
|
||||
{
|
||||
string[] items = value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (items.Length is 0)
|
||||
{
|
||||
throw new ArgumentException("At least one item is required.");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> ParseInt32List(string value)
|
||||
{
|
||||
string[] items = value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (items.Length is 0)
|
||||
{
|
||||
throw new ArgumentException("At least one item handle is required.");
|
||||
}
|
||||
|
||||
return items
|
||||
.Select(item => int.Parse(item, CultureInfo.InvariantCulture))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string CreateCorrelationId()
|
||||
@@ -736,9 +1029,15 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet register --session-id <id> --client-name <name> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet galaxy-test-connection [--json]");
|
||||
writer.WriteLine("mxgw-dotnet galaxy-last-deploy [--json]");
|
||||
writer.WriteLine("mxgw-dotnet galaxy-discover [--json]");
|
||||
writer.WriteLine("mxgw-dotnet galaxy-watch [--last-seen-deploy-time <iso8601>] [--max-events <n>] [--json]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,22 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -49,6 +60,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
CallOptions callOptions)
|
||||
{
|
||||
CloseSessionCalls.Add((request, callOptions));
|
||||
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return Task.FromResult(CloseSessionReply);
|
||||
}
|
||||
|
||||
@@ -57,6 +73,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
CallOptions callOptions)
|
||||
{
|
||||
InvokeCalls.Add((request, callOptions));
|
||||
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return Task.FromResult(_invokeReplies.Dequeue());
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Client.Cli;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
@@ -14,7 +16,7 @@ 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());
|
||||
}
|
||||
@@ -28,7 +30,7 @@ public sealed class MxGatewayClientCliTests
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("\"gatewayProtocolVersion\":1", output.ToString());
|
||||
Assert.Contains("\"gatewayProtocolVersion\":2", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
@@ -170,6 +172,191 @@ public sealed class MxGatewayClientCliTests
|
||||
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();
|
||||
@@ -237,5 +424,63 @@ public sealed class MxGatewayClientCliTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
@@ -137,6 +138,44 @@ public sealed class MxGatewayClientSessionTests
|
||||
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()
|
||||
{
|
||||
@@ -182,6 +221,55 @@ public sealed class MxGatewayClientSessionTests
|
||||
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()
|
||||
{
|
||||
@@ -214,4 +302,9 @@ public sealed class MxGatewayClientSessionTests
|
||||
ApiKey = "test-api-key",
|
||||
});
|
||||
}
|
||||
|
||||
private static RpcException CreateTransientRpcException()
|
||||
{
|
||||
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception);
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception);
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception);
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception);
|
||||
throw MapRpcException(exception, effectiveCancellationToken);
|
||||
}
|
||||
|
||||
yield return gatewayEvent;
|
||||
@@ -101,8 +101,18 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
return StreamEventsAsync(request, callOptions);
|
||||
}
|
||||
|
||||
private static MxGatewayException MapRpcException(RpcException exception)
|
||||
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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -11,6 +16,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
{
|
||||
private readonly GrpcChannel _channel;
|
||||
private readonly IMxGatewayClientTransport _transport;
|
||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||
private bool _disposed;
|
||||
|
||||
internal MxGatewayClient(
|
||||
@@ -22,6 +28,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
|
||||
Options = options;
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||
options.Retry,
|
||||
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
||||
_channel = null!;
|
||||
}
|
||||
|
||||
@@ -32,6 +41,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
_channel = channel;
|
||||
_transport = transport;
|
||||
Options = transport.Options;
|
||||
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||
Options.Retry,
|
||||
Options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
||||
}
|
||||
|
||||
public MxGatewayClientOptions Options { get; }
|
||||
@@ -45,11 +57,15 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
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(
|
||||
@@ -88,7 +104,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return _transport.CloseSessionAsync(request, CreateCallOptions(cancellationToken));
|
||||
return ExecuteSafeUnaryAsync(
|
||||
token => _transport.CloseSessionAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MxCommandReply> InvokeAsync(
|
||||
@@ -98,6 +116,13 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -108,7 +133,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return _transport.StreamEventsAsync(request, CreateCallOptions(cancellationToken));
|
||||
return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
@@ -124,6 +149,18 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -132,10 +169,63 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
|
||||
return new CallOptions(
|
||||
headers,
|
||||
DateTime.UtcNow.Add(Options.DefaultCallTimeout),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,194 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
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,
|
||||
@@ -297,4 +485,5 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,29 @@ 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
|
||||
@@ -109,3 +132,116 @@ dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint htt
|
||||
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)
|
||||
|
||||
+134
-4
@@ -44,6 +44,24 @@ 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
|
||||
@@ -58,10 +76,94 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||
```
|
||||
|
||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. 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.
|
||||
`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
|
||||
|
||||
@@ -77,7 +179,35 @@ go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -pl
|
||||
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
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
@@ -77,12 +80,24 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
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])
|
||||
@@ -268,6 +283,60 @@ func runAdvise(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
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)
|
||||
@@ -328,10 +397,12 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
events, err := session.EventsAfter(streamCtx, *after)
|
||||
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer subscription.Close()
|
||||
events := subscription.Events()
|
||||
|
||||
count := 0
|
||||
for result := range events {
|
||||
@@ -377,17 +448,19 @@ func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close(context.Background())
|
||||
|
||||
serverHandle, err := session.Register(ctx, *clientName)
|
||||
if err != nil {
|
||||
return err
|
||||
return closeSmokeSession(ctx, session, err)
|
||||
}
|
||||
itemHandle, err := session.AddItem(ctx, serverHandle, *item)
|
||||
if err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
|
||||
@@ -406,6 +479,53 @@ func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) erro
|
||||
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")
|
||||
@@ -507,6 +627,21 @@ func writeCommandOutput(stdout io.Writer, jsonOutput bool, command string, optio
|
||||
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("", " ")
|
||||
@@ -526,5 +661,226 @@ type protojsonMessage interface {
|
||||
}
|
||||
|
||||
func writeUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|write|stream-events|smoke>")
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -16,6 +16,7 @@ import (
|
||||
const (
|
||||
defaultDialTimeout = 10 * time.Second
|
||||
defaultCallTimeout = 30 * time.Second
|
||||
defaultMaxGrpcMessageBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Client owns a gateway gRPC connection and exposes session-oriented helpers.
|
||||
@@ -50,6 +51,10 @@ func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||
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...)
|
||||
@@ -62,6 +67,13 @@ func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||
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 {
|
||||
@@ -184,8 +196,11 @@ func (c *Client) callContext(ctx context.Context) (context.Context, context.Canc
|
||||
if timeout < 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,76 @@ func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -117,6 +187,49 @@ func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -188,12 +301,14 @@ func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
|
||||
type fakeGatewayServer struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
|
||||
openReply *pb.OpenSessionReply
|
||||
openAuth string
|
||||
streamAuth string
|
||||
streamStarted chan struct{}
|
||||
invokeReply *pb.MxCommandReply
|
||||
invokeRequest *pb.MxCommandRequest
|
||||
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) {
|
||||
@@ -234,15 +349,24 @@ func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest
|
||||
|
||||
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)
|
||||
}
|
||||
if err := stream.Send(&pb.MxEvent{
|
||||
SessionId: req.GetSessionId(),
|
||||
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
||||
WorkerSequence: 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type Options struct {
|
||||
ServerNameOverride string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
MaxGrpcMessageBytes int
|
||||
TLSConfig *tls.Config
|
||||
TransportCredentials credentials.TransportCredentials
|
||||
DialOptions []grpc.DialOption
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
@@ -13,12 +14,38 @@ import (
|
||||
"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
|
||||
@@ -104,6 +131,25 @@ func (s *Session) Unregister(ctx context.Context, serverHandle int32) error {
|
||||
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)
|
||||
@@ -182,6 +228,163 @@ func (s *Session) AdviseRaw(ctx context.Context, serverHandle, itemHandle int32)
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -215,32 +418,99 @@ func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
|
||||
|
||||
// EventsAfter streams ordered session events after the given worker sequence.
|
||||
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
|
||||
stream, err := s.client.StreamEventsRaw(ctx, &pb.StreamEventsRequest{
|
||||
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 {
|
||||
results <- EventResult{Event: event}
|
||||
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == io.EOF || status.Code(err) == codes.Canceled || ctx.Err() != nil {
|
||||
if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
results <- EventResult{Err: &GatewayError{Op: "stream events", Err: err}}
|
||||
sendEventResult(
|
||||
streamCtx,
|
||||
results,
|
||||
EventResult{Err: &GatewayError{Op: "stream events", Err: err}},
|
||||
cancelWhenResultBufferFull,
|
||||
cancel)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return results, nil
|
||||
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) {
|
||||
|
||||
@@ -12,30 +12,40 @@ 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
|
||||
AdviseCommand = pb.AdviseCommand
|
||||
WriteCommand = pb.WriteCommand
|
||||
Write2Command = pb.Write2Command
|
||||
RegisterReply = pb.RegisterReply
|
||||
AddItemReply = pb.AddItemReply
|
||||
AddItem2Reply = pb.AddItem2Reply
|
||||
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 (
|
||||
@@ -49,13 +59,21 @@ type (
|
||||
)
|
||||
|
||||
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
|
||||
CommandKindAdvise = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE
|
||||
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+192
-5
@@ -1,8 +1,7 @@
|
||||
# Java Client
|
||||
|
||||
The Java client workspace contains the Gradle scaffold for the MXAccess Gateway
|
||||
client library, generated protobuf/gRPC bindings, a test CLI project, and JUnit
|
||||
tests.
|
||||
The Java client workspace contains the MXAccess Gateway client library,
|
||||
generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -20,8 +19,171 @@ clients/java/
|
||||
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 used by later CLI implementation work.
|
||||
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
|
||||
|
||||
@@ -32,10 +194,35 @@ gradle test
|
||||
```
|
||||
|
||||
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
|
||||
code, and runs JUnit 5 tests for the scaffold and CLI entry point.
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,8 @@ plugins {
|
||||
}
|
||||
|
||||
ext {
|
||||
guavaVersion = '33.5.0-jre'
|
||||
gsonVersion = '2.13.2'
|
||||
grpcVersion = '1.76.0'
|
||||
junitVersion = '5.14.1'
|
||||
picocliVersion = '4.7.7'
|
||||
|
||||
@@ -4,6 +4,7 @@ plugins {
|
||||
|
||||
dependencies {
|
||||
implementation project(':mxgateway-client')
|
||||
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||
implementation "info.picocli:picocli:${picocliVersion}"
|
||||
}
|
||||
|
||||
|
||||
+918
-10
@@ -1,29 +1,72 @@
|
||||
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.",
|
||||
subcommands = MxGatewayCli.VersionCommand.class)
|
||||
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 = new CommandLine(new MxGatewayCli()).execute(args);
|
||||
int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
public static int execute(PrintWriter out, PrintWriter err, String... args) {
|
||||
CommandLine commandLine = new CommandLine(new MxGatewayCli());
|
||||
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);
|
||||
@@ -35,19 +78,884 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Command(name = "version", description = "Prints the Java client scaffold version.")
|
||||
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() {
|
||||
spec.commandLine().getOut().printf(
|
||||
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
|
||||
MxGatewayClientVersion.clientVersion(),
|
||||
MxGatewayClientVersion.gatewayProtocolVersion(),
|
||||
MxGatewayClientVersion.workerProtocolVersion());
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
+289
-7
@@ -1,27 +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),
|
||||
"version");
|
||||
args);
|
||||
return new CliRun(exitCode, output.toString(), errors.toString());
|
||||
}
|
||||
|
||||
assertEquals(0, exitCode);
|
||||
assertEquals("", errors.toString());
|
||||
assertTrue(output.toString().contains("mxgateway-java 0.1.0"));
|
||||
assertTrue(output.toString().contains("gatewayProtocolVersion=1"));
|
||||
assertTrue(output.toString().contains("workerProtocolVersion=1"));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,19 @@ plugins {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -19,6 +25,7 @@ sourceSets {
|
||||
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
|
||||
include 'mxaccess_gateway.proto'
|
||||
include 'mxaccess_worker.proto'
|
||||
include 'galaxy_repository.proto'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+323
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public final class MxGatewayAuthenticationException extends MxGatewayException {
|
||||
public MxGatewayAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public final class MxGatewayAuthorizationException extends MxGatewayException {
|
||||
public MxGatewayAuthorizationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+242
@@ -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;
|
||||
}
|
||||
}
|
||||
+177
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public final class MxGatewayClientVersion {
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 1;
|
||||
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";
|
||||
|
||||
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+286
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
@@ -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.
|
||||
}
|
||||
}
|
||||
+426
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+282
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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();
|
||||
}
|
||||
}
|
||||
+641
@@ -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;
|
||||
}
|
||||
}
|
||||
+10524
File diff suppressed because it is too large
Load Diff
+8568
-293
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"fixtureSet": "mxaccess-gateway-parity-fixture-matrix",
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 2,
|
||||
"workerProtocolVersion": 1,
|
||||
"sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures",
|
||||
"sourceDocs": [
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fixtureSet": "mxaccess-gateway-cross-language-smoke-matrix",
|
||||
"description": "Documented command matrix for opt-in cross-language client smoke runs against a live gateway.",
|
||||
"integrationGate": {
|
||||
"variable": "MXGATEWAY_INTEGRATION",
|
||||
"requiredValue": "1"
|
||||
},
|
||||
"defaultInputs": {
|
||||
"endpointVariable": "MXGATEWAY_ENDPOINT",
|
||||
"endpointFallback": "localhost:5000",
|
||||
"apiKeyVariable": "MXGATEWAY_API_KEY",
|
||||
"itemVariable": "MXGATEWAY_TEST_ITEM",
|
||||
"itemFallback": "TestChildObject.TestInt",
|
||||
"eventLimit": 1,
|
||||
"optionalWriteValueVariable": "MXGATEWAY_TEST_WRITE_VALUE",
|
||||
"optionalWriteType": "int32"
|
||||
},
|
||||
"requiredOperations": [
|
||||
"open-session",
|
||||
"register",
|
||||
"add-item",
|
||||
"advise",
|
||||
"stream-events",
|
||||
"close-session"
|
||||
],
|
||||
"optionalOperations": [
|
||||
"write"
|
||||
],
|
||||
"jsonComparison": {
|
||||
"requiredOutputMode": "json",
|
||||
"commonFields": [
|
||||
"language",
|
||||
"operation",
|
||||
"sessionId",
|
||||
"serverHandle",
|
||||
"itemHandle",
|
||||
"events",
|
||||
"closeStatus"
|
||||
],
|
||||
"comparisonFields": [
|
||||
"sessionId",
|
||||
"serverHandle",
|
||||
"itemHandle",
|
||||
"eventCount",
|
||||
"eventFamily",
|
||||
"workerSequence",
|
||||
"protocolStatus",
|
||||
"hresult",
|
||||
"statuses"
|
||||
]
|
||||
},
|
||||
"failureOutput": {
|
||||
"requiredContextFields": [
|
||||
"language",
|
||||
"endpoint",
|
||||
"authContext"
|
||||
],
|
||||
"authContext": {
|
||||
"sourceVariable": "MXGATEWAY_API_KEY",
|
||||
"redactedValue": "<redacted>",
|
||||
"forbiddenLiterals": [
|
||||
"mxgw_visible_secret",
|
||||
"Bearer mxgw_visible_secret"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"language": "dotnet",
|
||||
"displayName": ".NET",
|
||||
"workingDirectory": ".",
|
||||
"integrationSkip": {
|
||||
"variable": "MXGATEWAY_INTEGRATION",
|
||||
"requiredValue": "1"
|
||||
},
|
||||
"failureContextFields": [
|
||||
"language",
|
||||
"endpoint",
|
||||
"authContext"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"operation": "open-session",
|
||||
"command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --client-name mxgw-dotnet-smoke --json"
|
||||
},
|
||||
{
|
||||
"operation": "register",
|
||||
"command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --client-name mxgw-dotnet-smoke --json"
|
||||
},
|
||||
{
|
||||
"operation": "add-item",
|
||||
"command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --server-handle <server-handle> --item TestChildObject.TestInt --json"
|
||||
},
|
||||
{
|
||||
"operation": "advise",
|
||||
"command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --json"
|
||||
},
|
||||
{
|
||||
"operation": "stream-events",
|
||||
"command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --max-events 1 --json"
|
||||
},
|
||||
{
|
||||
"operation": "close-session",
|
||||
"command": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- close-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --json"
|
||||
}
|
||||
],
|
||||
"optionalWriteCommand": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --type int32 --value <write-value> --json",
|
||||
"bundledSmokeCommand": "dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json"
|
||||
},
|
||||
{
|
||||
"language": "go",
|
||||
"displayName": "Go",
|
||||
"workingDirectory": "clients/go",
|
||||
"integrationSkip": {
|
||||
"variable": "MXGATEWAY_INTEGRATION",
|
||||
"requiredValue": "1"
|
||||
},
|
||||
"failureContextFields": [
|
||||
"language",
|
||||
"endpoint",
|
||||
"authContext"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"operation": "open-session",
|
||||
"command": "go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -client-session-name mxgw-go-smoke -plaintext -json"
|
||||
},
|
||||
{
|
||||
"operation": "register",
|
||||
"command": "go run ./cmd/mxgw-go register -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id <session-id> -client-name mxgw-go-smoke -plaintext -json"
|
||||
},
|
||||
{
|
||||
"operation": "add-item",
|
||||
"command": "go run ./cmd/mxgw-go add-item -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id <session-id> -server-handle <server-handle> -item TestChildObject.TestInt -plaintext -json"
|
||||
},
|
||||
{
|
||||
"operation": "advise",
|
||||
"command": "go run ./cmd/mxgw-go advise -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id <session-id> -server-handle <server-handle> -item-handle <item-handle> -plaintext -json"
|
||||
},
|
||||
{
|
||||
"operation": "stream-events",
|
||||
"command": "go run ./cmd/mxgw-go stream-events -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id <session-id> -limit 1 -plaintext -json"
|
||||
},
|
||||
{
|
||||
"operation": "close-session",
|
||||
"command": "go run ./cmd/mxgw-go close-session -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id <session-id> -plaintext -json"
|
||||
}
|
||||
],
|
||||
"optionalWriteCommand": "go run ./cmd/mxgw-go write -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -session-id <session-id> -server-handle <server-handle> -item-handle <item-handle> -type int32 -value <write-value> -plaintext -json",
|
||||
"bundledSmokeCommand": "go run ./cmd/mxgw-go smoke -endpoint localhost:5000 -api-key-env MXGATEWAY_API_KEY -item TestChildObject.TestInt -plaintext -json"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"displayName": "Rust",
|
||||
"workingDirectory": "clients/rust",
|
||||
"integrationSkip": {
|
||||
"variable": "MXGATEWAY_INTEGRATION",
|
||||
"requiredValue": "1"
|
||||
},
|
||||
"failureContextFields": [
|
||||
"language",
|
||||
"endpoint",
|
||||
"authContext"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"operation": "open-session",
|
||||
"command": "cargo run -p mxgw-cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --client-name mxgw-rust-smoke --json"
|
||||
},
|
||||
{
|
||||
"operation": "register",
|
||||
"command": "cargo run -p mxgw-cli -- register --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --client-name mxgw-rust-smoke --json"
|
||||
},
|
||||
{
|
||||
"operation": "add-item",
|
||||
"command": "cargo run -p mxgw-cli -- add-item --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --server-handle <server-handle> --item TestChildObject.TestInt --json"
|
||||
},
|
||||
{
|
||||
"operation": "advise",
|
||||
"command": "cargo run -p mxgw-cli -- advise --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --json"
|
||||
},
|
||||
{
|
||||
"operation": "stream-events",
|
||||
"command": "cargo run -p mxgw-cli -- stream-events --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --max-events 1 --json"
|
||||
},
|
||||
{
|
||||
"operation": "close-session",
|
||||
"command": "cargo run -p mxgw-cli -- close-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --json"
|
||||
}
|
||||
],
|
||||
"optionalWriteCommand": "cargo run -p mxgw-cli -- write --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --value-type int32 --value <write-value> --json",
|
||||
"bundledSmokeCommand": "cargo run -p mxgw-cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json"
|
||||
},
|
||||
{
|
||||
"language": "python",
|
||||
"displayName": "Python",
|
||||
"workingDirectory": "clients/python",
|
||||
"integrationSkip": {
|
||||
"variable": "MXGATEWAY_INTEGRATION",
|
||||
"requiredValue": "1"
|
||||
},
|
||||
"failureContextFields": [
|
||||
"language",
|
||||
"endpoint",
|
||||
"authContext"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"operation": "open-session",
|
||||
"command": "mxgw-py open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-name mxgw-py-smoke --json"
|
||||
},
|
||||
{
|
||||
"operation": "register",
|
||||
"command": "mxgw-py register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --client-name mxgw-py-smoke --json"
|
||||
},
|
||||
{
|
||||
"operation": "add-item",
|
||||
"command": "mxgw-py add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item TestChildObject.TestInt --json"
|
||||
},
|
||||
{
|
||||
"operation": "advise",
|
||||
"command": "mxgw-py advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --json"
|
||||
},
|
||||
{
|
||||
"operation": "stream-events",
|
||||
"command": "mxgw-py stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --max-events 1 --json"
|
||||
},
|
||||
{
|
||||
"operation": "close-session",
|
||||
"command": "mxgw-py close-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --json"
|
||||
}
|
||||
],
|
||||
"optionalWriteCommand": "mxgw-py write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --type int32 --value <write-value> --json",
|
||||
"bundledSmokeCommand": "mxgw-py smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestChildObject.TestInt --max-events 1 --json"
|
||||
},
|
||||
{
|
||||
"language": "java",
|
||||
"displayName": "Java",
|
||||
"workingDirectory": "clients/java",
|
||||
"integrationSkip": {
|
||||
"variable": "MXGATEWAY_INTEGRATION",
|
||||
"requiredValue": "1"
|
||||
},
|
||||
"failureContextFields": [
|
||||
"language",
|
||||
"endpoint",
|
||||
"authContext"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"operation": "open-session",
|
||||
"command": "gradle :mxgateway-cli:run --args=\"open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name mxgw-java-smoke --json\""
|
||||
},
|
||||
{
|
||||
"operation": "register",
|
||||
"command": "gradle :mxgateway-cli:run --args=\"register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --client-name mxgw-java-smoke --json\""
|
||||
},
|
||||
{
|
||||
"operation": "add-item",
|
||||
"command": "gradle :mxgateway-cli:run --args=\"add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item TestChildObject.TestInt --json\""
|
||||
},
|
||||
{
|
||||
"operation": "advise",
|
||||
"command": "gradle :mxgateway-cli:run --args=\"advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --json\""
|
||||
},
|
||||
{
|
||||
"operation": "stream-events",
|
||||
"command": "gradle :mxgateway-cli:run --args=\"stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --limit 1 --json\""
|
||||
},
|
||||
{
|
||||
"operation": "close-session",
|
||||
"command": "gradle :mxgateway-cli:run --args=\"close-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --json\""
|
||||
}
|
||||
],
|
||||
"optionalWriteCommand": "gradle :mxgateway-cli:run --args=\"write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --type int32 --value <write-value> --json\"",
|
||||
"bundledSmokeCommand": "gradle :mxgateway-cli:run --args=\"smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestChildObject.TestInt --json\""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 2,
|
||||
"workerProtocolVersion": 1,
|
||||
"protoRoot": "src/MxGateway.Contracts/Protos",
|
||||
"sourceFiles": [
|
||||
@@ -12,6 +12,10 @@
|
||||
{
|
||||
"path": "mxaccess_worker.proto",
|
||||
"role": "gateway_worker_ipc"
|
||||
},
|
||||
{
|
||||
"path": "galaxy_repository.proto",
|
||||
"role": "public_galaxy_repository"
|
||||
}
|
||||
],
|
||||
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
|
||||
|
||||
+117
-1
@@ -47,6 +47,28 @@ The tests import the generated gateway and worker stubs, run fake async gateway
|
||||
stubs, verify API key metadata, exercise stream cancellation, load shared value
|
||||
and command fixtures, and check deterministic CLI output.
|
||||
|
||||
## Packaging
|
||||
|
||||
Install the package in editable mode for local development:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Build a wheel from `clients/python`:
|
||||
|
||||
```powershell
|
||||
python -m pip wheel . --no-deps --wheel-dir "$env:TEMP\mxgateway-python-wheel"
|
||||
```
|
||||
|
||||
Install the generated wheel into a target environment:
|
||||
|
||||
```powershell
|
||||
python -m pip install <wheel-path>
|
||||
```
|
||||
|
||||
The wheel exposes the `mxgw-py` console script.
|
||||
|
||||
## Library Usage
|
||||
|
||||
The library is async-first:
|
||||
@@ -56,7 +78,7 @@ from mxgateway import GatewayClient
|
||||
|
||||
async with await GatewayClient.connect(
|
||||
endpoint="localhost:5000",
|
||||
api_key="mxgw_example",
|
||||
api_key="<gateway-api-key>",
|
||||
plaintext=True,
|
||||
) as client:
|
||||
session = await client.open_session(client_session_name="python-client")
|
||||
@@ -76,6 +98,76 @@ MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
## Galaxy Repository Browse
|
||||
|
||||
The `GalaxyRepositoryClient` wraps the read-only `GalaxyRepository` gRPC
|
||||
service. It lets callers test connectivity to the AVEVA System Platform
|
||||
Galaxy Repository (ZB SQL database), read the last deploy timestamp, and
|
||||
enumerate the deployed object hierarchy plus each object's dynamic
|
||||
attributes:
|
||||
|
||||
```python
|
||||
from mxgateway import GalaxyRepositoryClient
|
||||
|
||||
async with await GalaxyRepositoryClient.connect(
|
||||
endpoint="localhost:5000",
|
||||
api_key="<gateway-api-key>",
|
||||
plaintext=True,
|
||||
) as galaxy:
|
||||
if not await galaxy.test_connection():
|
||||
raise RuntimeError("gateway cannot reach the Galaxy Repository DB")
|
||||
|
||||
last_deploy = await galaxy.get_last_deploy_time()
|
||||
print(f"last deploy: {last_deploy}")
|
||||
|
||||
for obj in await galaxy.discover_hierarchy():
|
||||
print(obj.tag_name, obj.contained_name)
|
||||
for attr in obj.attributes:
|
||||
print(" ", attr.attribute_name, "->", attr.full_tag_reference)
|
||||
```
|
||||
|
||||
The methods return native Python types (`bool`, `datetime | None`, and a
|
||||
`list[GalaxyObject]` of generated proto messages) so callers can index
|
||||
into the hierarchy without learning the underlying stub class. The
|
||||
service requires the `metadata:read` scope on the API key.
|
||||
|
||||
### Watching deploy events
|
||||
|
||||
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
||||
subscription that emits the current cached deploy state immediately and
|
||||
then one `DeployEvent` per new Galaxy deploy. `sequence` is monotonic per
|
||||
gateway start; gaps mean events were dropped from the per-subscriber
|
||||
buffer. Pass `last_seen_deploy_time` to suppress the bootstrap event when
|
||||
the caller already has the current state cached:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from mxgateway import DeployEvent, GalaxyRepositoryClient
|
||||
|
||||
async with await GalaxyRepositoryClient.connect(
|
||||
endpoint="localhost:5000",
|
||||
api_key="<gateway-api-key>",
|
||||
plaintext=True,
|
||||
) as galaxy:
|
||||
last_seen: datetime | None = None
|
||||
async for event in galaxy.watch_deploy_events(last_seen_deploy_time=last_seen):
|
||||
assert isinstance(event, DeployEvent)
|
||||
print(
|
||||
f"#{event.sequence} deploy={event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)} "
|
||||
f"objects={event.object_count} attributes={event.attribute_count}"
|
||||
)
|
||||
if event.time_of_last_deploy_present:
|
||||
last_seen = event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)
|
||||
```
|
||||
|
||||
The method returns an async iterator yielding the generated `DeployEvent`
|
||||
proto. Breaking out of the loop, calling `aclose()` on the iterator, or
|
||||
cancelling the surrounding task closes the underlying gRPC stream
|
||||
cleanly. The streaming RPC requires the same `metadata:read` scope as
|
||||
the other Galaxy methods. The CLI does not currently expose a
|
||||
streaming `watch-deploy-events` subcommand — use the library API
|
||||
directly when subscribing to deploy events from Python.
|
||||
|
||||
## Authentication And TLS
|
||||
|
||||
`ClientOptions.api_key` adds this metadata to unary calls and streams:
|
||||
@@ -105,3 +197,27 @@ mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 -
|
||||
Use `--api-key` or `--api-key-env MXGATEWAY_API_KEY` to attach API key
|
||||
metadata. `smoke` opens a session, registers, adds an item, advises, streams a
|
||||
bounded event count, and closes the session in a `finally` block.
|
||||
|
||||
Use TLS options for a secured gateway:
|
||||
|
||||
```powershell
|
||||
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --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 = 'Object.Attribute'
|
||||
mxgw-py 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)
|
||||
- [Python Client Detailed Design](../../docs/clients-python-design.md)
|
||||
|
||||
@@ -19,4 +19,5 @@ Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
|
||||
"--python_out=$outputRoot" `
|
||||
"--grpc_python_out=$outputRoot" `
|
||||
mxaccess_gateway.proto `
|
||||
mxaccess_worker.proto
|
||||
mxaccess_worker.proto `
|
||||
galaxy_repository.proto
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
from .auth import ApiKey, auth_metadata
|
||||
from .client import GatewayClient
|
||||
from .galaxy import GalaxyRepositoryClient
|
||||
from .generated.galaxy_repository_pb2 import (
|
||||
DeployEvent,
|
||||
GalaxyAttribute,
|
||||
GalaxyObject,
|
||||
WatchDeployEventsRequest,
|
||||
)
|
||||
from .errors import (
|
||||
MxAccessError,
|
||||
MxGatewayAuthenticationError,
|
||||
@@ -20,6 +27,10 @@ from .version import __version__
|
||||
__all__ = [
|
||||
"ApiKey",
|
||||
"ClientOptions",
|
||||
"DeployEvent",
|
||||
"GalaxyAttribute",
|
||||
"GalaxyObject",
|
||||
"GalaxyRepositoryClient",
|
||||
"GatewayClient",
|
||||
"MxAccessError",
|
||||
"MxGatewayAuthenticationError",
|
||||
@@ -31,6 +42,7 @@ __all__ = [
|
||||
"MxGatewayWorkerError",
|
||||
"MxValueView",
|
||||
"Session",
|
||||
"WatchDeployEventsRequest",
|
||||
"__version__",
|
||||
"auth_metadata",
|
||||
"from_mx_value",
|
||||
|
||||
@@ -74,9 +74,9 @@ class GatewayClient:
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._closed = True
|
||||
|
||||
async def open_session(
|
||||
self,
|
||||
@@ -124,10 +124,10 @@ class GatewayClient:
|
||||
) -> AsyncIterator[pb.MxEvent]:
|
||||
"""Return an async event iterator and cancel the stream when iteration stops."""
|
||||
|
||||
call = self.raw_stub.StreamEvents(
|
||||
request,
|
||||
metadata=merge_metadata(self.options.api_key, metadata),
|
||||
)
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
call = self.raw_stub.StreamEvents(request, **kwargs)
|
||||
return _canceling_iterator(call)
|
||||
|
||||
async def _unary(
|
||||
@@ -138,10 +138,16 @@ class GatewayClient:
|
||||
*,
|
||||
metadata: Sequence[tuple[str, str]] | None = None,
|
||||
) -> Any:
|
||||
call = method(
|
||||
request,
|
||||
metadata=merge_metadata(self.options.api_key, metadata),
|
||||
)
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.call_timeout is not None:
|
||||
kwargs["timeout"] = self.options.call_timeout
|
||||
try:
|
||||
call = method(request, **kwargs)
|
||||
except TypeError as error:
|
||||
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
|
||||
raise
|
||||
kwargs.pop("timeout")
|
||||
call = method(request, **kwargs)
|
||||
try:
|
||||
return await call
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Async Galaxy Repository client wrapper.
|
||||
|
||||
Wraps the read-only ``GalaxyRepository`` gRPC service exposed by the
|
||||
MxAccess Gateway. The service lets callers test connectivity to the AVEVA
|
||||
System Platform Galaxy Repository (ZB SQL database), read the last
|
||||
deployment timestamp, and enumerate the deployed object hierarchy plus the
|
||||
attributes on each object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
from .options import ClientOptions, create_channel
|
||||
|
||||
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
|
||||
|
||||
|
||||
class GalaxyRepositoryClient:
|
||||
"""Async client for the Galaxy Repository gRPC service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
options: ClientOptions,
|
||||
stub: Any,
|
||||
channel: grpc.aio.Channel | None = None,
|
||||
) -> None:
|
||||
self.options = options
|
||||
self.raw_stub = stub
|
||||
self._channel = channel
|
||||
self._closed = False
|
||||
|
||||
@classmethod
|
||||
async def connect(
|
||||
cls,
|
||||
options: ClientOptions | None = None,
|
||||
*,
|
||||
endpoint: str | None = None,
|
||||
api_key: str | None = None,
|
||||
plaintext: bool = False,
|
||||
ca_file: str | None = None,
|
||||
server_name_override: str | None = None,
|
||||
stub: Any | None = None,
|
||||
) -> "GalaxyRepositoryClient":
|
||||
"""Create a client with either a real async channel or a supplied fake stub."""
|
||||
|
||||
resolved = options or ClientOptions(
|
||||
endpoint=endpoint or "",
|
||||
api_key=api_key,
|
||||
plaintext=plaintext,
|
||||
ca_file=ca_file,
|
||||
server_name_override=server_name_override,
|
||||
)
|
||||
|
||||
if stub is not None:
|
||||
return cls(options=resolved, stub=stub)
|
||||
|
||||
channel = create_channel(resolved)
|
||||
return cls(
|
||||
options=resolved,
|
||||
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> "GalaxyRepositoryClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the owned gRPC channel."""
|
||||
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._closed = True
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Return ``True`` when the gateway can reach the Galaxy Repository DB."""
|
||||
|
||||
reply = await self._unary(
|
||||
"test connection",
|
||||
self.raw_stub.TestConnection,
|
||||
galaxy_pb.TestConnectionRequest(),
|
||||
)
|
||||
return bool(reply.ok)
|
||||
|
||||
async def get_last_deploy_time(self) -> datetime | None:
|
||||
"""Return the last Galaxy deploy timestamp or ``None`` when unset."""
|
||||
|
||||
reply = await self._unary(
|
||||
"get last deploy time",
|
||||
self.raw_stub.GetLastDeployTime,
|
||||
galaxy_pb.GetLastDeployTimeRequest(),
|
||||
)
|
||||
if not reply.present:
|
||||
return None
|
||||
return reply.time_of_last_deploy.ToDatetime()
|
||||
|
||||
async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]:
|
||||
"""Return the deployed Galaxy object hierarchy as raw proto messages."""
|
||||
|
||||
objects: list[galaxy_pb.GalaxyObject] = []
|
||||
seen_page_tokens: set[str] = set()
|
||||
page_token = ""
|
||||
while True:
|
||||
reply = await self._unary(
|
||||
"discover hierarchy",
|
||||
self.raw_stub.DiscoverHierarchy,
|
||||
galaxy_pb.DiscoverHierarchyRequest(
|
||||
page_size=_DISCOVER_HIERARCHY_PAGE_SIZE,
|
||||
page_token=page_token,
|
||||
),
|
||||
)
|
||||
objects.extend(reply.objects)
|
||||
page_token = reply.next_page_token
|
||||
if not page_token:
|
||||
return objects
|
||||
if page_token in seen_page_tokens:
|
||||
raise MxGatewayError(
|
||||
f"galaxy discover hierarchy returned repeated page token {page_token!r}"
|
||||
)
|
||||
seen_page_tokens.add(page_token)
|
||||
|
||||
def watch_deploy_events(
|
||||
self,
|
||||
last_seen_deploy_time: datetime | None = None,
|
||||
*,
|
||||
metadata: Sequence[tuple[str, str]] | None = None,
|
||||
) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||
"""Stream Galaxy deploy events.
|
||||
|
||||
On subscribe the gateway emits the current cached state and then one
|
||||
event per new deploy time. ``sequence`` is monotonic per server start;
|
||||
gaps mean events were dropped from the per-subscriber buffer. When
|
||||
``last_seen_deploy_time`` is supplied and matches the current cached
|
||||
deploy time the bootstrap event is suppressed.
|
||||
"""
|
||||
|
||||
request = galaxy_pb.WatchDeployEventsRequest()
|
||||
if last_seen_deploy_time is not None:
|
||||
timestamp = Timestamp()
|
||||
timestamp.FromDatetime(last_seen_deploy_time)
|
||||
request.last_seen_deploy_time.CopyFrom(timestamp)
|
||||
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
try:
|
||||
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
|
||||
except TypeError as error:
|
||||
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
|
||||
raise
|
||||
kwargs.pop("timeout")
|
||||
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
|
||||
|
||||
return _canceling_iterator(call)
|
||||
|
||||
async def _unary(
|
||||
self,
|
||||
operation: str,
|
||||
method: Any,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: Sequence[tuple[str, str]] | None = None,
|
||||
) -> Any:
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.call_timeout is not None:
|
||||
kwargs["timeout"] = self.options.call_timeout
|
||||
try:
|
||||
call = method(request, **kwargs)
|
||||
except TypeError as error:
|
||||
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
|
||||
raise
|
||||
kwargs.pop("timeout")
|
||||
call = method(request, **kwargs)
|
||||
try:
|
||||
return await call
|
||||
except asyncio.CancelledError:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error(operation, error) from error
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||
try:
|
||||
async for event in call:
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("watch deploy events", error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
@@ -21,7 +21,15 @@ sys.modules.setdefault("mxaccess_worker_pb2", mxaccess_worker_pb2)
|
||||
mxaccess_worker_pb2_grpc = import_module(f"{__name__}.mxaccess_worker_pb2_grpc")
|
||||
sys.modules.setdefault("mxaccess_worker_pb2_grpc", mxaccess_worker_pb2_grpc)
|
||||
|
||||
galaxy_repository_pb2 = import_module(f"{__name__}.galaxy_repository_pb2")
|
||||
sys.modules.setdefault("galaxy_repository_pb2", galaxy_repository_pb2)
|
||||
|
||||
galaxy_repository_pb2_grpc = import_module(f"{__name__}.galaxy_repository_pb2_grpc")
|
||||
sys.modules.setdefault("galaxy_repository_pb2_grpc", galaxy_repository_pb2_grpc)
|
||||
|
||||
__all__ = [
|
||||
"galaxy_repository_pb2",
|
||||
"galaxy_repository_pb2_grpc",
|
||||
"mxaccess_gateway_pb2",
|
||||
"mxaccess_gateway_pb2_grpc",
|
||||
"mxaccess_worker_pb2",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: galaxy_repository.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
'galaxy_repository.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
|
||||
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\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\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['DESCRIPTOR']._loaded_options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'\252\002 MxGateway.Contracts.Proto.Galaxy'
|
||||
_globals['_TESTCONNECTIONREQUEST']._serialized_start=114
|
||||
_globals['_TESTCONNECTIONREQUEST']._serialized_end=137
|
||||
_globals['_TESTCONNECTIONREPLY']._serialized_start=139
|
||||
_globals['_TESTCONNECTIONREPLY']._serialized_end=172
|
||||
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=174
|
||||
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=200
|
||||
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=202
|
||||
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=300
|
||||
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=303
|
||||
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=694
|
||||
_globals['_DISCOVERHIERARCHYREPLY']._serialized_start=697
|
||||
_globals['_DISCOVERHIERARCHYREPLY']._serialized_end=827
|
||||
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=829
|
||||
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=914
|
||||
_globals['_DEPLOYEVENT']._serialized_start=917
|
||||
_globals['_DEPLOYEVENT']._serialized_end=1138
|
||||
_globals['_GALAXYOBJECT']._serialized_start=1141
|
||||
_globals['_GALAXYOBJECT']._serialized_end=1416
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
|
||||
_globals['_GALAXYREPOSITORY']._serialized_start=1718
|
||||
_globals['_GALAXYREPOSITORY']._serialized_end=2178
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,244 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
import galaxy_repository_pb2 as galaxy__repository__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.80.0'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
try:
|
||||
from grpc._utilities import first_version_is_lower
|
||||
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
||||
except ImportError:
|
||||
_version_not_supported = True
|
||||
|
||||
if _version_not_supported:
|
||||
raise RuntimeError(
|
||||
f'The grpc package installed is at version {GRPC_VERSION},'
|
||||
+ ' but the generated code in galaxy_repository_pb2_grpc.py depends on'
|
||||
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
||||
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
||||
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
||||
)
|
||||
|
||||
|
||||
class GalaxyRepositoryStub(object):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.TestConnection = channel.unary_unary(
|
||||
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
|
||||
request_serializer=galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.TestConnectionReply.FromString,
|
||||
_registered_method=True)
|
||||
self.GetLastDeployTime = channel.unary_unary(
|
||||
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
|
||||
request_serializer=galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
|
||||
_registered_method=True)
|
||||
self.DiscoverHierarchy = channel.unary_unary(
|
||||
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
|
||||
request_serializer=galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
|
||||
_registered_method=True)
|
||||
self.WatchDeployEvents = channel.unary_stream(
|
||||
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
|
||||
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class GalaxyRepositoryServicer(object):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
def TestConnection(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetLastDeployTime(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def DiscoverHierarchy(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def WatchDeployEvents(self, request, context):
|
||||
"""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.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'TestConnection': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.TestConnection,
|
||||
request_deserializer=galaxy__repository__pb2.TestConnectionRequest.FromString,
|
||||
response_serializer=galaxy__repository__pb2.TestConnectionReply.SerializeToString,
|
||||
),
|
||||
'GetLastDeployTime': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetLastDeployTime,
|
||||
request_deserializer=galaxy__repository__pb2.GetLastDeployTimeRequest.FromString,
|
||||
response_serializer=galaxy__repository__pb2.GetLastDeployTimeReply.SerializeToString,
|
||||
),
|
||||
'DiscoverHierarchy': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.DiscoverHierarchy,
|
||||
request_deserializer=galaxy__repository__pb2.DiscoverHierarchyRequest.FromString,
|
||||
response_serializer=galaxy__repository__pb2.DiscoverHierarchyReply.SerializeToString,
|
||||
),
|
||||
'WatchDeployEvents': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.WatchDeployEvents,
|
||||
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
|
||||
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class GalaxyRepository(object):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def TestConnection(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
|
||||
galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
|
||||
galaxy__repository__pb2.TestConnectionReply.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetLastDeployTime(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
|
||||
galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
|
||||
galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def DiscoverHierarchy(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
|
||||
galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
|
||||
galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def WatchDeployEvents(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
|
||||
galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
|
||||
galaxy__repository__pb2.DeployEvent.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
File diff suppressed because one or more lines are too long
@@ -19,6 +19,9 @@ class ClientOptions:
|
||||
plaintext: bool = False
|
||||
ca_file: str | None = None
|
||||
server_name_override: str | None = None
|
||||
call_timeout: float | None = 30.0
|
||||
stream_timeout: float | None = None
|
||||
max_grpc_message_bytes: int = 16 * 1024 * 1024
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.endpoint:
|
||||
@@ -26,6 +29,12 @@ class ClientOptions:
|
||||
|
||||
if self.plaintext and self.ca_file:
|
||||
raise ValueError("ca_file cannot be used with plaintext connections")
|
||||
if self.call_timeout is not None and self.call_timeout <= 0:
|
||||
raise ValueError("call_timeout must be greater than zero")
|
||||
if self.stream_timeout is not None and self.stream_timeout <= 0:
|
||||
raise ValueError("stream_timeout must be greater than zero")
|
||||
if self.max_grpc_message_bytes <= 0:
|
||||
raise ValueError("max_grpc_message_bytes must be greater than zero")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
api_key = REDACTED if self.api_key else None
|
||||
@@ -33,14 +42,20 @@ class ClientOptions:
|
||||
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
||||
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
|
||||
f"ca_file={self.ca_file!r}, "
|
||||
f"server_name_override={self.server_name_override!r})"
|
||||
f"server_name_override={self.server_name_override!r}, "
|
||||
f"call_timeout={self.call_timeout!r}, "
|
||||
f"stream_timeout={self.stream_timeout!r}, "
|
||||
f"max_grpc_message_bytes={self.max_grpc_message_bytes!r})"
|
||||
)
|
||||
|
||||
|
||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
|
||||
|
||||
channel_options: list[tuple[str, str]] = []
|
||||
channel_options: list[tuple[str, str | int]] = [
|
||||
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
||||
("grpc.max_send_message_length", options.max_grpc_message_bytes),
|
||||
]
|
||||
if options.server_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", options.server_name_override))
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
|
||||
from .errors import ensure_mxaccess_success
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .values import MxValueInput, to_mx_value
|
||||
|
||||
MAX_BULK_ITEMS = 1000
|
||||
|
||||
|
||||
class Session:
|
||||
"""A single gateway-backed MXAccess session."""
|
||||
@@ -40,13 +42,14 @@ class Session:
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
|
||||
self._closed = True
|
||||
return await self.client.close_session_raw(
|
||||
reply = await self.client.close_session_raw(
|
||||
pb.CloseSessionRequest(
|
||||
session_id=self.session_id,
|
||||
client_correlation_id=client_correlation_id,
|
||||
),
|
||||
)
|
||||
self._closed = True
|
||||
return reply
|
||||
|
||||
async def invoke(self, command: pb.MxCommand, *, correlation_id: str = "") -> pb.MxCommandReply:
|
||||
"""Invoke a raw command and enforce gateway and MXAccess success."""
|
||||
@@ -89,6 +92,24 @@ class Session:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def remove_item(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_REMOVE_ITEM,
|
||||
remove_item=pb.RemoveItemCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def add_item(
|
||||
self,
|
||||
server_handle: int,
|
||||
@@ -147,6 +168,156 @@ class Session:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def unadvise(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_UN_ADVISE,
|
||||
un_advise=pb.UnAdviseCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def add_item_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
tag_addresses: Sequence[str],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.SubscribeResult]:
|
||||
if tag_addresses is None:
|
||||
raise TypeError("tag_addresses is required")
|
||||
_ensure_bulk_size("tag_addresses", len(tag_addresses))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADD_ITEM_BULK,
|
||||
add_item_bulk=pb.AddItemBulkCommand(
|
||||
server_handle=server_handle,
|
||||
tag_addresses=tag_addresses,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.add_item_bulk.results)
|
||||
|
||||
async def advise_item_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handles: Sequence[int],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.SubscribeResult]:
|
||||
if item_handles is None:
|
||||
raise TypeError("item_handles is required")
|
||||
_ensure_bulk_size("item_handles", len(item_handles))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE_ITEM_BULK,
|
||||
advise_item_bulk=pb.AdviseItemBulkCommand(
|
||||
server_handle=server_handle,
|
||||
item_handles=item_handles,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.advise_item_bulk.results)
|
||||
|
||||
async def remove_item_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handles: Sequence[int],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.SubscribeResult]:
|
||||
if item_handles is None:
|
||||
raise TypeError("item_handles is required")
|
||||
_ensure_bulk_size("item_handles", len(item_handles))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_REMOVE_ITEM_BULK,
|
||||
remove_item_bulk=pb.RemoveItemBulkCommand(
|
||||
server_handle=server_handle,
|
||||
item_handles=item_handles,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.remove_item_bulk.results)
|
||||
|
||||
async def unadvise_item_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handles: Sequence[int],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.SubscribeResult]:
|
||||
if item_handles is None:
|
||||
raise TypeError("item_handles is required")
|
||||
_ensure_bulk_size("item_handles", len(item_handles))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK,
|
||||
un_advise_item_bulk=pb.UnAdviseItemBulkCommand(
|
||||
server_handle=server_handle,
|
||||
item_handles=item_handles,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.un_advise_item_bulk.results)
|
||||
|
||||
async def subscribe_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
tag_addresses: Sequence[str],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.SubscribeResult]:
|
||||
if tag_addresses is None:
|
||||
raise TypeError("tag_addresses is required")
|
||||
_ensure_bulk_size("tag_addresses", len(tag_addresses))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_SUBSCRIBE_BULK,
|
||||
subscribe_bulk=pb.SubscribeBulkCommand(
|
||||
server_handle=server_handle,
|
||||
tag_addresses=tag_addresses,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.subscribe_bulk.results)
|
||||
|
||||
async def unsubscribe_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handles: Sequence[int],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.SubscribeResult]:
|
||||
if item_handles is None:
|
||||
raise TypeError("item_handles is required")
|
||||
_ensure_bulk_size("item_handles", len(item_handles))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_UNSUBSCRIBE_BULK,
|
||||
unsubscribe_bulk=pb.UnsubscribeBulkCommand(
|
||||
server_handle=server_handle,
|
||||
item_handles=item_handles,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.unsubscribe_bulk.results)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
server_handle: int,
|
||||
@@ -206,4 +377,9 @@ class Session:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
if count > MAX_BULK_ITEMS:
|
||||
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
|
||||
|
||||
|
||||
from .client import GatewayClient # noqa: E402
|
||||
|
||||
@@ -20,6 +20,8 @@ from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.options import ClientOptions
|
||||
from mxgateway.values import MxValueInput
|
||||
|
||||
MAX_AGGREGATE_EVENTS = 10_000
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
@@ -55,6 +57,8 @@ def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
||||
default=None,
|
||||
help="TLS server name override for test environments.",
|
||||
)(command)
|
||||
command = click.option("--call-timeout", default=30.0, type=float, show_default=True)(command)
|
||||
command = click.option("--stream-timeout", default=None, type=float)(command)
|
||||
return command
|
||||
|
||||
|
||||
@@ -146,6 +150,40 @@ def advise(**kwargs: Any) -> None:
|
||||
_run(_advise(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("subscribe-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--items", required=True, help="Comma-separated MXAccess item definitions.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def subscribe_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess SubscribeBulk."""
|
||||
|
||||
_run(
|
||||
_subscribe_bulk(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command("unsubscribe-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def unsubscribe_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess UnsubscribeBulk."""
|
||||
|
||||
_run(
|
||||
_unsubscribe_bulk(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command("stream-events")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -278,6 +316,28 @@ async def _advise(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.subscribe_bulk(
|
||||
kwargs["server_handle"],
|
||||
_parse_string_list(kwargs["items"]),
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _unsubscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.unsubscribe_bulk(
|
||||
kwargs["server_handle"],
|
||||
_parse_int_list(kwargs["item_handles"]),
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
@@ -352,6 +412,8 @@ async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
plaintext=_use_plaintext(kwargs),
|
||||
ca_file=kwargs.get("ca_file"),
|
||||
server_name_override=kwargs.get("server_name_override"),
|
||||
call_timeout=kwargs.get("call_timeout"),
|
||||
stream_timeout=kwargs.get("stream_timeout"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -416,6 +478,12 @@ async def _collect_events(
|
||||
max_events: int,
|
||||
timeout: float,
|
||||
) -> list[pb.MxEvent]:
|
||||
if max_events > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-events",
|
||||
)
|
||||
|
||||
collected: list[pb.MxEvent] = []
|
||||
iterator = events.__aiter__()
|
||||
try:
|
||||
@@ -423,6 +491,8 @@ async def _collect_events(
|
||||
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
@@ -456,6 +526,20 @@ def _parse_datetime(raw_value: str) -> datetime:
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_string_list(raw_value: str) -> list[str]:
|
||||
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if not values:
|
||||
raise click.BadParameter("at least one item is required", param_hint="--items")
|
||||
return values
|
||||
|
||||
|
||||
def _parse_int_list(raw_value: str) -> list[int]:
|
||||
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if not values:
|
||||
raise click.BadParameter("at least one item handle is required", param_hint="--item-handles")
|
||||
return [int(item) for item in values]
|
||||
|
||||
|
||||
def _message_dict(message: Any) -> dict[str, Any]:
|
||||
return MessageToDict(
|
||||
message,
|
||||
|
||||
@@ -61,7 +61,15 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
|
||||
channel = create_channel(ClientOptions(endpoint="localhost:5000", plaintext=True))
|
||||
|
||||
assert channel == "plain-channel"
|
||||
assert calls == [("localhost:5000", [])]
|
||||
assert calls == [
|
||||
(
|
||||
"localhost:5000",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -95,9 +103,13 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
|
||||
assert channel == "tls-channel"
|
||||
assert calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[("grpc.ssl_target_name_override", "gateway.test")],
|
||||
),
|
||||
]
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
("grpc.ssl_target_name_override", "gateway.test"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -58,6 +58,41 @@ async def test_mxaccess_error_preserves_raw_reply() -> None:
|
||||
assert captured.value.raw_reply is failure_reply
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_bulk_sends_one_bulk_command_and_returns_results() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
bulk_reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_SUBSCRIBE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
subscribe_bulk=pb.BulkSubscribeReply(
|
||||
results=[
|
||||
pb.SubscribeResult(
|
||||
server_handle=12,
|
||||
tag_address="Area001.Pump001.Speed",
|
||||
item_handle=34,
|
||||
was_successful=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
stub.invoke.replies = [bulk_reply]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
results = await session.subscribe_bulk(12, ["Area001.Pump001.Speed"])
|
||||
|
||||
assert results[0].item_handle == 34
|
||||
assert len(stub.invoke.requests) == 1
|
||||
assert stub.invoke.requests[0].command.kind == pb.MX_COMMAND_KIND_SUBSCRIBE_BULK
|
||||
assert list(stub.invoke.requests[0].command.subscribe_bulk.tag_addresses) == [
|
||||
"Area001.Pump001.Speed",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_cancels_underlying_call_when_closed() -> None:
|
||||
stream = FakeStream(
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Tests for the Galaxy Repository async client wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
|
||||
from mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
|
||||
|
||||
def test_galaxy_messages_import() -> None:
|
||||
request = galaxy_pb.DiscoverHierarchyRequest()
|
||||
obj = galaxy_pb.GalaxyObject(
|
||||
gobject_id=42,
|
||||
tag_name="DelmiaReceiver_001",
|
||||
contained_name="DelmiaReceiver",
|
||||
browse_name="DelmiaReceiver",
|
||||
parent_gobject_id=10,
|
||||
is_area=False,
|
||||
category_id=4,
|
||||
hosted_by_gobject_id=10,
|
||||
template_chain=["$ApplicationObject", "$DelmiaReceiver"],
|
||||
attributes=[
|
||||
galaxy_pb.GalaxyAttribute(
|
||||
attribute_name="DownloadPath",
|
||||
full_tag_reference="DelmiaReceiver_001.DownloadPath",
|
||||
mx_data_type=8,
|
||||
data_type_name="String",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert request.DESCRIPTOR is not None
|
||||
assert obj.attributes[0].attribute_name == "DownloadPath"
|
||||
assert hasattr(galaxy_pb_grpc, "GalaxyRepositoryStub")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_returns_bool_and_sends_auth() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.test_connection.replies = [galaxy_pb.TestConnectionReply(ok=True)]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
result = await client.test_connection()
|
||||
|
||||
assert result is True
|
||||
assert stub.test_connection.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
assert isinstance(stub.test_connection.requests[0], galaxy_pb.TestConnectionRequest)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_last_deploy_time_returns_datetime_when_present() -> None:
|
||||
timestamp = Timestamp()
|
||||
timestamp.FromDatetime(datetime(2025, 4, 1, 12, 30, 45, tzinfo=timezone.utc))
|
||||
stub = FakeGalaxyStub()
|
||||
stub.get_last_deploy_time.replies = [
|
||||
galaxy_pb.GetLastDeployTimeReply(present=True, time_of_last_deploy=timestamp),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
when = await client.get_last_deploy_time()
|
||||
|
||||
assert when is not None
|
||||
assert when.year == 2025
|
||||
assert when.month == 4
|
||||
assert when.day == 1
|
||||
assert when.hour == 12
|
||||
assert when.minute == 30
|
||||
assert when.second == 45
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_last_deploy_time_returns_none_when_not_present() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.get_last_deploy_time.replies = [galaxy_pb.GetLastDeployTimeReply(present=False)]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
assert await client.get_last_deploy_time() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_hierarchy_returns_proto_objects() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.discover_hierarchy.replies = [
|
||||
galaxy_pb.DiscoverHierarchyReply(
|
||||
next_page_token="page-2",
|
||||
total_object_count=2,
|
||||
objects=[
|
||||
galaxy_pb.GalaxyObject(
|
||||
gobject_id=1,
|
||||
tag_name="TestMachine_001",
|
||||
contained_name="TestMachine",
|
||||
browse_name="TestMachine_001",
|
||||
is_area=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
galaxy_pb.DiscoverHierarchyReply(
|
||||
total_object_count=2,
|
||||
objects=[
|
||||
galaxy_pb.GalaxyObject(
|
||||
gobject_id=2,
|
||||
tag_name="DelmiaReceiver_001",
|
||||
contained_name="DelmiaReceiver",
|
||||
browse_name="DelmiaReceiver",
|
||||
parent_gobject_id=1,
|
||||
attributes=[
|
||||
galaxy_pb.GalaxyAttribute(
|
||||
attribute_name="DownloadPath",
|
||||
full_tag_reference="DelmiaReceiver_001.DownloadPath",
|
||||
mx_data_type=8,
|
||||
data_type_name="String",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
objects = await client.discover_hierarchy()
|
||||
|
||||
assert isinstance(objects, list)
|
||||
assert len(objects) == 2
|
||||
assert len(stub.discover_hierarchy.requests) == 2
|
||||
assert stub.discover_hierarchy.requests[0].page_size == 5000
|
||||
assert stub.discover_hierarchy.requests[0].page_token == ""
|
||||
assert stub.discover_hierarchy.requests[1].page_token == "page-2"
|
||||
assert objects[0].tag_name == "TestMachine_001"
|
||||
assert objects[1].attributes[0].full_tag_reference == "DelmiaReceiver_001.DownloadPath"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_hierarchy_rejects_repeated_page_token() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.discover_hierarchy.replies = [
|
||||
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
|
||||
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="repeated page token"):
|
||||
await client.discover_hierarchy()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_deploy_events_yields_events_in_order() -> None:
|
||||
ts1 = Timestamp()
|
||||
ts1.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
|
||||
ts2 = Timestamp()
|
||||
ts2.FromDatetime(datetime(2025, 4, 1, 11, 0, 0, tzinfo=timezone.utc))
|
||||
events = [
|
||||
galaxy_pb.DeployEvent(
|
||||
sequence=1,
|
||||
observed_at=ts1,
|
||||
time_of_last_deploy=ts1,
|
||||
time_of_last_deploy_present=True,
|
||||
object_count=10,
|
||||
attribute_count=42,
|
||||
),
|
||||
galaxy_pb.DeployEvent(
|
||||
sequence=2,
|
||||
observed_at=ts2,
|
||||
time_of_last_deploy=ts2,
|
||||
time_of_last_deploy_present=True,
|
||||
object_count=11,
|
||||
attribute_count=45,
|
||||
),
|
||||
]
|
||||
stub = FakeGalaxyStub()
|
||||
stub.watch_deploy_events.replies = list(events)
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
received: list[DeployEvent] = []
|
||||
async for event in client.watch_deploy_events():
|
||||
received.append(event)
|
||||
|
||||
assert len(received) == 2
|
||||
assert received[0].sequence == 1
|
||||
assert received[1].sequence == 2
|
||||
assert received[0].object_count == 10
|
||||
assert received[1].attribute_count == 45
|
||||
assert stub.watch_deploy_events.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
assert isinstance(stub.watch_deploy_events.requests[0], galaxy_pb.WatchDeployEventsRequest)
|
||||
# No last_seen_deploy_time was passed, so the request should leave it unset.
|
||||
assert not stub.watch_deploy_events.requests[0].HasField("last_seen_deploy_time")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_deploy_events_propagates_last_seen_deploy_time() -> None:
|
||||
last_seen = datetime(2025, 4, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
stub = FakeGalaxyStub()
|
||||
stub.watch_deploy_events.replies = []
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
async for _ in client.watch_deploy_events(last_seen_deploy_time=last_seen):
|
||||
pass
|
||||
|
||||
request = stub.watch_deploy_events.requests[0]
|
||||
assert isinstance(request, WatchDeployEventsRequest)
|
||||
assert request.HasField("last_seen_deploy_time")
|
||||
assert request.last_seen_deploy_time.ToDatetime(tzinfo=timezone.utc) == last_seen
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_deploy_events_cancellation_closes_stream() -> None:
|
||||
ts = Timestamp()
|
||||
ts.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
|
||||
stub = FakeGalaxyStub()
|
||||
# Use a "blocking" stream that never yields more after the first event.
|
||||
stub.watch_deploy_events = FakeStream(
|
||||
[galaxy_pb.DeployEvent(sequence=1, observed_at=ts)],
|
||||
block_after_replies=True,
|
||||
)
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
iterator = client.watch_deploy_events()
|
||||
|
||||
first = await iterator.__anext__()
|
||||
assert first.sequence == 1
|
||||
|
||||
# Break the iterator by aclose() — this should drive the cancel path.
|
||||
await iterator.aclose()
|
||||
|
||||
assert stub.watch_deploy_events.cancel_called is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_marks_channel_closed_when_no_real_channel() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
await client.close()
|
||||
# Idempotent: a second close should not raise.
|
||||
await client.close()
|
||||
|
||||
|
||||
class FakeGalaxyStub:
|
||||
def __init__(self) -> None:
|
||||
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
||||
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
|
||||
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
|
||||
self.watch_deploy_events = FakeStream([])
|
||||
self.TestConnection = self.test_connection
|
||||
self.GetLastDeployTime = self.get_last_deploy_time
|
||||
self.DiscoverHierarchy = self.discover_hierarchy
|
||||
|
||||
@property
|
||||
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
|
||||
return self.watch_deploy_events
|
||||
|
||||
|
||||
class FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class FakeStream:
|
||||
"""Sync-callable fake matching the gRPC unary-stream surface.
|
||||
|
||||
Calling the stub returns ``self`` (an async iterator). After exhausting the
|
||||
seeded ``replies``, iteration either ends (default) or blocks indefinitely
|
||||
(``block_after_replies=True``) so cancellation paths can be exercised.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
replies: list[Any],
|
||||
*,
|
||||
block_after_replies: bool = False,
|
||||
) -> None:
|
||||
self.replies = list(replies)
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
self.cancel_called = False
|
||||
self._block_after_replies = block_after_replies
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
timeout: float | None = None,
|
||||
) -> "FakeStream":
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self
|
||||
|
||||
def __aiter__(self) -> "FakeStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
if self.replies:
|
||||
return self.replies.pop(0)
|
||||
if self._block_after_replies:
|
||||
# Sleep forever until the consumer cancels us.
|
||||
await asyncio.Event().wait()
|
||||
raise StopAsyncIteration
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancel_called = True
|
||||
Generated
+12
@@ -595,6 +595,7 @@ dependencies = [
|
||||
"clap",
|
||||
"futures-util",
|
||||
"mxgateway-client",
|
||||
"prost-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -886,6 +887,16 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -990,6 +1001,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -38,6 +38,18 @@ cargo clippy --workspace --all-targets -- -D warnings
|
||||
The build script uses `protoc` from `PATH` or the Windows path recorded in
|
||||
`../../docs/toolchain-links.md`.
|
||||
|
||||
## Packaging
|
||||
|
||||
Create local release artifacts from `clients/rust`:
|
||||
|
||||
```powershell
|
||||
cargo build --workspace --release
|
||||
cargo install --path crates/mxgw-cli --locked --force
|
||||
```
|
||||
|
||||
`cargo check --workspace` regenerates the `tonic` and `prost` modules into
|
||||
Cargo build output through `build.rs`.
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI exposes version, session, command, event stream, write, and smoke
|
||||
@@ -58,6 +70,10 @@ CLI reads the API key from `--api-key` or from `--api-key-env`, which defaults
|
||||
to `MXGATEWAY_API_KEY`. API keys are redacted by the library option and secret
|
||||
types.
|
||||
|
||||
```powershell
|
||||
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
||||
```
|
||||
|
||||
## Library Surface
|
||||
|
||||
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
||||
@@ -83,8 +99,91 @@ preserving the raw message for parity diagnostics. Command replies whose
|
||||
protocol status is not `PROTOCOL_STATUS_CODE_OK` become `Error::Command` and
|
||||
retain the raw `MxCommandReply`.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
The Galaxy Repository service exposes a read-only browse over the AVEVA System
|
||||
Platform Galaxy Repository (ZB SQL database). It uses the same API-key auth as
|
||||
the gateway service but requires the `metadata:read` scope on the server.
|
||||
|
||||
[`GalaxyClient`](src/galaxy.rs) wraps the generated Galaxy bindings the same
|
||||
way [`GatewayClient`](src/client.rs) wraps the gateway bindings:
|
||||
|
||||
```rust
|
||||
let mut galaxy = GalaxyClient::connect(
|
||||
ClientOptions::new("http://localhost:5000")
|
||||
.with_api_key(ApiKey::new(api_key)),
|
||||
).await?;
|
||||
|
||||
let ok = galaxy.test_connection().await?;
|
||||
let last_deploy = galaxy.get_last_deploy_time().await?; // Option<prost_types::Timestamp>
|
||||
let objects = galaxy.discover_hierarchy().await?; // Vec<GalaxyObject>
|
||||
```
|
||||
|
||||
`get_last_deploy_time` returns `None` when the server reports
|
||||
`present = false`. `discover_hierarchy` returns the generated
|
||||
`GalaxyObject` proto type (re-exported via
|
||||
`mxgateway_client::generated::galaxy_repository::v1`) with all attributes
|
||||
attached.
|
||||
|
||||
The CLI ships matching subcommands under `galaxy`:
|
||||
|
||||
```powershell
|
||||
cargo run -p mxgw-cli -- galaxy test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||
cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||
```
|
||||
|
||||
### Watching deploy events
|
||||
|
||||
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
||||
server emits a bootstrap [`DeployEvent`](src/galaxy.rs) describing the
|
||||
current cache state on subscribe, then one event each time the cached
|
||||
`galaxy.time_of_last_deploy` changes. `sequence` is monotonic per server
|
||||
start; gaps signal that the per-subscriber buffer dropped older events.
|
||||
Pass `last_seen_deploy_time` to suppress the bootstrap event when the
|
||||
client's cached deploy time matches the server's.
|
||||
|
||||
```rust
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut stream = galaxy.watch_deploy_events(None).await?;
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event?;
|
||||
println!(
|
||||
"seq={} objects={} attributes={}",
|
||||
event.sequence, event.object_count, event.attribute_count,
|
||||
);
|
||||
}
|
||||
// Drop the stream to cancel the gRPC call.
|
||||
```
|
||||
|
||||
The matching CLI subcommand prints one line per event (`--json` switches to
|
||||
one JSON object per event). `--last-seen-deploy-time` accepts an RFC3339
|
||||
timestamp and is forwarded to the server. `--max-events` (default 0 = no
|
||||
cap) lets you stop after a fixed number of events; otherwise the command
|
||||
runs until the stream ends or `Ctrl+C` is pressed.
|
||||
|
||||
```powershell
|
||||
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T15:30:00Z
|
||||
```
|
||||
|
||||
## Integration Checks
|
||||
|
||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||
|
||||
```powershell
|
||||
$env:MXGATEWAY_INTEGRATION = '1'
|
||||
$env:MXGATEWAY_ENDPOINT = 'http://127.0.0.1:5000'
|
||||
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||
$env:MXGATEWAY_TEST_ITEM = 'TestChildObject.TestInt'
|
||||
cargo run -p mxgw-cli -- 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)
|
||||
- [Rust Client Detailed Design](../../docs/clients-rust-design.md)
|
||||
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||
|
||||
@@ -13,17 +13,23 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let proto_root = repo_root.join("src/MxGateway.Contracts/Protos");
|
||||
let gateway_proto = proto_root.join("mxaccess_gateway.proto");
|
||||
let worker_proto = proto_root.join("mxaccess_worker.proto");
|
||||
let galaxy_proto = proto_root.join("galaxy_repository.proto");
|
||||
let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("mxaccessgw-client-v1.protoset");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", gateway_proto.display());
|
||||
println!("cargo:rerun-if-changed={}", worker_proto.display());
|
||||
println!("cargo:rerun-if-changed={}", galaxy_proto.display());
|
||||
|
||||
tonic_build::configure()
|
||||
.build_server(true)
|
||||
.build_client(true)
|
||||
.file_descriptor_set_path(descriptor_path)
|
||||
.compile_protos(
|
||||
&[gateway_proto.as_path(), worker_proto.as_path()],
|
||||
&[
|
||||
gateway_proto.as_path(),
|
||||
worker_proto.as_path(),
|
||||
galaxy_proto.as_path(),
|
||||
],
|
||||
&[proto_root.as_path()],
|
||||
)?;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
clap = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
mxgateway-client = { path = "../.." }
|
||||
prost-types = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
|
||||
@@ -5,17 +5,20 @@ use std::time::Duration;
|
||||
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use futures_util::StreamExt;
|
||||
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, OpenSessionRequest,
|
||||
PingCommand, StreamEventsRequest,
|
||||
};
|
||||
use mxgateway_client::{
|
||||
ApiKey, ClientOptions, Error, GatewayClient, MxValue, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION,
|
||||
WORKER_PROTOCOL_VERSION,
|
||||
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, CLIENT_VERSION,
|
||||
GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
const MAX_AGGREGATE_EVENTS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "mxgw")]
|
||||
#[command(about = "MXAccess Gateway Rust test CLI")]
|
||||
@@ -29,6 +32,8 @@ enum Command {
|
||||
Version {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg(long)]
|
||||
jsonl: bool,
|
||||
},
|
||||
Ping {
|
||||
#[command(flatten)]
|
||||
@@ -88,6 +93,30 @@ enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
SubscribeBulk {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
items: Vec<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
UnsubscribeBulk {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
item_handles: Vec<i32>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
StreamEvents {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
@@ -99,6 +128,8 @@ enum Command {
|
||||
max_events: usize,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg(long)]
|
||||
jsonl: bool,
|
||||
},
|
||||
Write {
|
||||
#[command(flatten)]
|
||||
@@ -148,6 +179,51 @@ enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
#[command(subcommand)]
|
||||
Galaxy(GalaxyCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum GalaxyCommand {
|
||||
TestConnection {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
LastDeployTime {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
DiscoverHierarchy {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Subscribe to the WatchDeployEvents server stream.
|
||||
///
|
||||
/// Prints one line per received event (or one JSON object with `--json`).
|
||||
/// Runs until the stream ends, the server fails the call, or the
|
||||
/// process is interrupted (Ctrl+C).
|
||||
#[command(alias = "watch-deploy-events")]
|
||||
Watch {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
/// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
|
||||
/// supplied, the server suppresses the bootstrap event if its
|
||||
/// cached deploy time matches this value.
|
||||
#[arg(long)]
|
||||
last_seen_deploy_time: Option<String>,
|
||||
/// Optional cap on the number of events to print before exiting.
|
||||
/// 0 (the default) means run until cancelled or the stream ends.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
max_events: usize,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
@@ -222,7 +298,7 @@ async fn main() -> ExitCode {
|
||||
|
||||
async fn run(cli: Cli) -> Result<(), Error> {
|
||||
match cli.command {
|
||||
Command::Version { json } => print_version(json),
|
||||
Command::Version { json, .. } => print_version(json),
|
||||
Command::Ping {
|
||||
connection,
|
||||
message,
|
||||
@@ -319,13 +395,45 @@ async fn run(cli: Cli) -> Result<(), Error> {
|
||||
session.advise(server_handle, item_handle).await?;
|
||||
print_ok("advise", json);
|
||||
}
|
||||
Command::SubscribeBulk {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
items,
|
||||
json,
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
let results = session.subscribe_bulk(server_handle, items).await?;
|
||||
print_bulk_results("subscribe-bulk", &results, json);
|
||||
}
|
||||
Command::UnsubscribeBulk {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
item_handles,
|
||||
json,
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
let results = session
|
||||
.unsubscribe_bulk(server_handle, item_handles)
|
||||
.await?;
|
||||
print_bulk_results("unsubscribe-bulk", &results, json);
|
||||
}
|
||||
Command::StreamEvents {
|
||||
connection,
|
||||
session_id,
|
||||
after_worker_sequence,
|
||||
max_events,
|
||||
json,
|
||||
jsonl,
|
||||
} => {
|
||||
if max_events > MAX_AGGREGATE_EVENTS {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "max-events".to_owned(),
|
||||
detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
|
||||
});
|
||||
}
|
||||
|
||||
let client = connect(connection).await?;
|
||||
let mut stream = client
|
||||
.stream_events(StreamEventsRequest {
|
||||
@@ -334,19 +442,30 @@ async fn run(cli: Cli) -> Result<(), Error> {
|
||||
})
|
||||
.await?;
|
||||
let mut events = Vec::new();
|
||||
while events.len() < max_events {
|
||||
let mut event_count = 0usize;
|
||||
while event_count < max_events {
|
||||
let Some(event) = stream.next().await else {
|
||||
break;
|
||||
};
|
||||
events.push(event?);
|
||||
}
|
||||
if json {
|
||||
println!("{}", json!({ "eventCount": events.len() }));
|
||||
} else {
|
||||
for event in events {
|
||||
let event = event?;
|
||||
event_count += 1;
|
||||
if jsonl {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"workerSequence": event.worker_sequence,
|
||||
"family": event.family,
|
||||
})
|
||||
);
|
||||
} else if json {
|
||||
events.push(event);
|
||||
} else {
|
||||
println!("{} {}", event.worker_sequence, event.family);
|
||||
}
|
||||
}
|
||||
if json {
|
||||
println!("{}", json!({ "eventCount": event_count }));
|
||||
}
|
||||
}
|
||||
Command::Write {
|
||||
connection,
|
||||
@@ -392,6 +511,7 @@ async fn run(cli: Cli) -> Result<(), Error> {
|
||||
.await?;
|
||||
print_ok("write2", json);
|
||||
}
|
||||
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
|
||||
Command::Smoke {
|
||||
connection,
|
||||
item,
|
||||
@@ -441,6 +561,133 @@ async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
|
||||
GatewayClient::connect(connection.options()).await
|
||||
}
|
||||
|
||||
async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
|
||||
GalaxyClient::connect(connection.options()).await
|
||||
}
|
||||
|
||||
async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
|
||||
match command {
|
||||
GalaxyCommand::TestConnection { connection, json } => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let ok = client.test_connection().await?;
|
||||
if json {
|
||||
println!("{}", json!({ "ok": ok }));
|
||||
} else if ok {
|
||||
println!("ok");
|
||||
} else {
|
||||
println!("not ok");
|
||||
}
|
||||
}
|
||||
GalaxyCommand::LastDeployTime { connection, json } => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let timestamp = client.get_last_deploy_time().await?;
|
||||
match (json, timestamp) {
|
||||
(true, Some(ts)) => {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"present": true,
|
||||
"seconds": ts.seconds,
|
||||
"nanos": ts.nanos,
|
||||
})
|
||||
);
|
||||
}
|
||||
(true, None) => {
|
||||
println!("{}", json!({ "present": false }));
|
||||
}
|
||||
(false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
|
||||
(false, None) => println!("(absent)"),
|
||||
}
|
||||
}
|
||||
GalaxyCommand::Watch {
|
||||
connection,
|
||||
last_seen_deploy_time,
|
||||
max_events,
|
||||
json,
|
||||
} => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let last_seen = last_seen_deploy_time
|
||||
.as_deref()
|
||||
.map(parse_rfc3339_timestamp)
|
||||
.transpose()?;
|
||||
let mut stream = client.watch_deploy_events(last_seen).await?;
|
||||
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|err| Error::InvalidArgument {
|
||||
name: "ctrl_c".to_owned(),
|
||||
detail: err.to_string(),
|
||||
})?;
|
||||
// Drop the stream below by breaking; tonic tears the
|
||||
// gRPC call down cooperatively.
|
||||
break;
|
||||
}
|
||||
next = stream.next() => {
|
||||
let Some(event) = next else { break; };
|
||||
let event = event?;
|
||||
count += 1;
|
||||
print_deploy_event(&event, json);
|
||||
if max_events != 0 && count >= max_events {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GalaxyCommand::DiscoverHierarchy { connection, json } => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let objects = client.discover_hierarchy().await?;
|
||||
if json {
|
||||
let payload: Vec<_> = objects
|
||||
.iter()
|
||||
.map(|object| {
|
||||
json!({
|
||||
"gobjectId": object.gobject_id,
|
||||
"tagName": object.tag_name,
|
||||
"containedName": object.contained_name,
|
||||
"browseName": object.browse_name,
|
||||
"parentGobjectId": object.parent_gobject_id,
|
||||
"isArea": object.is_area,
|
||||
"categoryId": object.category_id,
|
||||
"hostedByGobjectId": object.hosted_by_gobject_id,
|
||||
"templateChain": object.template_chain,
|
||||
"attributes": object.attributes.iter().map(|attribute| json!({
|
||||
"attributeName": attribute.attribute_name,
|
||||
"fullTagReference": attribute.full_tag_reference,
|
||||
"mxDataType": attribute.mx_data_type,
|
||||
"dataTypeName": attribute.data_type_name,
|
||||
"isArray": attribute.is_array,
|
||||
"arrayDimension": attribute.array_dimension,
|
||||
"arrayDimensionPresent": attribute.array_dimension_present,
|
||||
"mxAttributeCategory": attribute.mx_attribute_category,
|
||||
"securityClassification": attribute.security_classification,
|
||||
"isHistorized": attribute.is_historized,
|
||||
"isAlarm": attribute.is_alarm,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", json!({ "objects": payload }));
|
||||
} else {
|
||||
println!("{}", objects.len());
|
||||
for object in &objects {
|
||||
println!(
|
||||
"{} {} {} ({} attribute(s))",
|
||||
object.gobject_id,
|
||||
object.tag_name,
|
||||
object.browse_name,
|
||||
object.attributes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn session_for(
|
||||
connection: ConnectionArgs,
|
||||
session_id: String,
|
||||
@@ -504,6 +751,33 @@ fn print_ok(operation: &str, use_json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_bulk_results(
|
||||
operation: &str,
|
||||
results: &[mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
|
||||
use_json: bool,
|
||||
) {
|
||||
if use_json {
|
||||
let results_json: Vec<_> = results
|
||||
.iter()
|
||||
.map(|result| {
|
||||
json!({
|
||||
"serverHandle": result.server_handle,
|
||||
"tagAddress": result.tag_address,
|
||||
"itemHandle": result.item_handle,
|
||||
"wasSuccessful": result.was_successful,
|
||||
"errorMessage": result.error_message,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
json!({ "operation": operation, "results": results_json })
|
||||
);
|
||||
} else {
|
||||
println!("{}", results.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error> {
|
||||
let parsed = match value_type {
|
||||
CliValueType::Bool => MxValue::bool(parse_cli_value(value)?),
|
||||
@@ -516,6 +790,208 @@ fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error>
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn print_deploy_event(event: &DeployEvent, use_json: bool) {
|
||||
if use_json {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"sequence": event.sequence,
|
||||
"observedAt": event.observed_at.as_ref().map(|ts| json!({
|
||||
"seconds": ts.seconds,
|
||||
"nanos": ts.nanos,
|
||||
})),
|
||||
"timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
|
||||
"seconds": ts.seconds,
|
||||
"nanos": ts.nanos,
|
||||
})),
|
||||
"timeOfLastDeployPresent": event.time_of_last_deploy_present,
|
||||
"objectCount": event.object_count,
|
||||
"attributeCount": event.attribute_count,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
let observed = event
|
||||
.observed_at
|
||||
.as_ref()
|
||||
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
|
||||
.unwrap_or_else(|| "(absent)".to_owned());
|
||||
let last_deploy = if event.time_of_last_deploy_present {
|
||||
event
|
||||
.time_of_last_deploy
|
||||
.as_ref()
|
||||
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
|
||||
.unwrap_or_else(|| "(absent)".to_owned())
|
||||
} else {
|
||||
"(absent)".to_owned()
|
||||
};
|
||||
println!(
|
||||
"seq={} observed={} lastDeploy={} objects={} attributes={}",
|
||||
event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a small but practically-complete subset of RFC3339:
|
||||
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
|
||||
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
|
||||
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
|
||||
fn invalid(detail: impl Into<String>) -> Error {
|
||||
Error::InvalidArgument {
|
||||
name: "last-seen-deploy-time".to_owned(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = input.as_bytes();
|
||||
if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
|
||||
return Err(invalid(format!(
|
||||
"expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
|
||||
std::str::from_utf8(&bytes[start..start + len])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<u32>().ok())
|
||||
.ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
|
||||
};
|
||||
|
||||
let year = read_u32(0, 4)? as i32;
|
||||
if bytes[4] != b'-' {
|
||||
return Err(invalid("expected '-' after year"));
|
||||
}
|
||||
let month = read_u32(5, 2)?;
|
||||
if bytes[7] != b'-' {
|
||||
return Err(invalid("expected '-' after month"));
|
||||
}
|
||||
let day = read_u32(8, 2)?;
|
||||
let hour = read_u32(11, 2)?;
|
||||
if bytes[13] != b':' {
|
||||
return Err(invalid("expected ':' after hour"));
|
||||
}
|
||||
let minute = read_u32(14, 2)?;
|
||||
if bytes[16] != b':' {
|
||||
return Err(invalid("expected ':' after minute"));
|
||||
}
|
||||
let second = read_u32(17, 2)?;
|
||||
|
||||
let mut cursor = 19usize;
|
||||
let mut nanos: u32 = 0;
|
||||
if cursor < bytes.len() && bytes[cursor] == b'.' {
|
||||
cursor += 1;
|
||||
let frac_start = cursor;
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
|
||||
cursor += 1;
|
||||
}
|
||||
let frac_len = cursor - frac_start;
|
||||
if frac_len == 0 {
|
||||
return Err(invalid("expected fractional digits after '.'"));
|
||||
}
|
||||
let take = frac_len.min(9);
|
||||
let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<u32>().ok())
|
||||
.ok_or_else(|| invalid("invalid fractional digits"))?;
|
||||
nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
|
||||
}
|
||||
|
||||
let mut offset_seconds: i64 = 0;
|
||||
if cursor >= bytes.len() {
|
||||
return Err(invalid("missing timezone designator (Z or +HH:MM)"));
|
||||
}
|
||||
match bytes[cursor] {
|
||||
b'Z' | b'z' => cursor += 1,
|
||||
sign @ (b'+' | b'-') => {
|
||||
cursor += 1;
|
||||
if cursor + 5 > bytes.len() {
|
||||
return Err(invalid("offset must be ±HH:MM"));
|
||||
}
|
||||
let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<i64>().ok())
|
||||
.ok_or_else(|| invalid("invalid offset hour"))?;
|
||||
if bytes[cursor + 2] != b':' {
|
||||
return Err(invalid("offset must contain ':' between HH and MM"));
|
||||
}
|
||||
let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<i64>().ok())
|
||||
.ok_or_else(|| invalid("invalid offset minute"))?;
|
||||
cursor += 5;
|
||||
let signed = if sign == b'-' { -1 } else { 1 };
|
||||
offset_seconds = signed * (oh * 3600 + om * 60);
|
||||
}
|
||||
other => {
|
||||
return Err(invalid(format!(
|
||||
"unexpected timezone designator byte {other:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if cursor != bytes.len() {
|
||||
return Err(invalid("trailing characters after timezone"));
|
||||
}
|
||||
|
||||
let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
|
||||
let seconds = unix - offset_seconds;
|
||||
|
||||
Ok(prost_types::Timestamp {
|
||||
seconds,
|
||||
nanos: nanos as i32,
|
||||
})
|
||||
}
|
||||
|
||||
fn ymdhms_to_unix(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
second: u32,
|
||||
) -> Result<i64, Error> {
|
||||
if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "last-seen-deploy-time".to_owned(),
|
||||
detail: "calendar component out of range".to_owned(),
|
||||
});
|
||||
}
|
||||
fn is_leap(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let mut max = DAYS[(month - 1) as usize];
|
||||
if month == 2 && is_leap(year) {
|
||||
max = 29;
|
||||
}
|
||||
if day > max {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "last-seen-deploy-time".to_owned(),
|
||||
detail: format!("day {day} out of range for month {month}/{year}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Days from 1970-01-01 to year-month-day.
|
||||
let mut total_days: i64 = 0;
|
||||
if year >= 1970 {
|
||||
for y in 1970..year {
|
||||
total_days += if is_leap(y) { 366 } else { 365 };
|
||||
}
|
||||
} else {
|
||||
for y in year..1970 {
|
||||
total_days -= if is_leap(y) { 366 } else { 365 };
|
||||
}
|
||||
}
|
||||
for m in 1..month {
|
||||
let mut dim = DAYS[(m - 1) as usize];
|
||||
if m == 2 && is_leap(year) {
|
||||
dim = 29;
|
||||
}
|
||||
total_days += dim as i64;
|
||||
}
|
||||
total_days += (day - 1) as i64;
|
||||
|
||||
Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
|
||||
}
|
||||
|
||||
fn parse_cli_value<T>(value: &str) -> Result<T, Error>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
@@ -562,7 +1038,41 @@ mod tests {
|
||||
fn version_json_output_has_protocol_versions() {
|
||||
let value = super::version_json();
|
||||
|
||||
assert_eq!(value["gatewayProtocolVersion"], 1);
|
||||
assert_eq!(value["gatewayProtocolVersion"], 2);
|
||||
assert_eq!(value["workerProtocolVersion"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"galaxy",
|
||||
"watch",
|
||||
"--last-seen-deploy-time",
|
||||
"2026-04-28T15:30:00Z",
|
||||
"--max-events",
|
||||
"5",
|
||||
"--json",
|
||||
]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_galaxy_watch_deploy_events_alias() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
|
||||
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
|
||||
let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
|
||||
let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
|
||||
let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();
|
||||
|
||||
assert_eq!(utc.seconds, plus.seconds);
|
||||
assert_eq!(utc.nanos, 0);
|
||||
assert_eq!(frac.seconds, utc.seconds);
|
||||
assert_eq!(frac.nanos, 250_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::Request;
|
||||
|
||||
use crate::auth::AuthInterceptor;
|
||||
use crate::error::{ensure_command_success, Error};
|
||||
use crate::error::{ensure_command_success, ensure_protocol_success, Error};
|
||||
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
|
||||
@@ -23,6 +23,7 @@ pub type EventStream =
|
||||
pub struct GatewayClient {
|
||||
inner: RawGatewayClient,
|
||||
call_timeout: std::time::Duration,
|
||||
stream_timeout: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl GatewayClient {
|
||||
@@ -53,10 +54,14 @@ impl GatewayClient {
|
||||
|
||||
let channel = endpoint.connect().await?;
|
||||
let interceptor = AuthInterceptor::new(options.api_key().cloned());
|
||||
let max_grpc_message_bytes = options.max_grpc_message_bytes();
|
||||
|
||||
Ok(Self {
|
||||
inner: MxAccessGatewayClient::with_interceptor(channel, interceptor),
|
||||
inner: MxAccessGatewayClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_grpc_message_bytes)
|
||||
.max_encoding_message_size(max_grpc_message_bytes),
|
||||
call_timeout: options.call_timeout(),
|
||||
stream_timeout: options.stream_timeout(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +88,7 @@ impl GatewayClient {
|
||||
|
||||
pub async fn open_session(&self, request: OpenSessionRequest) -> Result<Session, Error> {
|
||||
let reply = self.open_session_raw(request).await?;
|
||||
ensure_protocol_success("open session", reply.protocol_status.as_ref())?;
|
||||
Ok(Session::new(reply.session_id, self.clone()))
|
||||
}
|
||||
|
||||
@@ -107,7 +113,7 @@ impl GatewayClient {
|
||||
|
||||
pub async fn stream_events(&self, request: StreamEventsRequest) -> Result<EventStream, Error> {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client.stream_events(self.unary_request(request)).await?;
|
||||
let response = client.stream_events(self.stream_request(request)).await?;
|
||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||
result.map_err(Error::from)
|
||||
});
|
||||
@@ -120,4 +126,13 @@ impl GatewayClient {
|
||||
request.set_timeout(self.call_timeout);
|
||||
request
|
||||
}
|
||||
|
||||
fn stream_request<T>(&self, message: T) -> Request<T> {
|
||||
let mut request = Request::new(message);
|
||||
if let Some(timeout) = self.stream_timeout {
|
||||
request.set_timeout(timeout);
|
||||
}
|
||||
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use thiserror::Error as ThisError;
|
||||
use tonic::Code;
|
||||
|
||||
use crate::generated::mxaccess_gateway::v1::{MxCommandReply, ProtocolStatusCode};
|
||||
use crate::generated::mxaccess_gateway::v1::{MxCommandReply, ProtocolStatus, ProtocolStatusCode};
|
||||
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
@@ -47,6 +47,13 @@ pub enum Error {
|
||||
|
||||
#[error("gateway command failed: {0}")]
|
||||
Command(#[from] Box<CommandError>),
|
||||
|
||||
#[error("gateway {operation} failed: {code:?}: {message}")]
|
||||
ProtocolStatus {
|
||||
operation: &'static str,
|
||||
code: ProtocolStatusCode,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -125,6 +132,27 @@ pub fn ensure_command_success(reply: MxCommandReply) -> Result<MxCommandReply, E
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_protocol_success(
|
||||
operation: &'static str,
|
||||
status: Option<&ProtocolStatus>,
|
||||
) -> Result<(), Error> {
|
||||
let code = status
|
||||
.and_then(|status| ProtocolStatusCode::try_from(status.code).ok())
|
||||
.unwrap_or(ProtocolStatusCode::Unspecified);
|
||||
|
||||
if code == ProtocolStatusCode::Ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message: status
|
||||
.map(|status| status.message.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_credentials(message: &str) -> String {
|
||||
message
|
||||
.split_whitespace()
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
//! Thin async wrapper for the `GalaxyRepository` gRPC service.
|
||||
//!
|
||||
//! The wrapper mirrors [`crate::client::GatewayClient`]: it owns a tonic
|
||||
//! channel with the shared bearer-token interceptor and exposes the three
|
||||
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
|
||||
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
||||
|
||||
use std::fs;
|
||||
|
||||
use prost_types::Timestamp;
|
||||
use tonic::codegen::InterceptedService;
|
||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::Request;
|
||||
|
||||
use crate::auth::AuthInterceptor;
|
||||
use crate::error::Error;
|
||||
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
|
||||
use crate::generated::galaxy_repository::v1::{
|
||||
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest,
|
||||
TestConnectionRequest, WatchDeployEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
|
||||
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
||||
|
||||
/// Convenience alias for the generated Galaxy client wrapped in the
|
||||
/// authentication interceptor.
|
||||
pub type RawGalaxyClient = GalaxyRepositoryClient<InterceptedService<Channel, AuthInterceptor>>;
|
||||
|
||||
/// Stream of `DeployEvent` values returned by
|
||||
/// [`GalaxyClient::watch_deploy_events`]. Mirrors
|
||||
/// [`crate::client::EventStream`]: a boxed `Stream` whose `tonic::Status`
|
||||
/// errors have already been mapped onto [`Error`]. Dropping the stream
|
||||
/// cancels the underlying gRPC call.
|
||||
pub type DeployEventStream = std::pin::Pin<
|
||||
Box<dyn futures_core::Stream<Item = Result<DeployEvent, Error>> + Send + 'static>,
|
||||
>;
|
||||
|
||||
/// Thin async wrapper around the generated Galaxy Repository gRPC client.
|
||||
///
|
||||
/// Construct it with [`GalaxyClient::connect`] using the same
|
||||
/// [`ClientOptions`] that drive [`crate::client::GatewayClient`]. The
|
||||
/// service is metadata-only (no sessions) and requires the `metadata:read`
|
||||
/// API-key scope on the server side.
|
||||
#[derive(Clone)]
|
||||
pub struct GalaxyClient {
|
||||
inner: RawGalaxyClient,
|
||||
call_timeout: std::time::Duration,
|
||||
stream_timeout: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl GalaxyClient {
|
||||
/// Connect to the gateway endpoint and build a Galaxy client. Mirrors
|
||||
/// the TLS / plaintext / API-key handling used by `GatewayClient`.
|
||||
pub async fn connect(options: ClientOptions) -> Result<Self, Error> {
|
||||
let mut endpoint =
|
||||
Channel::from_shared(options.endpoint().to_owned()).map_err(|source| {
|
||||
Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: source.to_string(),
|
||||
}
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if !options.plaintext() {
|
||||
let mut tls = ClientTlsConfig::new();
|
||||
if let Some(server_name) = options.server_name_override() {
|
||||
tls = tls.domain_name(server_name.to_owned());
|
||||
}
|
||||
if let Some(ca_file) = options.ca_file() {
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||
}
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
let channel = endpoint.connect().await?;
|
||||
let interceptor = AuthInterceptor::new(options.api_key().cloned());
|
||||
let max_grpc_message_bytes = options.max_grpc_message_bytes();
|
||||
|
||||
Ok(Self {
|
||||
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_grpc_message_bytes)
|
||||
.max_encoding_message_size(max_grpc_message_bytes),
|
||||
call_timeout: options.call_timeout(),
|
||||
stream_timeout: options.stream_timeout(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a [`GalaxyClient`] that talks through an existing tonic
|
||||
/// channel. Tests use this to wire up an in-memory transport.
|
||||
pub fn from_channel(channel: Channel, options: &ClientOptions) -> Self {
|
||||
let interceptor = AuthInterceptor::new(options.api_key().cloned());
|
||||
let max_grpc_message_bytes = options.max_grpc_message_bytes();
|
||||
Self {
|
||||
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_grpc_message_bytes)
|
||||
.max_encoding_message_size(max_grpc_message_bytes),
|
||||
call_timeout: options.call_timeout(),
|
||||
stream_timeout: options.stream_timeout(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the underlying generated client for advanced callers that need
|
||||
/// access to features not surfaced by the wrapper.
|
||||
pub fn raw_client(&mut self) -> &mut RawGalaxyClient {
|
||||
&mut self.inner
|
||||
}
|
||||
|
||||
/// Consume the wrapper and return the generated client.
|
||||
pub fn into_inner(self) -> RawGalaxyClient {
|
||||
self.inner
|
||||
}
|
||||
|
||||
/// Probe the Galaxy Repository database connection. Returns the `ok`
|
||||
/// flag from the server reply.
|
||||
pub async fn test_connection(&mut self) -> Result<bool, Error> {
|
||||
let response = self
|
||||
.inner
|
||||
.test_connection(self.unary_request(TestConnectionRequest {}))
|
||||
.await?;
|
||||
Ok(response.into_inner().ok)
|
||||
}
|
||||
|
||||
/// Read the most recent Galaxy deployment timestamp. Returns `None`
|
||||
/// when the server reports `present = false`.
|
||||
pub async fn get_last_deploy_time(&mut self) -> Result<Option<Timestamp>, Error> {
|
||||
let response = self
|
||||
.inner
|
||||
.get_last_deploy_time(self.unary_request(GetLastDeployTimeRequest {}))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
if reply.present {
|
||||
Ok(reply.time_of_last_deploy)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the deployed object hierarchy. Each [`GalaxyObject`] contains
|
||||
/// the object's identifying names plus its dynamic attributes.
|
||||
pub async fn discover_hierarchy(&mut self) -> Result<Vec<GalaxyObject>, Error> {
|
||||
let mut objects = Vec::new();
|
||||
let mut seen_page_tokens = std::collections::HashSet::new();
|
||||
let mut page_token = String::new();
|
||||
loop {
|
||||
let response = self
|
||||
.inner
|
||||
.discover_hierarchy(self.unary_request(DiscoverHierarchyRequest {
|
||||
page_size: DISCOVER_HIERARCHY_PAGE_SIZE,
|
||||
page_token,
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
objects.extend(reply.objects);
|
||||
page_token = reply.next_page_token;
|
||||
if page_token.is_empty() {
|
||||
return Ok(objects);
|
||||
}
|
||||
if !seen_page_tokens.insert(page_token.clone()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!(
|
||||
"galaxy discover hierarchy returned repeated page token `{page_token}`"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the server-streamed deploy-event feed.
|
||||
///
|
||||
/// The server emits a bootstrap event describing the current cache state
|
||||
/// immediately on subscribe, then one event per observed change to
|
||||
/// `galaxy.time_of_last_deploy`. When `last_seen_deploy_time` matches the
|
||||
/// current cache, the bootstrap event is suppressed and the stream stays
|
||||
/// idle until the next deploy.
|
||||
///
|
||||
/// Cancellation is cooperative: dropping the returned
|
||||
/// [`DeployEventStream`] tears down the underlying gRPC call. Callers
|
||||
/// drive consumption with `StreamExt::next` (or any other `Stream`
|
||||
/// adapter).
|
||||
pub async fn watch_deploy_events(
|
||||
&mut self,
|
||||
last_seen_deploy_time: Option<Timestamp>,
|
||||
) -> Result<DeployEventStream, Error> {
|
||||
let request = WatchDeployEventsRequest {
|
||||
last_seen_deploy_time,
|
||||
};
|
||||
let response = self
|
||||
.inner
|
||||
.watch_deploy_events(self.stream_request(request))
|
||||
.await?;
|
||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||
result.map_err(Error::from)
|
||||
});
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn unary_request<T>(&self, message: T) -> Request<T> {
|
||||
let mut request = Request::new(message);
|
||||
request.set_timeout(self.call_timeout);
|
||||
request
|
||||
}
|
||||
|
||||
fn stream_request<T>(&self, message: T) -> Request<T> {
|
||||
let mut request = Request::new(message);
|
||||
if let Some(timeout) = self.stream_timeout {
|
||||
request.set_timeout(timeout);
|
||||
}
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream};
|
||||
use tonic::transport::Server;
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use super::*;
|
||||
use crate::auth::ApiKey;
|
||||
use crate::generated::galaxy_repository::v1::galaxy_repository_server::{
|
||||
GalaxyRepository, GalaxyRepositoryServer,
|
||||
};
|
||||
use crate::generated::galaxy_repository::v1::{
|
||||
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute,
|
||||
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply,
|
||||
TestConnectionRequest, WatchDeployEventsRequest,
|
||||
};
|
||||
|
||||
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeState {
|
||||
authorization: Mutex<Option<String>>,
|
||||
present: Mutex<bool>,
|
||||
last_deploy: Mutex<Option<Timestamp>>,
|
||||
objects: Mutex<Vec<GalaxyObject>>,
|
||||
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
|
||||
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
|
||||
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
|
||||
watch_events: Mutex<Vec<DeployEvent>>,
|
||||
watch_senders: Mutex<Vec<DeployEventTx>>,
|
||||
watch_drop_signal: Mutex<Option<mpsc::UnboundedSender<()>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeGalaxy {
|
||||
state: Arc<FakeState>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl GalaxyRepository for FakeGalaxy {
|
||||
async fn test_connection(
|
||||
&self,
|
||||
request: Request<TestConnectionRequest>,
|
||||
) -> Result<Response<TestConnectionReply>, Status> {
|
||||
*self.state.authorization.lock().unwrap() = request
|
||||
.metadata()
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
Ok(Response::new(TestConnectionReply { ok: true }))
|
||||
}
|
||||
|
||||
async fn get_last_deploy_time(
|
||||
&self,
|
||||
_request: Request<GetLastDeployTimeRequest>,
|
||||
) -> Result<Response<GetLastDeployTimeReply>, Status> {
|
||||
let present = *self.state.present.lock().unwrap();
|
||||
let time = self.state.last_deploy.lock().unwrap().clone();
|
||||
Ok(Response::new(GetLastDeployTimeReply {
|
||||
present,
|
||||
time_of_last_deploy: time,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn discover_hierarchy(
|
||||
&self,
|
||||
request: Request<DiscoverHierarchyRequest>,
|
||||
) -> Result<Response<DiscoverHierarchyReply>, Status> {
|
||||
self.state
|
||||
.discover_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.into_inner());
|
||||
if let Some(reply) = self.state.discover_replies.lock().unwrap().pop_front() {
|
||||
return Ok(Response::new(reply));
|
||||
}
|
||||
|
||||
Ok(Response::new(DiscoverHierarchyReply {
|
||||
objects: self.state.objects.lock().unwrap().clone(),
|
||||
next_page_token: String::new(),
|
||||
total_object_count: self.state.objects.lock().unwrap().len() as i32,
|
||||
}))
|
||||
}
|
||||
|
||||
type WatchDeployEventsStream =
|
||||
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
|
||||
|
||||
async fn watch_deploy_events(
|
||||
&self,
|
||||
request: Request<WatchDeployEventsRequest>,
|
||||
) -> Result<Response<Self::WatchDeployEventsStream>, Status> {
|
||||
self.state
|
||||
.watch_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.into_inner());
|
||||
|
||||
let preset = self.state.watch_events.lock().unwrap().clone();
|
||||
let (tx, rx) = mpsc::channel::<Result<DeployEvent, Status>>(16);
|
||||
for event in preset {
|
||||
tx.send(Ok(event))
|
||||
.await
|
||||
.map_err(|err| Status::internal(err.to_string()))?;
|
||||
}
|
||||
self.state.watch_senders.lock().unwrap().push(tx.clone());
|
||||
|
||||
let drop_signal = self.state.watch_drop_signal.lock().unwrap().clone();
|
||||
let stream = ReceiverStream::new(rx);
|
||||
let stream: Pin<Box<dyn tokio_stream::Stream<Item = _> + Send + 'static>> =
|
||||
if let Some(signal) = drop_signal {
|
||||
Box::pin(WatchStreamWithDropSignal {
|
||||
inner: stream,
|
||||
signal: Some(signal),
|
||||
})
|
||||
} else {
|
||||
Box::pin(stream)
|
||||
};
|
||||
|
||||
Ok(Response::new(stream))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the receiver stream so we can detect when the server-side stream
|
||||
/// future is dropped (the client cancelled or dropped the stream). Used by
|
||||
/// `watch_drop_tears_down_call`.
|
||||
struct WatchStreamWithDropSignal<S> {
|
||||
inner: S,
|
||||
signal: Option<mpsc::UnboundedSender<()>>,
|
||||
}
|
||||
|
||||
impl<S: tokio_stream::Stream + Unpin> tokio_stream::Stream for WatchStreamWithDropSignal<S> {
|
||||
type Item = S::Item;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
Pin::new(&mut self.inner).poll_next(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Drop for WatchStreamWithDropSignal<S> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(signal) = self.signal.take() {
|
||||
let _ = signal.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_fake(state: Arc<FakeState>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let incoming = TcpListenerStream::new(listener);
|
||||
let service = GalaxyRepositoryServer::new(FakeGalaxy { state });
|
||||
tokio::spawn(async move {
|
||||
Server::builder()
|
||||
.add_service(service)
|
||||
.serve_with_incoming(incoming)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_attaches_bearer_metadata_and_returns_ok() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(
|
||||
ClientOptions::new(endpoint).with_api_key(ApiKey::new("mxgw_galaxy_secret")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ok = client.test_connection().await.unwrap();
|
||||
|
||||
assert!(ok);
|
||||
assert_eq!(
|
||||
state.authorization.lock().unwrap().as_deref(),
|
||||
Some("Bearer mxgw_galaxy_secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_last_deploy_time_returns_none_when_not_present() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.present.lock().unwrap() = false;
|
||||
*state.last_deploy.lock().unwrap() = Some(Timestamp {
|
||||
seconds: 1_700_000_000,
|
||||
nanos: 0,
|
||||
});
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = client.get_last_deploy_time().await.unwrap();
|
||||
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"present=false on the wire must surface as None, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_last_deploy_time_returns_timestamp_when_present() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.present.lock().unwrap() = true;
|
||||
*state.last_deploy.lock().unwrap() = Some(Timestamp {
|
||||
seconds: 1_700_000_000,
|
||||
nanos: 250_000_000,
|
||||
});
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = client.get_last_deploy_time().await.unwrap();
|
||||
|
||||
let timestamp = result.expect("present=true should yield a timestamp");
|
||||
assert_eq!(timestamp.seconds, 1_700_000_000);
|
||||
assert_eq!(timestamp.nanos, 250_000_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_hierarchy_returns_objects_with_attributes() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: vec![GalaxyObject {
|
||||
gobject_id: 42,
|
||||
tag_name: "DelmiaReceiver_001".to_owned(),
|
||||
contained_name: "DelmiaReceiver".to_owned(),
|
||||
browse_name: "TestMachine_001/DelmiaReceiver".to_owned(),
|
||||
parent_gobject_id: 7,
|
||||
is_area: false,
|
||||
category_id: 3,
|
||||
hosted_by_gobject_id: 1,
|
||||
template_chain: vec!["$UserDefined".to_owned(), "$DelmiaReceiver".to_owned()],
|
||||
attributes: vec![GalaxyAttribute {
|
||||
attribute_name: "DownloadPath".to_owned(),
|
||||
full_tag_reference: "DelmiaReceiver_001.DownloadPath".to_owned(),
|
||||
mx_data_type: 8,
|
||||
data_type_name: "MxString".to_owned(),
|
||||
is_array: false,
|
||||
array_dimension: 0,
|
||||
array_dimension_present: false,
|
||||
mx_attribute_category: 2,
|
||||
security_classification: 1,
|
||||
is_historized: false,
|
||||
is_alarm: false,
|
||||
}],
|
||||
}],
|
||||
next_page_token: "page-2".to_owned(),
|
||||
total_object_count: 2,
|
||||
});
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: vec![GalaxyObject {
|
||||
gobject_id: 43,
|
||||
tag_name: "DelmiaReceiver_002".to_owned(),
|
||||
contained_name: String::new(),
|
||||
browse_name: String::new(),
|
||||
parent_gobject_id: 0,
|
||||
is_area: false,
|
||||
category_id: 0,
|
||||
hosted_by_gobject_id: 0,
|
||||
template_chain: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}],
|
||||
next_page_token: String::new(),
|
||||
total_object_count: 2,
|
||||
});
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let objects = client.discover_hierarchy().await.unwrap();
|
||||
|
||||
assert_eq!(objects.len(), 2);
|
||||
let requests = state.discover_requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].page_size, 5000);
|
||||
assert_eq!(requests[0].page_token, "");
|
||||
assert_eq!(requests[1].page_token, "page-2");
|
||||
assert_eq!(objects[0].tag_name, "DelmiaReceiver_001");
|
||||
assert_eq!(objects[0].attributes.len(), 1);
|
||||
assert_eq!(objects[0].attributes[0].attribute_name, "DownloadPath");
|
||||
assert_eq!(
|
||||
objects[0].attributes[0].full_tag_reference,
|
||||
"DelmiaReceiver_001.DownloadPath"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_hierarchy_rejects_repeated_page_token() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: Vec::new(),
|
||||
next_page_token: "7:1".to_owned(),
|
||||
total_object_count: 1,
|
||||
});
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: Vec::new(),
|
||||
next_page_token: "7:1".to_owned(),
|
||||
total_object_count: 1,
|
||||
});
|
||||
let endpoint = spawn_fake(state).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = client.discover_hierarchy().await.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("repeated page token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_deploy_events_yields_events_in_order() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.watch_events.lock().unwrap() = vec![
|
||||
DeployEvent {
|
||||
sequence: 1,
|
||||
observed_at: Some(Timestamp {
|
||||
seconds: 1_700_000_000,
|
||||
nanos: 0,
|
||||
}),
|
||||
time_of_last_deploy: Some(Timestamp {
|
||||
seconds: 1_699_000_000,
|
||||
nanos: 0,
|
||||
}),
|
||||
time_of_last_deploy_present: true,
|
||||
object_count: 12,
|
||||
attribute_count: 80,
|
||||
},
|
||||
DeployEvent {
|
||||
sequence: 2,
|
||||
observed_at: Some(Timestamp {
|
||||
seconds: 1_700_000_500,
|
||||
nanos: 0,
|
||||
}),
|
||||
time_of_last_deploy: Some(Timestamp {
|
||||
seconds: 1_699_500_000,
|
||||
nanos: 0,
|
||||
}),
|
||||
time_of_last_deploy_present: true,
|
||||
object_count: 13,
|
||||
attribute_count: 85,
|
||||
},
|
||||
];
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stream = client.watch_deploy_events(None).await.unwrap();
|
||||
|
||||
let first = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("bootstrap event")
|
||||
.expect("ok deploy event");
|
||||
let second = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("second event")
|
||||
.expect("ok deploy event");
|
||||
|
||||
assert_eq!(first.sequence, 1);
|
||||
assert_eq!(first.object_count, 12);
|
||||
assert_eq!(second.sequence, 2);
|
||||
assert_eq!(second.object_count, 13);
|
||||
assert!(first.time_of_last_deploy_present);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_deploy_events_propagates_last_seen_deploy_time() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let last_seen = Timestamp {
|
||||
seconds: 1_699_999_999,
|
||||
nanos: 123_456_789,
|
||||
};
|
||||
let stream = client.watch_deploy_events(Some(last_seen)).await.unwrap();
|
||||
|
||||
// Drop the stream right away — the test is solely about the request
|
||||
// payload reaching the server.
|
||||
drop(stream);
|
||||
|
||||
// Give the server task a moment to record the request.
|
||||
for _ in 0..20 {
|
||||
if !state.watch_requests.lock().unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
let requests = state.watch_requests.lock().unwrap().clone();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let recorded = requests[0]
|
||||
.last_seen_deploy_time
|
||||
.as_ref()
|
||||
.expect("last_seen_deploy_time forwarded");
|
||||
assert_eq!(recorded.seconds, last_seen.seconds);
|
||||
assert_eq!(recorded.nanos, last_seen.nanos);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_deploy_events_drop_tears_down_call() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let (signal_tx, mut signal_rx) = mpsc::unbounded_channel();
|
||||
*state.watch_drop_signal.lock().unwrap() = Some(signal_tx);
|
||||
// Seed one event so the client gets something on the stream before we
|
||||
// drop it; this proves the call is live.
|
||||
*state.watch_events.lock().unwrap() = vec![DeployEvent {
|
||||
sequence: 7,
|
||||
observed_at: None,
|
||||
time_of_last_deploy: None,
|
||||
time_of_last_deploy_present: false,
|
||||
object_count: 0,
|
||||
attribute_count: 0,
|
||||
}];
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stream = client.watch_deploy_events(None).await.unwrap();
|
||||
let event = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("bootstrap event")
|
||||
.expect("ok deploy event");
|
||||
assert_eq!(event.sequence, 7);
|
||||
|
||||
// Dropping the client-side stream must trigger the server-side stream
|
||||
// future to be dropped as well, signalling cancellation.
|
||||
drop(stream);
|
||||
|
||||
let drop_seen = tokio::time::timeout(std::time::Duration::from_secs(2), signal_rx.recv())
|
||||
.await
|
||||
.expect("server-side stream future was not dropped within 2s");
|
||||
assert!(
|
||||
drop_seen.is_some(),
|
||||
"drop signal channel closed unexpectedly"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,11 @@ pub mod mxaccess_worker {
|
||||
tonic::include_proto!("mxaccess_worker.v1");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod galaxy_repository {
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
|
||||
tonic::include_proto!("galaxy_repository.v1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod galaxy;
|
||||
pub mod generated;
|
||||
pub mod options;
|
||||
pub mod session;
|
||||
@@ -16,6 +17,7 @@ pub mod version;
|
||||
pub use auth::{ApiKey, AuthInterceptor};
|
||||
pub use client::{EventStream, GatewayClient};
|
||||
pub use error::{CommandError, Error};
|
||||
pub use galaxy::{DeployEventStream, GalaxyClient};
|
||||
pub use options::ClientOptions;
|
||||
pub use session::Session;
|
||||
pub use value::{MxArrayProjection, MxArrayValue, MxStatus, MxValue, MxValueProjection};
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::time::Duration;
|
||||
|
||||
use crate::auth::ApiKey;
|
||||
|
||||
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientOptions {
|
||||
endpoint: String,
|
||||
@@ -13,6 +15,8 @@ pub struct ClientOptions {
|
||||
server_name_override: Option<String>,
|
||||
connect_timeout: Duration,
|
||||
call_timeout: Duration,
|
||||
stream_timeout: Option<Duration>,
|
||||
max_grpc_message_bytes: usize,
|
||||
}
|
||||
|
||||
impl ClientOptions {
|
||||
@@ -25,6 +29,8 @@ impl ClientOptions {
|
||||
server_name_override: None,
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
call_timeout: Duration::from_secs(30),
|
||||
stream_timeout: None,
|
||||
max_grpc_message_bytes: DEFAULT_MAX_GRPC_MESSAGE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +64,16 @@ impl ClientOptions {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_stream_timeout(mut self, stream_timeout: Duration) -> Self {
|
||||
self.stream_timeout = Some(stream_timeout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_grpc_message_bytes(mut self, max_grpc_message_bytes: usize) -> Self {
|
||||
self.max_grpc_message_bytes = max_grpc_message_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
@@ -85,6 +101,14 @@ impl ClientOptions {
|
||||
pub fn call_timeout(&self) -> Duration {
|
||||
self.call_timeout
|
||||
}
|
||||
|
||||
pub fn stream_timeout(&self) -> Option<Duration> {
|
||||
self.stream_timeout
|
||||
}
|
||||
|
||||
pub fn max_grpc_message_bytes(&self) -> usize {
|
||||
self.max_grpc_message_bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
@@ -104,6 +128,8 @@ impl fmt::Debug for ClientOptions {
|
||||
.field("server_name_override", &self.server_name_override)
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("call_timeout", &self.call_timeout)
|
||||
.field("stream_timeout", &self.stream_timeout)
|
||||
.field("max_grpc_message_bytes", &self.max_grpc_message_bytes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user