Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6939432f9 | |||
| 02143ef7e2 | |||
| c032852065 | |||
| 1d93e77234 | |||
| 0a670eb381 | |||
| b57662aae7 | |||
| 14afb325c3 | |||
| af42891d5a | |||
| 01a51df053 | |||
| 89a8fb876a | |||
| c58358fad9 | |||
| 8d312a6d2e | |||
| f861a8b3b8 | |||
| 499708b2a2 | |||
| 191b724f95 | |||
| 8793011838 | |||
| b275eedb44 | |||
| a9ef6d10d4 | |||
| 0f17a1d1d9 | |||
| 160343aff4 | |||
| 8ef98b8beb | |||
| f049d3e603 | |||
| ee88f9d647 | |||
| 6e34efd1a5 | |||
| 01d6c33156 | |||
| ec4e2f687e | |||
| f7929cc12f | |||
| d890eff862 | |||
| 9dcd4baff2 | |||
| 7a0743496f | |||
| bcfbd1cfc8 | |||
| 8e3b0c1c4a | |||
| bd4be85f26 | |||
| 7331c6157a | |||
| cbc317e3e7 |
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
internal sealed class CliArguments
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public CliArguments(IEnumerable<string> args)
|
||||||
|
{
|
||||||
|
string? pendingName = null;
|
||||||
|
|
||||||
|
foreach (string arg in args)
|
||||||
|
{
|
||||||
|
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (pendingName is not null)
|
||||||
|
{
|
||||||
|
_flags.Add(pendingName);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingName = arg[2..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingName is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unexpected argument '{arg}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_values[pendingName] = arg;
|
||||||
|
pendingName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingName is not null)
|
||||||
|
{
|
||||||
|
_flags.Add(pendingName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasFlag(string name)
|
||||||
|
{
|
||||||
|
return _flags.Contains(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetOptional(string name)
|
||||||
|
{
|
||||||
|
return _values.TryGetValue(name, out string? value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetRequired(string name)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Missing required option --{name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetInt32(string name, int? defaultValue = null)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
if (defaultValue.HasValue)
|
||||||
|
{
|
||||||
|
return defaultValue.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Missing required option --{name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return int.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint GetUInt32(string name, uint defaultValue)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? defaultValue
|
||||||
|
: uint.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ulong GetUInt64(string name, ulong defaultValue)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? defaultValue
|
||||||
|
: ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
||||||
|
{
|
||||||
|
string? value = GetOptional(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.EndsWith("ms", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(double.Parse(value[..^2], CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.EndsWith('s'))
|
||||||
|
{
|
||||||
|
return TimeSpan.FromSeconds(double.Parse(value[..^1], CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeSpan.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
public interface IMxGatewayCliClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
internal sealed class MxGatewayCliClientAdapter(MxGatewayClient client) : IMxGatewayCliClient
|
||||||
|
{
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return client.OpenSessionRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return client.CloseSessionRawAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return client.InvokeAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return client.StreamEventsAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return client.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
internal static class MxGatewayCliSecretRedactor
|
||||||
|
{
|
||||||
|
public static string Redact(string value, string? apiKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
public static class MxGatewayClientCli
|
||||||
|
{
|
||||||
|
private static readonly JsonFormatter ProtobufJsonFormatter = JsonFormatter.Default;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public static int Run(
|
||||||
|
string[] args,
|
||||||
|
TextWriter standardOutput,
|
||||||
|
TextWriter standardError)
|
||||||
|
{
|
||||||
|
return RunAsync(args, standardOutput, standardError)
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<int> RunAsync(
|
||||||
|
string[] args,
|
||||||
|
TextWriter standardOutput,
|
||||||
|
TextWriter standardError,
|
||||||
|
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
ArgumentNullException.ThrowIfNull(standardOutput);
|
||||||
|
ArgumentNullException.ThrowIfNull(standardError);
|
||||||
|
|
||||||
|
return RunCoreAsync(
|
||||||
|
args,
|
||||||
|
standardOutput,
|
||||||
|
standardError,
|
||||||
|
clientFactory ?? CreateDefaultClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> RunCoreAsync(
|
||||||
|
string[] args,
|
||||||
|
TextWriter standardOutput,
|
||||||
|
TextWriter standardError,
|
||||||
|
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory)
|
||||||
|
{
|
||||||
|
if (args.Length is 0 || IsHelp(args[0]))
|
||||||
|
{
|
||||||
|
WriteUsage(standardOutput);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
string command = args[0].ToLowerInvariant();
|
||||||
|
CliArguments arguments = new(args.Skip(1));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (command is "version")
|
||||||
|
{
|
||||||
|
WriteVersion(arguments, standardOutput);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsKnownGatewayCommand(command))
|
||||||
|
{
|
||||||
|
return WriteUnknownCommand(command, standardError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using IMxGatewayCliClient client = clientFactory(CreateOptions(arguments));
|
||||||
|
using CancellationTokenSource cancellation = CreateCancellation(arguments);
|
||||||
|
|
||||||
|
return command switch
|
||||||
|
{
|
||||||
|
"open-session" => await OpenSessionAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"close-session" => await CloseSessionAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"ping" => await PingAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"register" => await RegisterAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"add-item" => await AddItemAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"advise" => await AdviseAsync(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)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"smoke" => await SmokeAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
_ => WriteUnknownCommand(command, standardError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
string? apiKey = arguments.GetOptional("api-key");
|
||||||
|
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
||||||
|
|
||||||
|
if (arguments.HasFlag("json"))
|
||||||
|
{
|
||||||
|
standardError.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new { error = message, type = exception.GetType().Name },
|
||||||
|
JsonOptions));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
standardError.WriteLine(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayClientOptions CreateOptions(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string endpoint = arguments.GetOptional("endpoint")
|
||||||
|
?? Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT")
|
||||||
|
?? "http://localhost:5000";
|
||||||
|
|
||||||
|
string apiKey = ResolveApiKey(arguments);
|
||||||
|
|
||||||
|
return new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(endpoint, UriKind.Absolute),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
UseTls = arguments.HasFlag("tls")
|
||||||
|
|| endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
|
||||||
|
DefaultCallTimeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)),
|
||||||
|
ConnectTimeout = arguments.GetDuration("connect-timeout", TimeSpan.FromSeconds(10)),
|
||||||
|
CaCertificatePath = arguments.GetOptional("ca-file"),
|
||||||
|
ServerNameOverride = arguments.GetOptional("server-name"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveApiKey(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string? apiKey = arguments.GetOptional("api-key");
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
|
||||||
|
?? "MXGATEWAY_API_KEY";
|
||||||
|
|
||||||
|
apiKey = Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationTokenSource CreateCancellation(CliArguments arguments)
|
||||||
|
{
|
||||||
|
var cancellation = new CancellationTokenSource();
|
||||||
|
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
|
||||||
|
cancellation.CancelAfter(timeout);
|
||||||
|
return cancellation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> OpenSessionAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return WriteReplyAsync(
|
||||||
|
client.OpenSessionAsync(
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
ClientSessionName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-cli",
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
RequestedBackend = arguments.GetOptional("backend") ?? string.Empty,
|
||||||
|
},
|
||||||
|
cancellationToken),
|
||||||
|
arguments,
|
||||||
|
output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> CloseSessionAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return WriteReplyAsync(
|
||||||
|
client.CloseSessionAsync(
|
||||||
|
new CloseSessionRequest
|
||||||
|
{
|
||||||
|
SessionId = arguments.GetRequired("session-id"),
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
},
|
||||||
|
cancellationToken),
|
||||||
|
arguments,
|
||||||
|
output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> PingAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
Ping = new PingCommand { Message = arguments.GetOptional("message") ?? "ping" },
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> RegisterAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-cli" },
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> AddItemAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
ItemDefinition = arguments.GetRequired("item"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> AdviseAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
ItemHandle = arguments.GetInt32("item-handle"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> WriteAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
Write = new WriteCommand
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
ItemHandle = arguments.GetInt32("item-handle"),
|
||||||
|
UserId = arguments.GetInt32("user-id", 0),
|
||||||
|
Value = ParseValue(arguments),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> Write2Async(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write2,
|
||||||
|
Write2 = new Write2Command
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
ItemHandle = arguments.GetInt32("item-handle"),
|
||||||
|
UserId = arguments.GetInt32("user-id", 0),
|
||||||
|
Value = ParseValue(arguments),
|
||||||
|
TimestampValue = ParseTimestampValue(arguments),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> StreamEventsAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var events = new List<MxEvent>();
|
||||||
|
uint maxEvents = arguments.GetUInt32("max-events", 0);
|
||||||
|
uint eventCount = 0;
|
||||||
|
var request = new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = arguments.GetRequired("session-id"),
|
||||||
|
AfterWorkerSequence = arguments.GetUInt64("after-worker-sequence", 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(request, cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (arguments.HasFlag("json"))
|
||||||
|
{
|
||||||
|
events.Add(gatewayEvent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||||
|
}
|
||||||
|
|
||||||
|
eventCount++;
|
||||||
|
if (maxEvents > 0 && eventCount >= maxEvents)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.HasFlag("json"))
|
||||||
|
{
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new { events = events.Select(EventToJsonElement).ToArray() },
|
||||||
|
JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> SmokeAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
OpenSessionReply? openReply = null;
|
||||||
|
CloseSessionReply? closeReply = null;
|
||||||
|
var commandReplies = new List<MxCommandReply>();
|
||||||
|
var events = new List<MxEvent>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
openReply = await client.OpenSessionAsync(
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
ClientSessionName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke",
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
int serverHandle = await InvokeForHandleAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
openReply.SessionId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" },
|
||||||
|
},
|
||||||
|
reply => reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value,
|
||||||
|
commandReplies,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
int itemHandle = await InvokeForHandleAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
openReply.SessionId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = arguments.GetRequired("item"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reply => reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value,
|
||||||
|
commandReplies,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
commandReplies.Add(await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(
|
||||||
|
openReply.SessionId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false));
|
||||||
|
|
||||||
|
if (arguments.GetOptional("value") is not null)
|
||||||
|
{
|
||||||
|
commandReplies.Add(await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(
|
||||||
|
openReply.SessionId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
Write = new WriteCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
UserId = arguments.GetInt32("user-id", 0),
|
||||||
|
Value = ParseValue(arguments),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource streamCancellation = CancellationTokenSource
|
||||||
|
.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
streamCancellation.CancelAfter(arguments.GetDuration(
|
||||||
|
"event-timeout",
|
||||||
|
TimeSpan.FromSeconds(2)));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(
|
||||||
|
new StreamEventsRequest { SessionId = openReply.SessionId },
|
||||||
|
streamCancellation.Token)
|
||||||
|
.WithCancellation(streamCancellation.Token)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
events.Add(gatewayEvent);
|
||||||
|
if (events.Count >= arguments.GetUInt32("max-events", 1))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (openReply is not null)
|
||||||
|
{
|
||||||
|
closeReply = await client.CloseSessionAsync(
|
||||||
|
new CloseSessionRequest
|
||||||
|
{
|
||||||
|
SessionId = openReply.SessionId,
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
},
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSmokeResult(arguments, output, openReply, closeReply, commandReplies, events);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> InvokeAndWriteAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
MxCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(arguments.GetRequired("session-id"), command),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
WriteMessage(arguments, output, reply);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> InvokeForHandleAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
string sessionId,
|
||||||
|
MxCommand command,
|
||||||
|
Func<MxCommandReply, int> handleSelector,
|
||||||
|
List<MxCommandReply> replies,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(sessionId, command),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
replies.Add(reply);
|
||||||
|
return handleSelector(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<MxCommandReply> InvokeAndEnsureAsync(
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await client.InvokeAsync(request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateCommandRequest(
|
||||||
|
string sessionId,
|
||||||
|
MxCommand command)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
Command = command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> WriteReplyAsync<TReply>(
|
||||||
|
Task<TReply> replyTask,
|
||||||
|
CliArguments arguments,
|
||||||
|
TextWriter output)
|
||||||
|
where TReply : IMessage
|
||||||
|
{
|
||||||
|
TReply reply = await replyTask.ConfigureAwait(false);
|
||||||
|
WriteMessage(arguments, output, reply);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteVersion(CliArguments arguments, TextWriter output)
|
||||||
|
{
|
||||||
|
if (arguments.HasFlag("json"))
|
||||||
|
{
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
gatewayProtocolVersion = MxGatewayClientContractInfo.GatewayProtocolVersion,
|
||||||
|
workerProtocolVersion = MxGatewayClientContractInfo.WorkerProtocolVersion,
|
||||||
|
},
|
||||||
|
JsonOptions));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.WriteLine(
|
||||||
|
$"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}");
|
||||||
|
output.WriteLine(
|
||||||
|
$"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMessage(
|
||||||
|
CliArguments arguments,
|
||||||
|
TextWriter output,
|
||||||
|
IMessage message)
|
||||||
|
{
|
||||||
|
output.WriteLine(arguments.HasFlag("json")
|
||||||
|
? ProtobufJsonFormatter.Format(message)
|
||||||
|
: message.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSmokeResult(
|
||||||
|
CliArguments arguments,
|
||||||
|
TextWriter output,
|
||||||
|
OpenSessionReply? openReply,
|
||||||
|
CloseSessionReply? closeReply,
|
||||||
|
IReadOnlyList<MxCommandReply> commandReplies,
|
||||||
|
IReadOnlyList<MxEvent> events)
|
||||||
|
{
|
||||||
|
if (!arguments.HasFlag("json"))
|
||||||
|
{
|
||||||
|
output.WriteLine($"session-id={openReply?.SessionId}");
|
||||||
|
output.WriteLine($"commands={commandReplies.Count}");
|
||||||
|
output.WriteLine($"events={events.Count}");
|
||||||
|
output.WriteLine($"closed={closeReply is not null}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
sessionId = openReply?.SessionId,
|
||||||
|
closed = closeReply is not null,
|
||||||
|
commandReplies = commandReplies.Select(CommandReplyToJsonElement).ToArray(),
|
||||||
|
events = events.Select(EventToJsonElement).ToArray(),
|
||||||
|
},
|
||||||
|
JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement CommandReplyToJsonElement(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(ProtobufJsonFormatter.Format(reply)).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement EventToJsonElement(MxEvent gatewayEvent)
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(ProtobufJsonFormatter.Format(gatewayEvent)).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxValue ParseValue(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string type = arguments.GetRequired("type").ToLowerInvariant();
|
||||||
|
string value = arguments.GetRequired("value");
|
||||||
|
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
"bool" or "boolean" => bool.Parse(value).ToMxValue(),
|
||||||
|
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
|
||||||
|
"int32" or "integer" => int.Parse(value, CultureInfo.InvariantCulture).ToMxValue(),
|
||||||
|
"int32-array" or "integer-array" => values.Select(item => int.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(),
|
||||||
|
"int64" => long.Parse(value, CultureInfo.InvariantCulture).ToMxValue(),
|
||||||
|
"int64-array" => values.Select(item => long.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(),
|
||||||
|
"float" => float.Parse(value, CultureInfo.InvariantCulture).ToMxValue(),
|
||||||
|
"float-array" => values.Select(item => float.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(),
|
||||||
|
"double" => double.Parse(value, CultureInfo.InvariantCulture).ToMxValue(),
|
||||||
|
"double-array" => values.Select(item => double.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(),
|
||||||
|
"string" => value.ToMxValue(),
|
||||||
|
"string-array" => values.ToMxValue(),
|
||||||
|
"time" or "timestamp" => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).ToMxValue(),
|
||||||
|
"time-array" or "timestamp-array" => values
|
||||||
|
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
|
||||||
|
.ToArray()
|
||||||
|
.ToMxValue(),
|
||||||
|
_ => throw new ArgumentException($"Unsupported MX value type '{type}'."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxValue ParseTimestampValue(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string timestamp = arguments.GetOptional("timestamp")
|
||||||
|
?? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return DateTimeOffset.Parse(
|
||||||
|
timestamp,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal)
|
||||||
|
.ToMxValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int WriteUnknownCommand(string command, TextWriter standardError)
|
||||||
|
{
|
||||||
|
standardError.WriteLine($"Unknown command '{command}'.");
|
||||||
|
WriteUsage(standardError);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHelp(string value)
|
||||||
|
{
|
||||||
|
return string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value, "help", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKnownGatewayCommand(string command)
|
||||||
|
{
|
||||||
|
return command is "open-session"
|
||||||
|
or "close-session"
|
||||||
|
or "ping"
|
||||||
|
or "register"
|
||||||
|
or "add-item"
|
||||||
|
or "advise"
|
||||||
|
or "stream-events"
|
||||||
|
or "write"
|
||||||
|
or "write2"
|
||||||
|
or "smoke";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateCorrelationId()
|
||||||
|
{
|
||||||
|
return Guid.NewGuid().ToString("N");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUsage(TextWriter writer)
|
||||||
|
{
|
||||||
|
writer.WriteLine("mxgw-dotnet version [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet ping --session-id <id> [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet open-session [--client-name <name>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet close-session --session-id <id> [--json]");
|
||||||
|
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 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]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||||
|
private readonly List<MxEvent> _events = [];
|
||||||
|
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||||
|
|
||||||
|
public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = [];
|
||||||
|
|
||||||
|
public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = [];
|
||||||
|
|
||||||
|
public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = [];
|
||||||
|
|
||||||
|
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
|
||||||
|
|
||||||
|
public OpenSessionReply OpenSessionReply { get; set; } = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
BackendName = "mxaccess-worker",
|
||||||
|
GatewayProtocolVersion = 1,
|
||||||
|
WorkerProtocolVersion = 1,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
public CloseSessionReply CloseSessionReply { get; set; } = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
FinalState = SessionState.Closed,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
OpenSessionCalls.Add((request, callOptions));
|
||||||
|
return Task.FromResult(OpenSessionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
CloseSessionCalls.Add((request, callOptions));
|
||||||
|
return Task.FromResult(CloseSessionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
InvokeCalls.Add((request, callOptions));
|
||||||
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
StreamEventsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
foreach (MxEvent gatewayEvent in _events)
|
||||||
|
{
|
||||||
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInvokeReply(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
_invokeReplies.Enqueue(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddEvent(MxEvent gatewayEvent)
|
||||||
|
{
|
||||||
|
_events.Add(gatewayEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxCommandReplyExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EnsureSuccess_WithRegisterFixture_ReturnsReply()
|
||||||
|
{
|
||||||
|
MxCommandReply reply = ReadReplyFixture("register.ok.reply.json");
|
||||||
|
|
||||||
|
Assert.Same(reply, reply.EnsureProtocolSuccess());
|
||||||
|
Assert.Same(reply, reply.EnsureMxAccessSuccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses()
|
||||||
|
{
|
||||||
|
MxCommandReply reply = ReadReplyFixture("write.mxaccess-failure.reply.json");
|
||||||
|
|
||||||
|
reply.EnsureProtocolSuccess();
|
||||||
|
MxAccessException exception = Assert.Throws<MxAccessException>(
|
||||||
|
reply.EnsureMxAccessSuccess);
|
||||||
|
|
||||||
|
Assert.Equal(-2147220992, exception.HResultCode);
|
||||||
|
Assert.Equal(reply.Statuses.Count, exception.Statuses.Count);
|
||||||
|
Assert.Equal(reply, exception.Reply);
|
||||||
|
Assert.Contains("0x80040200", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException()
|
||||||
|
{
|
||||||
|
MxCommandReply reply = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-missing",
|
||||||
|
CorrelationId = "correlation",
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.SessionNotFound,
|
||||||
|
Message = "Session was not found.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
MxGatewaySessionException exception = Assert.Throws<MxGatewaySessionException>(
|
||||||
|
reply.EnsureProtocolSuccess);
|
||||||
|
|
||||||
|
Assert.Equal("session-missing", exception.SessionId);
|
||||||
|
Assert.Equal(ProtocolStatusCode.SessionNotFound, exception.ProtocolStatus?.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandReply ReadReplyFixture(string fileName)
|
||||||
|
{
|
||||||
|
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
directory.FullName,
|
||||||
|
"clients",
|
||||||
|
"proto",
|
||||||
|
"fixtures",
|
||||||
|
"behavior",
|
||||||
|
"command-replies",
|
||||||
|
fileName);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return JsonParser.Default.Parse<MxCommandReply>(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using MxGateway.Client.Cli;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientCliTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Run_Version_PrintsCompiledProtocolVersions()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("gateway-protocol=1", output.ToString());
|
||||||
|
Assert.Contains("worker-protocol=1", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("\"gatewayProtocolVersion\":1", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"write",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--server-handle",
|
||||||
|
"12",
|
||||||
|
"--item-handle",
|
||||||
|
"34",
|
||||||
|
"--type",
|
||||||
|
"int32",
|
||||||
|
"--value",
|
||||||
|
"123",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
||||||
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
||||||
|
Assert.Equal(123, request.Command.Write.Value.Int32Value);
|
||||||
|
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"open-session",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"secret-api-key",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => throw new InvalidOperationException("boom secret-api-key"));
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
Assert.DoesNotContain("secret-api-key", error.ToString());
|
||||||
|
Assert.Contains("[redacted]", error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 1,
|
||||||
|
});
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnWriteComplete,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-events",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--max-events",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("workerSequence", output.ToString());
|
||||||
|
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new()
|
||||||
|
{
|
||||||
|
InvokeFailure = new InvalidOperationException("register failed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"smoke",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--item",
|
||||||
|
"Area001.Pump001.Speed",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
|
||||||
|
Assert.Equal("session-fixture", closeRequest.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeCliClient : IMxGatewayCliClient
|
||||||
|
{
|
||||||
|
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
||||||
|
|
||||||
|
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
||||||
|
|
||||||
|
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
||||||
|
|
||||||
|
public List<MxEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
public Exception? InvokeFailure { get; init; }
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new OpenSessionReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
GatewayProtocolVersion = 1,
|
||||||
|
WorkerProtocolVersion = 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CloseSessionRequests.Add(request);
|
||||||
|
return Task.FromResult(new CloseSessionReply
|
||||||
|
{
|
||||||
|
SessionId = request.SessionId,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
FinalState = SessionState.Closed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
InvokeRequests.Add(request);
|
||||||
|
if (InvokeFailure is not null)
|
||||||
|
{
|
||||||
|
throw InvokeFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(InvokeReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (MxEvent gatewayEvent in Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientContractInfoTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GatewayProtocolVersion_MatchesSharedContract()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GatewayContractInfo.GatewayProtocolVersion,
|
||||||
|
MxGatewayClientContractInfo.GatewayProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkerProtocolVersion_MatchesSharedContract()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
MxGatewayClientContractInfo.WorkerProtocolVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithEmptyApiKey_Throws()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(options.Validate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientSessionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.OpenSessionRawAsync(new OpenSessionRequest(), cancellation.Token);
|
||||||
|
|
||||||
|
var call = Assert.Single(transport.OpenSessionCalls);
|
||||||
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
|
Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.OpenSessionReply.WorkerProcessId = 1234;
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
Assert.Equal("session-fixture", session.SessionId);
|
||||||
|
Assert.Same(transport.OpenSessionReply, session.OpenSessionReply);
|
||||||
|
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Register = new RegisterReply { ServerHandle = 12 },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
int serverHandle = await session.RegisterAsync("fixture-client");
|
||||||
|
|
||||||
|
Assert.Equal(12, serverHandle);
|
||||||
|
var call = Assert.Single(transport.InvokeCalls);
|
||||||
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(call.Request.ClientCorrelationId));
|
||||||
|
Assert.Equal(MxCommandKind.Register, call.Request.Command.Kind);
|
||||||
|
Assert.Equal("fixture-client", call.Request.Command.Register.ClientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
AddItem2 = new AddItem2Reply { ItemHandle = 34 },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
int itemHandle = await session.AddItem2Async(12, "Area001.Pump001.Speed", "runtime");
|
||||||
|
|
||||||
|
Assert.Equal(34, itemHandle);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.AddItem2, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.AddItem2.ServerHandle);
|
||||||
|
Assert.Equal("Area001.Pump001.Speed", request.Command.AddItem2.ItemDefinition);
|
||||||
|
Assert.Equal("runtime", request.Command.AddItem2.ItemContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
MxValue value = new()
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I4",
|
||||||
|
Int32Value = 123,
|
||||||
|
};
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.WriteRawAsync(12, 34, value, 56);
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Write, reply.Kind);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.Write.ServerHandle);
|
||||||
|
Assert.Equal(34, request.Command.Write.ItemHandle);
|
||||||
|
Assert.Same(value, request.Command.Write.Value);
|
||||||
|
Assert.Equal(56, request.Command.Write.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write2,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
MxValue value = 123.ToMxValue();
|
||||||
|
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56);
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Write2, reply.Kind);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.Write2, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.Write2.ServerHandle);
|
||||||
|
Assert.Equal(34, request.Command.Write2.ItemHandle);
|
||||||
|
Assert.Same(value, request.Command.Write2.Value);
|
||||||
|
Assert.Same(timestampValue, request.Command.Write2.TimestampValue);
|
||||||
|
Assert.Equal(56, request.Command.Write2.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddEvent(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 1,
|
||||||
|
});
|
||||||
|
transport.AddEvent(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnWriteComplete,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
List<ulong> sequences = [];
|
||||||
|
|
||||||
|
await foreach (MxEvent gatewayEvent in session.StreamEventsAsync(afterWorkerSequence: 0))
|
||||||
|
{
|
||||||
|
sequences.Add(gatewayEvent.WorkerSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal([1UL, 2UL], sequences);
|
||||||
|
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
|
||||||
|
Assert.Equal("session-fixture", request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
CloseSessionReply first = await session.CloseAsync();
|
||||||
|
CloseSessionReply second = await session.CloseAsync();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
var call = Assert.Single(transport.CloseSessionCalls);
|
||||||
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await session.AdviseAsync(12, 34, cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||||
|
{
|
||||||
|
return new MxGatewayClient(transport.Options, transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeGatewayTransport CreateTransport()
|
||||||
|
{
|
||||||
|
return new FakeGatewayTransport(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var client = MxGatewayClient.Create(options);
|
||||||
|
|
||||||
|
Assert.NotNull(client.RawClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxStatusProxyExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FixtureStatuses_ProjectSuccessAndPreserveRawFields()
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(ReadFixture(
|
||||||
|
"statuses",
|
||||||
|
"status-conversion-cases.json"));
|
||||||
|
|
||||||
|
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
|
||||||
|
{
|
||||||
|
MxStatusProxy status = JsonParser.Default.Parse<MxStatusProxy>(
|
||||||
|
testCase.GetProperty("status").GetRawText());
|
||||||
|
int success = testCase.GetProperty("status").GetProperty("success").GetInt32();
|
||||||
|
|
||||||
|
Assert.Equal(success != 0 && status.Category is MxStatusCategory.Ok, status.IsSuccess());
|
||||||
|
Assert.Equal(
|
||||||
|
testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(),
|
||||||
|
status.RawCategory);
|
||||||
|
Assert.Equal(
|
||||||
|
testCase.GetProperty("status").GetProperty("rawDetectedBy").GetInt32(),
|
||||||
|
status.RawDetectedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadFixture(string category, string fileName)
|
||||||
|
{
|
||||||
|
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
directory.FullName,
|
||||||
|
"clients",
|
||||||
|
"proto",
|
||||||
|
"fixtures",
|
||||||
|
"behavior",
|
||||||
|
category,
|
||||||
|
fileName);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxValueExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues()
|
||||||
|
{
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.BoolValue, true.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.Int32Value, 123.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.Int64Value, 123L.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.FloatValue, 1.25F.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.DoubleValue, 2.5D.ToMxValue().KindCase);
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues()
|
||||||
|
{
|
||||||
|
MxValue value = new[] { "alpha", "beta" }.ToMxValue();
|
||||||
|
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
|
||||||
|
Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase);
|
||||||
|
Assert.Equal(["alpha", "beta"], value.ArrayValue.StringValues.Values);
|
||||||
|
Assert.Equal([2U], value.ArrayValue.Dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata()
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(ReadFixture(
|
||||||
|
"values",
|
||||||
|
"value-conversion-cases.json"));
|
||||||
|
|
||||||
|
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
|
||||||
|
{
|
||||||
|
string expectedKind = testCase.GetProperty("expectedKind").GetString()!;
|
||||||
|
MxValue value = JsonParser.Default.Parse<MxValue>(
|
||||||
|
testCase.GetProperty("value").GetRawText());
|
||||||
|
|
||||||
|
Assert.Equal(expectedKind, value.GetProjectionKind());
|
||||||
|
|
||||||
|
if (testCase.GetProperty("id").GetString() is "raw-fallback.variant")
|
||||||
|
{
|
||||||
|
Assert.Equal(32767, value.RawDataType);
|
||||||
|
Assert.Equal([1, 2, 3, 4, 5], Assert.IsType<byte[]>(value.ToClrValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadFixture(string category, string fileName)
|
||||||
|
{
|
||||||
|
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
directory.FullName,
|
||||||
|
"clients",
|
||||||
|
"proto",
|
||||||
|
"fixtures",
|
||||||
|
"behavior",
|
||||||
|
category,
|
||||||
|
fileName);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
internal sealed class GrpcMxGatewayClientTransport(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient;
|
||||||
|
|
||||||
|
MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient;
|
||||||
|
|
||||||
|
public async Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.OpenSessionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.CloseSessionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.InvokeAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||||
|
? cancellationToken
|
||||||
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
|
using AsyncServerStreamingCall<MxEvent> call = RawClient.StreamEvents(request, callOptions);
|
||||||
|
|
||||||
|
IAsyncStreamReader<MxEvent> responseStream = call.ResponseStream;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
MxEvent? gatewayEvent;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayEvent = responseStream.Current;
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return StreamEventsAsync(request, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayException MapRpcException(RpcException exception)
|
||||||
|
{
|
||||||
|
return exception.StatusCode switch
|
||||||
|
{
|
||||||
|
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
innerException: exception),
|
||||||
|
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
innerException: exception),
|
||||||
|
_ => new MxGatewayException(exception.Status.Detail, exception),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
internal interface IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
MxAccessGateway.MxAccessGatewayClient? RawClient { get; }
|
||||||
|
|
||||||
|
Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public sealed class MxAccessException : MxGatewayCommandException
|
||||||
|
{
|
||||||
|
public MxAccessException(
|
||||||
|
string message,
|
||||||
|
MxCommandReply reply,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
reply.HasHresult ? reply.Hresult : null,
|
||||||
|
reply.Statuses.ToArray(),
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
Reply = reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply Reply { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public static class MxCommandReplyExtensions
|
||||||
|
{
|
||||||
|
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|
||||||
|
ProtocolStatusCode code = reply.ProtocolStatus?.Code
|
||||||
|
?? ProtocolStatusCode.Unspecified;
|
||||||
|
|
||||||
|
if (code is ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure)
|
||||||
|
{
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw CreateProtocolException(reply, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|
||||||
|
bool mxAccessFailure = reply.ProtocolStatus?.Code is ProtocolStatusCode.MxaccessFailure;
|
||||||
|
bool hResultFailure = reply.HasHresult && reply.Hresult != 0;
|
||||||
|
bool statusFailure = reply.Statuses.Any(status => !status.IsSuccess());
|
||||||
|
|
||||||
|
if (!mxAccessFailure && !hResultFailure && !statusFailure)
|
||||||
|
{
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new MxAccessException(CreateMxAccessMessage(reply), reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayException CreateProtocolException(
|
||||||
|
MxCommandReply reply,
|
||||||
|
ProtocolStatusCode code)
|
||||||
|
{
|
||||||
|
string message = CreateProtocolMessage(reply);
|
||||||
|
int? hResult = reply.HasHresult ? reply.Hresult : null;
|
||||||
|
MxStatusProxy[] statuses = reply.Statuses.ToArray();
|
||||||
|
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
ProtocolStatusCode.SessionNotFound or ProtocolStatusCode.SessionNotReady
|
||||||
|
=> new MxGatewaySessionException(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses),
|
||||||
|
ProtocolStatusCode.WorkerUnavailable
|
||||||
|
=> new MxGatewayWorkerException(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses),
|
||||||
|
_
|
||||||
|
=> new MxGatewayCommandException(
|
||||||
|
message,
|
||||||
|
reply.SessionId,
|
||||||
|
reply.CorrelationId,
|
||||||
|
reply.ProtocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateProtocolMessage(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
string statusMessage = string.IsNullOrWhiteSpace(reply.ProtocolStatus?.Message)
|
||||||
|
? "Gateway protocol failure."
|
||||||
|
: reply.ProtocolStatus.Message;
|
||||||
|
|
||||||
|
return $"{statusMessage} code={reply.ProtocolStatus?.Code}; session={reply.SessionId}; correlation={reply.CorrelationId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateMxAccessMessage(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
string statusSummary = reply.Statuses.Count is 0
|
||||||
|
? "no MXSTATUS_PROXY entries"
|
||||||
|
: string.Join("; ", reply.Statuses.Select(status => status.ToDiagnosticSummary()));
|
||||||
|
|
||||||
|
string hResult = reply.HasHresult
|
||||||
|
? $"0x{reply.Hresult:X8}"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
return $"MXAccess command failed. kind={reply.Kind}; hresult={hResult}; statuses={statusSummary}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||||
|
{
|
||||||
|
public MxGatewayAuthenticationException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||||
|
{
|
||||||
|
public MxGatewayAuthorizationException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly GrpcChannel _channel;
|
||||||
|
private readonly IMxGatewayClientTransport _transport;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal MxGatewayClient(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
IMxGatewayClientTransport transport)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
Options = options;
|
||||||
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||||
|
_channel = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxGatewayClient(
|
||||||
|
GrpcChannel channel,
|
||||||
|
IMxGatewayClientTransport transport)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
_transport = transport;
|
||||||
|
Options = transport.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient RawClient =>
|
||||||
|
_transport.RawClient
|
||||||
|
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
|
||||||
|
|
||||||
|
public static MxGatewayClient Create(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
var channel = GrpcChannel.ForAddress(
|
||||||
|
options.Endpoint,
|
||||||
|
new GrpcChannelOptions
|
||||||
|
{
|
||||||
|
LoggerFactory = options.LoggerFactory,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MxGatewayClient(
|
||||||
|
channel,
|
||||||
|
new GrpcMxGatewayClientTransport(
|
||||||
|
options,
|
||||||
|
new MxAccessGateway.MxAccessGatewayClient(channel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MxGatewaySession> OpenSessionAsync(
|
||||||
|
OpenSessionRequest? request = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
OpenSessionReply reply = await OpenSessionRawAsync(
|
||||||
|
request ?? new OpenSessionRequest(),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new MxGatewaySession(this, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OpenSessionReply> OpenSessionRawAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CloseSessionReply> CloseSessionRawAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.CloseSessionAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.StreamEventsAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_channel?.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Metadata headers = new()
|
||||||
|
{
|
||||||
|
{ "authorization", $"Bearer {Options.ApiKey}" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CallOptions(
|
||||||
|
headers,
|
||||||
|
DateTime.UtcNow.Add(Options.DefaultCallTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes the protocol versions compiled into this client package.
|
||||||
|
/// </summary>
|
||||||
|
public static class MxGatewayClientContractInfo
|
||||||
|
{
|
||||||
|
public const uint GatewayProtocolVersion =
|
||||||
|
GatewayContractInfo.GatewayProtocolVersion;
|
||||||
|
|
||||||
|
public const uint WorkerProtocolVersion =
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
public required Uri Endpoint { get; init; }
|
||||||
|
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
|
||||||
|
public bool UseTls { get; init; }
|
||||||
|
|
||||||
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
|
public string? ServerNameOverride { get; init; }
|
||||||
|
|
||||||
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public ILoggerFactory? LoggerFactory { get; init; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(Endpoint);
|
||||||
|
|
||||||
|
if (!Endpoint.IsAbsoluteUri)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The gateway endpoint must be an absolute URI.",
|
||||||
|
nameof(Endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The gateway API key must not be empty.",
|
||||||
|
nameof(ApiKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConnectTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(ConnectTimeout),
|
||||||
|
"The connect timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DefaultCallTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(DefaultCallTimeout),
|
||||||
|
"The default call timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public class MxGatewayCommandException : MxGatewayException
|
||||||
|
{
|
||||||
|
public MxGatewayCommandException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public class MxGatewayException : Exception
|
||||||
|
{
|
||||||
|
public MxGatewayException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
Statuses = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxGatewayException(string message, Exception? innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
Statuses = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxGatewayException(
|
||||||
|
string message,
|
||||||
|
string? sessionId,
|
||||||
|
string? correlationId,
|
||||||
|
ProtocolStatus? protocolStatus,
|
||||||
|
int? hResult,
|
||||||
|
IReadOnlyList<MxStatusProxy> statuses,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
SessionId = sessionId;
|
||||||
|
CorrelationId = correlationId;
|
||||||
|
ProtocolStatus = protocolStatus;
|
||||||
|
HResultCode = hResult;
|
||||||
|
Statuses = statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? SessionId { get; }
|
||||||
|
|
||||||
|
public string? CorrelationId { get; }
|
||||||
|
|
||||||
|
public ProtocolStatus? ProtocolStatus { get; }
|
||||||
|
|
||||||
|
public int? HResultCode { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one gateway-backed MXAccess session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewaySession : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly MxGatewayClient _client;
|
||||||
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
|
private CloseSessionReply? _closeReply;
|
||||||
|
|
||||||
|
internal MxGatewaySession(
|
||||||
|
MxGatewayClient client,
|
||||||
|
OpenSessionReply openSessionReply)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId => OpenSessionReply.SessionId;
|
||||||
|
|
||||||
|
public OpenSessionReply OpenSessionReply { get; }
|
||||||
|
|
||||||
|
public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_closeReply is not null)
|
||||||
|
{
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_closeReply is not null)
|
||||||
|
{
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeReply = await _client.CloseSessionRawAsync(
|
||||||
|
new CloseSessionRequest { SessionId = SessionId },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_closeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RegisterAsync(
|
||||||
|
string clientName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> RegisterRawAsync(
|
||||||
|
string clientName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = clientName },
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddItemAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AddItemRawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemDefinition,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> AddItemRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = itemDefinition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddItem2Async(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
string itemContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AddItem2RawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemDefinition,
|
||||||
|
itemContext,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> AddItem2RawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
string itemContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
AddItem2 = new AddItem2Command
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = itemDefinition,
|
||||||
|
ItemContext = itemContext ?? string.Empty,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AdviseAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AdviseRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> AdviseRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> WriteRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
Write = new WriteCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
Value = value,
|
||||||
|
UserId = userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Write2Async(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
MxValue timestampValue,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await Write2RawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemHandle,
|
||||||
|
value,
|
||||||
|
timestampValue,
|
||||||
|
userId,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> Write2RawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
MxValue timestampValue,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
ArgumentNullException.ThrowIfNull(timestampValue);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write2,
|
||||||
|
Write2 = new Write2Command
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
Value = value,
|
||||||
|
TimestampValue = timestampValue,
|
||||||
|
UserId = userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
return _client.InvokeAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
ulong afterWorkerSequence = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _client.StreamEventsAsync(
|
||||||
|
new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
AfterWorkerSequence = afterWorkerSequence,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
_closeLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<MxCommandReply> InvokeCommandAsync(
|
||||||
|
MxCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.InvokeAsync(
|
||||||
|
new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
||||||
|
Command = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public sealed class MxGatewaySessionException : MxGatewayException
|
||||||
|
{
|
||||||
|
public MxGatewaySessionException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public sealed class MxGatewayWorkerException : MxGatewayException
|
||||||
|
{
|
||||||
|
public MxGatewayWorkerException(
|
||||||
|
string message,
|
||||||
|
string? sessionId = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
ProtocolStatus? protocolStatus = null,
|
||||||
|
int? hResult = null,
|
||||||
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
: base(
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
correlationId,
|
||||||
|
protocolStatus,
|
||||||
|
hResult,
|
||||||
|
statuses ?? [],
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
public static class MxStatusProxyExtensions
|
||||||
|
{
|
||||||
|
public static bool IsSuccess(this MxStatusProxy status)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|
||||||
|
return status.Success != 0
|
||||||
|
&& status.Category is MxStatusCategory.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|
||||||
|
string diagnosticText = string.IsNullOrWhiteSpace(status.DiagnosticText)
|
||||||
|
? "no diagnostic text"
|
||||||
|
: status.DiagnosticText;
|
||||||
|
|
||||||
|
return $"{status.Category} by {status.DetectedBy}; detail={status.Detail}; {diagnosticText}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and projects gateway MXAccess values without hiding the raw
|
||||||
|
/// protobuf value carried by command replies and events.
|
||||||
|
/// </summary>
|
||||||
|
public static class MxValueExtensions
|
||||||
|
{
|
||||||
|
public static MxValue ToMxValue(this bool value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Boolean,
|
||||||
|
VariantType = "VT_BOOL",
|
||||||
|
BoolValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this int value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I4",
|
||||||
|
Int32Value = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this long value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I8",
|
||||||
|
Int64Value = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this float value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Float,
|
||||||
|
VariantType = "VT_R4",
|
||||||
|
FloatValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this double value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Double,
|
||||||
|
VariantType = "VT_R8",
|
||||||
|
DoubleValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this string value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.String,
|
||||||
|
VariantType = "VT_BSTR",
|
||||||
|
StringValue = value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this DateTimeOffset value)
|
||||||
|
{
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Time,
|
||||||
|
VariantType = "VT_DATE",
|
||||||
|
TimestampValue = Timestamp.FromDateTimeOffset(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this DateTime value)
|
||||||
|
{
|
||||||
|
return new DateTimeOffset(
|
||||||
|
value.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
: value.ToUniversalTime())
|
||||||
|
.ToMxValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new BoolArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Boolean, "VT_ARRAY|VT_BOOL", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Boolean,
|
||||||
|
VariantType = "VT_ARRAY|VT_BOOL",
|
||||||
|
BoolValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new Int32Array();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I4", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_ARRAY|VT_I4",
|
||||||
|
Int32Values = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new Int64Array();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I8", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_ARRAY|VT_I8",
|
||||||
|
Int64Values = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new FloatArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Float, "VT_ARRAY|VT_R4", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Float,
|
||||||
|
VariantType = "VT_ARRAY|VT_R4",
|
||||||
|
FloatValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new DoubleArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.Double, "VT_ARRAY|VT_R8", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Double,
|
||||||
|
VariantType = "VT_ARRAY|VT_R8",
|
||||||
|
DoubleValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new StringArray();
|
||||||
|
array.Values.Add(values);
|
||||||
|
return CreateArrayValue(MxDataType.String, "VT_ARRAY|VT_BSTR", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.String,
|
||||||
|
VariantType = "VT_ARRAY|VT_BSTR",
|
||||||
|
StringValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
var array = new TimestampArray();
|
||||||
|
array.Values.Add(values.Select(Timestamp.FromDateTimeOffset));
|
||||||
|
return CreateArrayValue(MxDataType.Time, "VT_ARRAY|VT_DATE", values.Count, new MxArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Time,
|
||||||
|
VariantType = "VT_ARRAY|VT_DATE",
|
||||||
|
TimestampValues = array,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetProjectionKind(this MxValue value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return value.KindCase switch
|
||||||
|
{
|
||||||
|
MxValue.KindOneofCase.BoolValue => "boolValue",
|
||||||
|
MxValue.KindOneofCase.Int32Value => "int32Value",
|
||||||
|
MxValue.KindOneofCase.Int64Value => "int64Value",
|
||||||
|
MxValue.KindOneofCase.FloatValue => "floatValue",
|
||||||
|
MxValue.KindOneofCase.DoubleValue => "doubleValue",
|
||||||
|
MxValue.KindOneofCase.StringValue => "stringValue",
|
||||||
|
MxValue.KindOneofCase.TimestampValue => "timestampValue",
|
||||||
|
MxValue.KindOneofCase.ArrayValue => "arrayValue",
|
||||||
|
MxValue.KindOneofCase.RawValue => "rawValue",
|
||||||
|
_ => value.IsNull ? "nullValue" : "unspecified",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static object? ToClrValue(this MxValue value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return value.KindCase switch
|
||||||
|
{
|
||||||
|
MxValue.KindOneofCase.BoolValue => value.BoolValue,
|
||||||
|
MxValue.KindOneofCase.Int32Value => value.Int32Value,
|
||||||
|
MxValue.KindOneofCase.Int64Value => value.Int64Value,
|
||||||
|
MxValue.KindOneofCase.FloatValue => value.FloatValue,
|
||||||
|
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
|
||||||
|
MxValue.KindOneofCase.StringValue => value.StringValue,
|
||||||
|
MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTimeOffset(),
|
||||||
|
MxValue.KindOneofCase.ArrayValue => value.ArrayValue.ToClrArrayValue(),
|
||||||
|
MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(),
|
||||||
|
_ => value.IsNull ? null : value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static object? ToClrArrayValue(this MxArray array)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(array);
|
||||||
|
|
||||||
|
return array.ValuesCase switch
|
||||||
|
{
|
||||||
|
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values
|
||||||
|
.Select(timestamp => timestamp.ToDateTimeOffset())
|
||||||
|
.ToArray(),
|
||||||
|
MxArray.ValuesOneofCase.RawValues => array.RawValues.Values
|
||||||
|
.Select(value => value.ToByteArray())
|
||||||
|
.ToArray(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxValue ToRawMxValue(
|
||||||
|
byte[] value,
|
||||||
|
string variantType,
|
||||||
|
string rawDiagnostic,
|
||||||
|
int rawDataType = 0)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Unknown,
|
||||||
|
VariantType = variantType,
|
||||||
|
RawDiagnostic = rawDiagnostic,
|
||||||
|
RawDataType = rawDataType,
|
||||||
|
RawValue = ByteString.CopyFrom(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxValue CreateArrayValue(
|
||||||
|
MxDataType dataType,
|
||||||
|
string variantType,
|
||||||
|
int length,
|
||||||
|
MxArray array)
|
||||||
|
{
|
||||||
|
array.Dimensions.Add((uint)length);
|
||||||
|
return new MxValue
|
||||||
|
{
|
||||||
|
DataType = dataType,
|
||||||
|
VariantType = variantType,
|
||||||
|
ArrayValue = array,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# .NET Client Projects
|
||||||
|
|
||||||
|
The .NET client workspace contains the MXAccess Gateway client library, test
|
||||||
|
CLI, and unit tests.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
| Project | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
||||||
|
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||||
|
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
||||||
|
|
||||||
|
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
|
||||||
|
the client compiles against the same generated protobuf and gRPC types as the
|
||||||
|
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
||||||
|
future client build switches to client-local `Grpc.Tools` generation.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build clients/dotnet/MxGateway.Client.sln
|
||||||
|
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Usage
|
||||||
|
|
||||||
|
`MxGatewayClient` opens a gRPC channel to the gateway and attaches the API key
|
||||||
|
to every unary and streaming call as `authorization: Bearer <api-key>`.
|
||||||
|
Cancellation tokens passed to the public methods flow to the generated gRPC
|
||||||
|
call. Client-side cancellation stops waiting for the gateway response; it does
|
||||||
|
not abort an MXAccess COM call that is already executing inside a worker.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using MxGatewayClient client = MxGatewayClient.Create(
|
||||||
|
new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int serverHandle = await session.RegisterAsync("sample-client");
|
||||||
|
int itemHandle = await session.AddItemAsync(
|
||||||
|
serverHandle,
|
||||||
|
"Area001.Pump001.Speed");
|
||||||
|
|
||||||
|
await session.AdviseAsync(serverHandle, itemHandle);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await session.CloseAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `OpenSessionRawAsync`, `CloseSessionRawAsync`, `InvokeAsync`, and
|
||||||
|
`StreamEventsAsync` when tests or parity tools need direct generated protobuf
|
||||||
|
messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
||||||
|
available, and command helpers have `*RawAsync` variants when callers need the
|
||||||
|
complete `MxCommandReply`.
|
||||||
|
|
||||||
|
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||||
|
the first `CloseSessionReply` instead of sending another close request.
|
||||||
|
|
||||||
|
## Values, Status, And Errors
|
||||||
|
|
||||||
|
The client provides extension helpers for generated protobuf values. Use
|
||||||
|
`ToMxValue()` on .NET scalar values and typed arrays to create `MxValue`
|
||||||
|
instances for `Write` and `Write2`. Use `ToClrValue()` and
|
||||||
|
`GetProjectionKind()` when test or diagnostic code needs to inspect generated
|
||||||
|
`MxValue` replies while preserving `rawDiagnostic`, raw data type fields, and
|
||||||
|
raw byte payloads.
|
||||||
|
|
||||||
|
`MxStatusProxy.IsSuccess()` and `ToDiagnosticSummary()` expose MXAccess status
|
||||||
|
arrays without collapsing them into a single gateway success flag. Command
|
||||||
|
reply helpers follow the same split:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
reply.EnsureProtocolSuccess();
|
||||||
|
reply.EnsureMxAccessSuccess();
|
||||||
|
```
|
||||||
|
|
||||||
|
`EnsureProtocolSuccess()` raises gateway, session, worker, or command
|
||||||
|
exceptions for gateway-level failures. It leaves
|
||||||
|
`PROTOCOL_STATUS_CODE_MXACCESS_FAILURE` to `EnsureMxAccessSuccess()` so callers
|
||||||
|
can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
|
||||||
|
itself rejects a command. `MxAccessException.Reply` contains the raw generated
|
||||||
|
reply.
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
The test CLI supports deterministic JSON output for automation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||||
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||||
|
optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||||
|
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||||
|
output redacts API keys supplied through `--api-key`.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Go Client
|
||||||
|
|
||||||
|
The Go client module contains the generated MXAccess Gateway protobuf bindings,
|
||||||
|
a small handwritten `mxgateway` package, and the `mxgw-go` test CLI scaffold.
|
||||||
|
The module uses the shared proto inputs documented in
|
||||||
|
`../../docs/client-proto-generation.md` so gateway and client contracts stay in
|
||||||
|
sync.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/go/
|
||||||
|
go.mod
|
||||||
|
generate-proto.ps1
|
||||||
|
internal/generated/
|
||||||
|
mxgateway/
|
||||||
|
cmd/mxgw-go/
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/generated` contains code produced by `protoc`, `protoc-gen-go`, and
|
||||||
|
`protoc-gen-go-grpc`. Do not edit generated files by hand.
|
||||||
|
|
||||||
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
|
Run generation after the shared `.proto` files or the Go output path changes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./generate-proto.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses the tool paths recorded in `../../docs/toolchain-links.md`.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Go module checks from `clients/go`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./...
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Client API
|
||||||
|
|
||||||
|
Use `mxgateway.Dial` with `mxgateway.Options` to configure plaintext or TLS
|
||||||
|
transport, API-key metadata, dial timeout, and per-call timeout:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
||||||
|
Plaintext: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||||
|
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. 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.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
|
||||||
|
the gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/mxgw-go version -json
|
||||||
|
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type versionOutput struct {
|
||||||
|
ClientVersion string `json:"clientVersion"`
|
||||||
|
GatewayProtocolVersion uint32 `json:"gatewayProtocolVersion"`
|
||||||
|
WorkerProtocolVersion uint32 `json:"workerProtocolVersion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commonOptions struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||||
|
Plaintext bool `json:"plaintext"`
|
||||||
|
CACertFile string `json:"caCertFile,omitempty"`
|
||||||
|
ServerName string `json:"serverNameOverride,omitempty"`
|
||||||
|
CallTimeout string `json:"callTimeout,omitempty"`
|
||||||
|
|
||||||
|
apiKeyValue string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type openSessionOutput struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Options commonOptions `json:"options"`
|
||||||
|
Reply json.RawMessage `json:"reply"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commandReplyOutput struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Options commonOptions `json:"options"`
|
||||||
|
Reply json.RawMessage `json:"reply"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := runWithIO(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) error {
|
||||||
|
return runWithIO(context.Background(), args, os.Stdout, os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
writeUsage(stderr)
|
||||||
|
return errors.New("missing command")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "version":
|
||||||
|
return runVersion(args[1:], stdout, stderr)
|
||||||
|
case "open-session":
|
||||||
|
return runOpenSession(ctx, args[1:], stdout, stderr)
|
||||||
|
case "close-session":
|
||||||
|
return runCloseSession(ctx, args[1:], stdout, stderr)
|
||||||
|
case "register":
|
||||||
|
return runRegister(ctx, args[1:], stdout, stderr)
|
||||||
|
case "add-item":
|
||||||
|
return runAddItem(ctx, args[1:], stdout, stderr)
|
||||||
|
case "advise":
|
||||||
|
return runAdvise(ctx, args[1:], stdout, stderr)
|
||||||
|
case "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)
|
||||||
|
default:
|
||||||
|
writeUsage(stderr)
|
||||||
|
return fmt.Errorf("unknown command %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVersion(args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
output := versionOutput{
|
||||||
|
ClientVersion: mxgateway.ClientVersion,
|
||||||
|
GatewayProtocolVersion: mxgateway.GatewayProtocolVersion,
|
||||||
|
WorkerProtocolVersion: mxgateway.WorkerProtocolVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "mxgw-go %s\n", output.ClientVersion)
|
||||||
|
fmt.Fprintf(stdout, "gateway protocol %d\n", output.GatewayProtocolVersion)
|
||||||
|
fmt.Fprintf(stdout, "worker protocol %d\n", output.WorkerProtocolVersion)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOpenSession(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("open-session", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
clientName := flags.String("client-session-name", "", "client session name")
|
||||||
|
backend := flags.String("backend", "", "requested backend")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
reply, err := client.OpenSessionRaw(ctx, (&mxgateway.OpenSessionOptions{
|
||||||
|
RequestedBackend: *backend,
|
||||||
|
ClientSessionName: *clientName,
|
||||||
|
}).Request())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, openSessionOutput{
|
||||||
|
Command: "open-session",
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, reply.GetSessionId())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCloseSession(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("close-session", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
reply, err := client.CloseSessionRaw(ctx, &mxgateway.CloseSessionRequest{SessionId: *sessionID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, commandReplyOutput{
|
||||||
|
Command: "close-session",
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, reply.GetFinalState())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRegister(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("register", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
clientName := flags.String("client-name", "", "MXAccess client name")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" || *clientName == "" {
|
||||||
|
return errors.New("session-id and client-name are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.RegisterRaw(ctx, *clientName)
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "register", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAddItem(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("add-item", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
item := flags.String("item", "", "item definition")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" || *item == "" {
|
||||||
|
return errors.New("session-id and item are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.AddItemRaw(ctx, int32(*serverHandle), *item)
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "add-item", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAdvise(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("advise", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.AdviseRaw(ctx, int32(*serverHandle), int32(*itemHandle))
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "advise", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
|
||||||
|
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
||||||
|
valueText := flags.String("value", "", "value text")
|
||||||
|
userID := flags.Int("user-id", 0, "MXAccess user id")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := parseValue(*valueType, *valueText)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.WriteRaw(ctx, int32(*serverHandle), int32(*itemHandle), value, int32(*userID))
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "write", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("stream-events", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
after := flags.Uint64("after-worker-sequence", 0, "first worker sequence to read after")
|
||||||
|
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, _, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||||
|
defer cancelStream()
|
||||||
|
events, err := session.EventsAfter(streamCtx, *after)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for result := range events {
|
||||||
|
if result.Err != nil {
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
fmt.Fprintln(stdout, string(mustMarshalProto(result.Event)))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(stdout, "%d %s\n", result.Event.GetWorkerSequence(), result.Event.GetFamily())
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if *limit > 0 && count >= *limit {
|
||||||
|
cancelStream()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
clientName := flags.String("client-name", "mxgw-go-smoke", "MXAccess client name")
|
||||||
|
item := flags.String("item", "", "item definition")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *item == "" {
|
||||||
|
return errors.New("item is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.OpenSession(ctx, mxgateway.OpenSessionOptions{ClientSessionName: *clientName})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close(context.Background())
|
||||||
|
|
||||||
|
serverHandle, err := session.Register(ctx, *clientName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemHandle, err := session.AddItem(ctx, serverHandle, *item)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := session.Advise(ctx, serverHandle, itemHandle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
output := map[string]any{
|
||||||
|
"command": "smoke",
|
||||||
|
"options": options,
|
||||||
|
"sessionId": session.ID(),
|
||||||
|
"serverHandle": serverHandle,
|
||||||
|
"itemHandle": itemHandle,
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "session=%s server=%d item=%d\n", session.ID(), serverHandle, itemHandle)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||||
|
common := &commonOptions{}
|
||||||
|
flags.StringVar(&common.Endpoint, "endpoint", "localhost:5000", "gateway endpoint")
|
||||||
|
flags.StringVar(&common.APIKey, "api-key", "", "gateway API key")
|
||||||
|
flags.StringVar(&common.APIKeyEnv, "api-key-env", "MXGATEWAY_API_KEY", "environment variable containing the API key")
|
||||||
|
flags.BoolVar(&common.Plaintext, "plaintext", false, "use plaintext transport")
|
||||||
|
flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file")
|
||||||
|
flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override")
|
||||||
|
flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout")
|
||||||
|
return common
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Client, commonOptions, error) {
|
||||||
|
options, err := common.resolved()
|
||||||
|
if err != nil {
|
||||||
|
return nil, options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||||
|
Endpoint: options.Endpoint,
|
||||||
|
APIKey: options.apiKeyValue,
|
||||||
|
Plaintext: options.Plaintext,
|
||||||
|
CACertFile: options.CACertFile,
|
||||||
|
ServerNameOverride: options.ServerName,
|
||||||
|
CallTimeout: options.timeout,
|
||||||
|
})
|
||||||
|
return client, options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *commonOptions) resolved() (commonOptions, error) {
|
||||||
|
resolved := *o
|
||||||
|
if resolved.APIKey == "" && resolved.APIKeyEnv != "" {
|
||||||
|
resolved.apiKeyValue = os.Getenv(resolved.APIKeyEnv)
|
||||||
|
} else {
|
||||||
|
resolved.apiKeyValue = resolved.APIKey
|
||||||
|
}
|
||||||
|
resolved.APIKey = mxgateway.RedactAPIKey(resolved.apiKeyValue)
|
||||||
|
if resolved.CallTimeout != "" {
|
||||||
|
timeout, err := time.ParseDuration(resolved.CallTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return resolved, err
|
||||||
|
}
|
||||||
|
resolved.timeout = timeout
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
|
||||||
|
switch valueType {
|
||||||
|
case "bool":
|
||||||
|
value, err := strconv.ParseBool(valueText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.BoolValue(value), nil
|
||||||
|
case "int32":
|
||||||
|
value, err := strconv.ParseInt(valueText, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.Int32Value(int32(value)), nil
|
||||||
|
case "int64":
|
||||||
|
value, err := strconv.ParseInt(valueText, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.Int64Value(value), nil
|
||||||
|
case "float":
|
||||||
|
value, err := strconv.ParseFloat(valueText, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.FloatValue(float32(value)), nil
|
||||||
|
case "double":
|
||||||
|
value, err := strconv.ParseFloat(valueText, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.DoubleValue(value), nil
|
||||||
|
case "string":
|
||||||
|
return mxgateway.StringValue(valueText), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported value type %q", valueType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCommandOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, reply *mxgateway.MxCommandReply, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return writeJSON(stdout, commandReplyOutput{
|
||||||
|
Command: command,
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, reply.GetKind())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(writer)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalProto(message protojsonMessage) json.RawMessage {
|
||||||
|
data, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
type protojsonMessage interface {
|
||||||
|
ProtoReflect() protoreflect.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUsage(writer io.Writer) {
|
||||||
|
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|write|stream-events|smoke>")
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunVersionJSON(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
|
||||||
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var output versionOutput
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
||||||
|
t.Fatalf("parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
||||||
|
t.Fatalf("protocol versions were not populated: %+v", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonOptionsRedactsAPIKey(t *testing.T) {
|
||||||
|
options, err := (&commonOptions{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: "mxgw_super_secret",
|
||||||
|
Plaintext: true,
|
||||||
|
CallTimeout: "2s",
|
||||||
|
}).resolved()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolved() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal options: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "super_secret") {
|
||||||
|
t.Fatalf("redacted JSON leaked API key: %s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "mxgw") {
|
||||||
|
t.Fatalf("redacted JSON did not preserve key shape: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||||
|
value, err := parseValue("int32", "123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := value.GetInt32Value(); got != 123 {
|
||||||
|
t.Fatalf("int32 value = %d, want 123", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||||
|
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
||||||
|
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||||
|
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||||
|
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||||
|
$goPluginPath = 'C:\Users\dohertj2\go\bin'
|
||||||
|
|
||||||
|
if (-not (Test-Path $protoc)) {
|
||||||
|
throw "protoc was not found at $protoc. See docs/toolchain-links.md."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) {
|
||||||
|
$pluginPath = Join-Path $goPluginPath $pluginName
|
||||||
|
if (-not (Test-Path $pluginPath)) {
|
||||||
|
throw "$pluginName was not found at $pluginPath. See docs/toolchain-links.md."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||||
|
Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item
|
||||||
|
|
||||||
|
$env:Path = "$goPluginPath;$env:Path"
|
||||||
|
|
||||||
|
& $protoc `
|
||||||
|
--proto_path=$protoRoot `
|
||||||
|
--go_out=$outputRoot `
|
||||||
|
--go_opt=paths=source_relative `
|
||||||
|
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||||
|
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
|
||||||
|
mxaccess_gateway.proto `
|
||||||
|
mxaccess_worker.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
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v7.34.1
|
||||||
|
// source: mxaccess_gateway.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 (
|
||||||
|
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
|
||||||
|
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
|
||||||
|
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||||
|
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MxAccessGatewayClient is the client API for MxAccessGateway 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.
|
||||||
|
//
|
||||||
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
type MxAccessGatewayClient interface {
|
||||||
|
OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error)
|
||||||
|
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error)
|
||||||
|
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||||
|
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mxAccessGatewayClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMxAccessGatewayClient(cc grpc.ClientConnInterface) MxAccessGatewayClient {
|
||||||
|
return &mxAccessGatewayClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OpenSessionReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_OpenSession_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CloseSessionReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_CloseSession_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(MxCommandReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_Invoke_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[0], MxAccessGateway_StreamEvents_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[StreamEventsRequest, MxEvent]{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 MxAccessGateway_StreamEventsClient = grpc.ServerStreamingClient[MxEvent]
|
||||||
|
|
||||||
|
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||||
|
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
type MxAccessGatewayServer interface {
|
||||||
|
OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error)
|
||||||
|
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error)
|
||||||
|
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||||
|
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||||
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedMxAccessGatewayServer 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 UnimplementedMxAccessGatewayServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedMxAccessGatewayServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method OpenSession not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Invoke not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method StreamEvents not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeMxAccessGatewayServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to MxAccessGatewayServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeMxAccessGatewayServer interface {
|
||||||
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterMxAccessGatewayServer(s grpc.ServiceRegistrar, srv MxAccessGatewayServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedMxAccessGatewayServer 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(&MxAccessGateway_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(OpenSessionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).OpenSession(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_OpenSession_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).OpenSession(ctx, req.(*OpenSessionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CloseSessionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).CloseSession(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_CloseSession_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).CloseSession(ctx, req.(*CloseSessionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_Invoke_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(MxCommandRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).Invoke(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_Invoke_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).Invoke(ctx, req.(*MxCommandRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(StreamEventsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(MxAccessGatewayServer).StreamEvents(m, &grpc.GenericServerStream[StreamEventsRequest, MxEvent]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamEventsServer = grpc.ServerStreamingServer[MxEvent]
|
||||||
|
|
||||||
|
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "mxaccess_gateway.v1.MxAccessGateway",
|
||||||
|
HandlerType: (*MxAccessGatewayServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "OpenSession",
|
||||||
|
Handler: _MxAccessGateway_OpenSession_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CloseSession",
|
||||||
|
Handler: _MxAccessGateway_CloseSession_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Invoke",
|
||||||
|
Handler: _MxAccessGateway_Invoke_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "StreamEvents",
|
||||||
|
Handler: _MxAccessGateway_StreamEvents_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "mxaccess_gateway.proto",
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const authorizationHeader = "authorization"
|
||||||
|
|
||||||
|
func unaryAuthInterceptor(apiKey string) grpc.UnaryClientInterceptor {
|
||||||
|
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||||
|
return invoker(authContext(ctx, apiKey), method, req, reply, cc, opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamAuthInterceptor(apiKey string) grpc.StreamClientInterceptor {
|
||||||
|
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||||
|
return streamer(authContext(ctx, apiKey), desc, cc, method, opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContext(ctx context.Context, apiKey string) context.Context {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.AppendToOutgoingContext(ctx, authorizationHeader, "Bearer "+apiKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDialTimeout = 10 * time.Second
|
||||||
|
defaultCallTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client owns a gateway gRPC connection and exposes session-oriented helpers.
|
||||||
|
type Client struct {
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
raw pb.MxAccessGatewayClient
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a gRPC connection to the gateway and configures auth metadata,
|
||||||
|
// transport security, and blocking dial cancellation from ctx.
|
||||||
|
func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||||
|
if opts.Endpoint == "" {
|
||||||
|
return nil, errors.New("mxgateway: endpoint is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx := ctx
|
||||||
|
cancel := func() {}
|
||||||
|
if opts.DialTimeout > 0 {
|
||||||
|
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
|
||||||
|
} else if _, ok := ctx.Deadline(); !ok {
|
||||||
|
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
transportCredentials, err := resolveTransportCredentials(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dialOptions := []grpc.DialOption{
|
||||||
|
grpc.WithTransportCredentials(transportCredentials),
|
||||||
|
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||||
|
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||||
|
grpc.WithBlock(),
|
||||||
|
}
|
||||||
|
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "dial", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewClient(conn, opts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps an existing gRPC connection. The caller owns closing conn
|
||||||
|
// unless it calls Close on the returned Client.
|
||||||
|
func NewClient(conn *grpc.ClientConn, opts Options) *Client {
|
||||||
|
return &Client{
|
||||||
|
conn: conn,
|
||||||
|
raw: pb.NewMxAccessGatewayClient(conn),
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawClient returns the generated gRPC client for command-specific parity tests.
|
||||||
|
func (c *Client) RawClient() RawGatewayClient {
|
||||||
|
return c.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSession creates a gateway-backed MXAccess session.
|
||||||
|
func (c *Client) OpenSession(ctx context.Context, opts OpenSessionOptions) (*Session, error) {
|
||||||
|
reply, err := c.OpenSessionRaw(ctx, opts.Request())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSession(c, reply), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSessionRaw sends a raw OpenSession request and validates protocol status.
|
||||||
|
func (c *Client) OpenSessionRaw(ctx context.Context, req *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: open session request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.OpenSession(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "open session", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("open session", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke sends a raw MXAccess command request and validates protocol and
|
||||||
|
// MXAccess status fields while preserving the raw reply on typed errors.
|
||||||
|
func (c *Client) Invoke(ctx context.Context, req *MxCommandRequest) (*MxCommandReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: command request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.Invoke(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "invoke", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("invoke", reply.GetProtocolStatus(), reply); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
if err := EnsureMxAccessSuccess("invoke", reply); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseSessionRaw sends a raw CloseSession request and validates protocol
|
||||||
|
// status.
|
||||||
|
func (c *Client) CloseSessionRaw(ctx context.Context, req *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: close session request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.CloseSession(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "close session", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("close session", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEventsRaw starts the generated event stream for callers that need direct
|
||||||
|
// control over Recv.
|
||||||
|
func (c *Client) StreamEventsRaw(ctx context.Context, req *StreamEventsRequest) (RawEventStream, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: stream events request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.raw.StreamEvents(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "stream events", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying gRPC connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
if c == nil || c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
timeout := c.opts.CallTimeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = defaultCallTimeout
|
||||||
|
}
|
||||||
|
if timeout < 0 {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
if _, ok := ctx.Deadline(); ok {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTransportCredentials(opts Options) (credentials.TransportCredentials, error) {
|
||||||
|
if opts.TransportCredentials != nil {
|
||||||
|
return opts.TransportCredentials, nil
|
||||||
|
}
|
||||||
|
if opts.Plaintext {
|
||||||
|
return insecure.NewCredentials(), nil
|
||||||
|
}
|
||||||
|
if opts.CACertFile != "" {
|
||||||
|
return credentials.NewClientTLSFromFile(opts.CACertFile, opts.ServerNameOverride)
|
||||||
|
}
|
||||||
|
if opts.TLSConfig != nil {
|
||||||
|
cfg := opts.TLSConfig.Clone()
|
||||||
|
if opts.ServerNameOverride != "" {
|
||||||
|
cfg.ServerName = opts.ServerNameOverride
|
||||||
|
}
|
||||||
|
return credentials.NewTLS(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials.NewTLS(&tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: opts.ServerNameOverride,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
||||||
|
type OpenSessionOptions struct {
|
||||||
|
RequestedBackend string
|
||||||
|
ClientSessionName string
|
||||||
|
ClientCorrelationID string
|
||||||
|
CommandTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request returns the raw protobuf OpenSessionRequest for these options.
|
||||||
|
func (o OpenSessionOptions) Request() *OpenSessionRequest {
|
||||||
|
req := &OpenSessionRequest{
|
||||||
|
RequestedBackend: o.RequestedBackend,
|
||||||
|
ClientSessionName: o.ClientSessionName,
|
||||||
|
ClientCorrelationId: o.ClientCorrelationID,
|
||||||
|
}
|
||||||
|
if o.CommandTimeout > 0 {
|
||||||
|
req.CommandTimeout = durationpb.New(o.CommandTimeout)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufSize = 1024 * 1024
|
||||||
|
|
||||||
|
func TestDialAttachesAuthMetadataToUnaryCalls(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
openReply: &pb.OpenSessionReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
GatewayProtocolVersion: GatewayProtocolVersion,
|
||||||
|
WorkerProtocolVersion: WorkerProtocolVersion,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.OpenSession(context.Background(), OpenSessionOptions{ClientSessionName: "fixture"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fake.openAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
streamStarted: make(chan struct{}),
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
events, err := session.Events(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Events() error = %v", err)
|
||||||
|
}
|
||||||
|
<-fake.streamStarted
|
||||||
|
|
||||||
|
first := <-events
|
||||||
|
if first.Err != nil {
|
||||||
|
t.Fatalf("first event error = %v", first.Err)
|
||||||
|
}
|
||||||
|
if first.Event.GetWorkerSequence() != 1 {
|
||||||
|
t.Fatalf("worker sequence = %d, want 1", first.Event.GetWorkerSequence())
|
||||||
|
}
|
||||||
|
if got := fake.streamAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("stream authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case _, ok := <-events:
|
||||||
|
if ok {
|
||||||
|
t.Fatal("events channel produced an extra item after cancellation")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("events channel did not close after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_AddItem2{
|
||||||
|
AddItem2: &pb.AddItem2Reply{ItemHandle: 42},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
itemHandle, err := session.AddItem2(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemHandle != 42 {
|
||||||
|
t.Fatalf("item handle = %d, want 42", itemHandle)
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetSessionId() != "session-1" {
|
||||||
|
t.Fatalf("session id = %q, want session-1", req.GetSessionId())
|
||||||
|
}
|
||||||
|
if req.GetClientCorrelationId() == "" {
|
||||||
|
t.Fatal("client correlation id is empty")
|
||||||
|
}
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2 {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if req.GetCommand().GetAddItem2().GetItemContext() != "runtime" {
|
||||||
|
t.Fatalf("item context = %q, want runtime", req.GetCommand().GetAddItem2().GetItemContext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
||||||
|
hresult := int32(-2147467259)
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
|
||||||
|
Hresult: &hresult,
|
||||||
|
DiagnosticMessage: "native failure",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, Message: "MXAccess failed"},
|
||||||
|
Statuses: []*pb.MxStatusProxy{{Success: 0, DiagnosticText: "failed"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
err := session.Advise(context.Background(), 12, 34)
|
||||||
|
|
||||||
|
var mxErr *MxAccessError
|
||||||
|
if !errors.As(err, &mxErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*MxAccessError)", err)
|
||||||
|
}
|
||||||
|
if mxErr.Reply.GetHresult() != hresult {
|
||||||
|
t.Fatalf("raw reply HRESULT = %d, want %d", mxErr.Reply.GetHresult(), hresult)
|
||||||
|
}
|
||||||
|
var commandErr *CommandError
|
||||||
|
if !errors.As(err, &commandErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*CommandError)", err)
|
||||||
|
}
|
||||||
|
if commandErr.Reply.GetDiagnosticMessage() != "native failure" {
|
||||||
|
t.Fatalf("raw diagnostic = %q", commandErr.Reply.GetDiagnosticMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(bufSize)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||||
|
t.Errorf("bufconn server failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return listener.DialContext(ctx)
|
||||||
|
}
|
||||||
|
client, err := Dial(context.Background(), Options{
|
||||||
|
Endpoint: "bufnet",
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
Plaintext: true,
|
||||||
|
DialOptions: []grpc.DialOption{
|
||||||
|
grpc.WithContextDialer(dialer),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, func() {
|
||||||
|
client.Close()
|
||||||
|
server.Stop()
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGatewayServer struct {
|
||||||
|
pb.UnimplementedMxAccessGatewayServer
|
||||||
|
|
||||||
|
openReply *pb.OpenSessionReply
|
||||||
|
openAuth string
|
||||||
|
streamAuth string
|
||||||
|
streamStarted chan struct{}
|
||||||
|
invokeReply *pb.MxCommandReply
|
||||||
|
invokeRequest *pb.MxCommandRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) OpenSession(ctx context.Context, req *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
||||||
|
s.openAuth = authorizationFromContext(ctx)
|
||||||
|
if s.openReply != nil {
|
||||||
|
return s.openReply, nil
|
||||||
|
}
|
||||||
|
return &pb.OpenSessionReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) CloseSession(ctx context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
|
||||||
|
return &pb.CloseSessionReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
||||||
|
s.invokeRequest = req
|
||||||
|
if s.invokeReply != nil {
|
||||||
|
return s.invokeReply, nil
|
||||||
|
}
|
||||||
|
return &pb.MxCommandReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
Kind: req.GetCommand().GetKind(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grpc.ServerStreamingServer[pb.MxEvent]) error {
|
||||||
|
s.streamAuth = authorizationFromContext(stream.Context())
|
||||||
|
if s.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
|
||||||
|
}
|
||||||
|
<-stream.Context().Done()
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationFromContext(ctx context.Context) string {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values := md.Get(authorizationHeader)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValueConversionFixtures(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "values", "value-conversion-cases.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ExpectedKind string `json:"expectedKind"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
t.Fatalf("parse fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range fixture.Cases {
|
||||||
|
t.Run(tc.ID, func(t *testing.T) {
|
||||||
|
var value pb.MxValue
|
||||||
|
if err := protojson.Unmarshal(tc.Value, &value); err != nil {
|
||||||
|
t.Fatalf("parse value: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NativeValue(&value); err != nil {
|
||||||
|
t.Fatalf("NativeValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := value.ProtoReflect().WhichOneof(value.ProtoReflect().Descriptor().Oneofs().ByName("kind")).JSONName(); got != tc.ExpectedKind {
|
||||||
|
t.Fatalf("kind = %q, want %q", got, tc.ExpectedKind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusConversionFixtures(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "statuses", "status-conversion-cases.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status json.RawMessage `json:"status"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
t.Fatalf("parse fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range fixture.Cases {
|
||||||
|
t.Run(tc.ID, func(t *testing.T) {
|
||||||
|
var status pb.MxStatusProxy
|
||||||
|
if err := protojson.Unmarshal(tc.Status, &status); err != nil {
|
||||||
|
t.Fatalf("parse status: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := StatusSucceeded(&status), status.GetSuccess() != 0; got != want {
|
||||||
|
t.Fatalf("StatusSucceeded() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GatewayError wraps transport-level gRPC failures.
|
||||||
|
type GatewayError struct {
|
||||||
|
Op string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GatewayError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Op == "" {
|
||||||
|
return fmt.Sprintf("mxgateway: %v", e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GatewayError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||||
|
// command reply when one exists.
|
||||||
|
type CommandError struct {
|
||||||
|
Op string
|
||||||
|
Status *ProtocolStatus
|
||||||
|
Reply *MxCommandReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CommandError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
status := e.Status
|
||||||
|
if status == nil {
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
|
||||||
|
}
|
||||||
|
if status.GetMessage() == "" {
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
|
||||||
|
type MxAccessError struct {
|
||||||
|
Command *CommandError
|
||||||
|
Reply *MxCommandReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MxAccessError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
|
||||||
|
return e.Command.Error()
|
||||||
|
}
|
||||||
|
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
|
||||||
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
|
||||||
|
}
|
||||||
|
if e.Reply != nil && e.Reply.Hresult != nil {
|
||||||
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
|
||||||
|
}
|
||||||
|
return "mxgateway: MXAccess command failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MxAccessError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
|
||||||
|
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
|
||||||
|
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commandError := &CommandError{
|
||||||
|
Op: op,
|
||||||
|
Status: status,
|
||||||
|
Reply: reply,
|
||||||
|
}
|
||||||
|
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
|
||||||
|
return &MxAccessError{
|
||||||
|
Command: commandError,
|
||||||
|
Reply: reply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandError
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
|
||||||
|
// MXSTATUS_PROXY entries.
|
||||||
|
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
|
||||||
|
if reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if reply.Hresult != nil && reply.GetHresult() != 0 {
|
||||||
|
return &MxAccessError{Reply: reply}
|
||||||
|
}
|
||||||
|
for _, status := range reply.GetStatuses() {
|
||||||
|
if !StatusSucceeded(status) {
|
||||||
|
return &MxAccessError{Reply: reply}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures gateway connections.
|
||||||
|
type Options struct {
|
||||||
|
Endpoint string
|
||||||
|
APIKey string
|
||||||
|
Plaintext bool
|
||||||
|
CACertFile string
|
||||||
|
ServerNameOverride string
|
||||||
|
DialTimeout time.Duration
|
||||||
|
CallTimeout time.Duration
|
||||||
|
TLSConfig *tls.Config
|
||||||
|
TransportCredentials credentials.TransportCredentials
|
||||||
|
DialOptions []grpc.DialOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactedAPIKey returns a display-safe representation of the configured API
|
||||||
|
// key for diagnostics and CLI output.
|
||||||
|
func (o Options) RedactedAPIKey() string {
|
||||||
|
return RedactAPIKey(o.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactAPIKey hides credential material while keeping enough shape for
|
||||||
|
// troubleshooting whether a key was supplied.
|
||||||
|
func RedactAPIKey(apiKey string) string {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiKey) <= 8 {
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, suffix := apiKey[:4], apiKey[len(apiKey)-4:]
|
||||||
|
return prefix + strings.Repeat("*", len(apiKey)-8) + suffix
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRedactAPIKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
apiKey string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", apiKey: "", want: ""},
|
||||||
|
{name: "short", apiKey: "mxgw_1", want: "<redacted>"},
|
||||||
|
{name: "long", apiKey: "mxgw_key_secret", want: "mxgw*******cret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := RedactAPIKey(tt.apiKey); got != tt.want {
|
||||||
|
t.Fatalf("RedactAPIKey() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedGoldenFixturesParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
msg proto.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "open session reply",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"),
|
||||||
|
msg: &pb.OpenSessionReply{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "register command request",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "register-command-request.json"),
|
||||||
|
msg: &pb.MxCommandRequest{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "on data change event",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "on-data-change-event.json"),
|
||||||
|
msg: &pb.MxEvent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
unmarshal := protojson.UnmarshalOptions{DiscardUnknown: false}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unmarshal.Unmarshal(data, tt.msg); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSessionFixtureProtocolVersions(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply pb.OpenSessionReply
|
||||||
|
if err := protojson.Unmarshal(data, &reply); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.GetGatewayProtocolVersion() != GatewayProtocolVersion {
|
||||||
|
t.Fatalf("gateway protocol = %d, want %d", reply.GetGatewayProtocolVersion(), GatewayProtocolVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.GetWorkerProtocolVersion() != WorkerProtocolVersion {
|
||||||
|
t.Fatalf("worker protocol = %d, want %d", reply.GetWorkerProtocolVersion(), WorkerProtocolVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventResult carries either the next ordered event or a terminal stream error.
|
||||||
|
type EventResult struct {
|
||||||
|
Event *MxEvent
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents one gateway-backed MXAccess session.
|
||||||
|
type Session struct {
|
||||||
|
client *Client
|
||||||
|
openReply *OpenSessionReply
|
||||||
|
closeMu sync.Mutex
|
||||||
|
closeReply *CloseSessionReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(client *Client, openReply *OpenSessionReply) *Session {
|
||||||
|
return &Session{
|
||||||
|
client: client,
|
||||||
|
openReply: openReply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionForID creates a session wrapper for commands against an existing
|
||||||
|
// gateway session id.
|
||||||
|
func NewSessionForID(client *Client, sessionID string) *Session {
|
||||||
|
return newSession(client, &pb.OpenSessionReply{SessionId: sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the gateway session identifier.
|
||||||
|
func (s *Session) ID() string {
|
||||||
|
return s.openReply.GetSessionId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReply returns the raw OpenSession reply.
|
||||||
|
func (s *Session) OpenReply() *OpenSessionReply {
|
||||||
|
return s.openReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the gateway session once and returns the raw close reply.
|
||||||
|
func (s *Session) Close(ctx context.Context) (*CloseSessionReply, error) {
|
||||||
|
s.closeMu.Lock()
|
||||||
|
defer s.closeMu.Unlock()
|
||||||
|
|
||||||
|
if s.closeReply != nil {
|
||||||
|
return s.closeReply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := s.client.CloseSessionRaw(ctx, &pb.CloseSessionRequest{SessionId: s.ID()})
|
||||||
|
if err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
s.closeReply = reply
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register invokes MXAccess Register and returns the server handle.
|
||||||
|
func (s *Session) Register(ctx context.Context, clientName string) (int32, error) {
|
||||||
|
reply, err := s.RegisterRaw(ctx, clientName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetRegister() != nil {
|
||||||
|
return reply.GetRegister().GetServerHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRaw invokes MXAccess Register and returns the raw reply.
|
||||||
|
func (s *Session) RegisterRaw(ctx context.Context, clientName string) (*MxCommandReply, error) {
|
||||||
|
if clientName == "" {
|
||||||
|
return nil, errors.New("mxgateway: client name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REGISTER,
|
||||||
|
Payload: &pb.MxCommand_Register{
|
||||||
|
Register: &pb.RegisterCommand{ClientName: clientName},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister invokes MXAccess Unregister.
|
||||||
|
func (s *Session) Unregister(ctx context.Context, serverHandle int32) error {
|
||||||
|
_, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER,
|
||||||
|
Payload: &pb.MxCommand_Unregister{
|
||||||
|
Unregister: &pb.UnregisterCommand{ServerHandle: serverHandle},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem invokes MXAccess AddItem and returns the item handle.
|
||||||
|
func (s *Session) AddItem(ctx context.Context, serverHandle int32, itemDefinition string) (int32, error) {
|
||||||
|
reply, err := s.AddItemRaw(ctx, serverHandle, itemDefinition)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetAddItem() != nil {
|
||||||
|
return reply.GetAddItem().GetItemHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItemRaw invokes MXAccess AddItem and returns the raw reply.
|
||||||
|
func (s *Session) AddItemRaw(ctx context.Context, serverHandle int32, itemDefinition string) (*MxCommandReply, error) {
|
||||||
|
if itemDefinition == "" {
|
||||||
|
return nil, errors.New("mxgateway: item definition is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM,
|
||||||
|
Payload: &pb.MxCommand_AddItem{
|
||||||
|
AddItem: &pb.AddItemCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemDefinition: itemDefinition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem2 invokes MXAccess AddItem2 and returns the item handle.
|
||||||
|
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
|
||||||
|
reply, err := s.AddItem2Raw(ctx, serverHandle, itemDefinition, itemContext)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetAddItem2() != nil {
|
||||||
|
return reply.GetAddItem2().GetItemHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem2Raw invokes MXAccess AddItem2 and returns the raw reply.
|
||||||
|
func (s *Session) AddItem2Raw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
|
||||||
|
if itemDefinition == "" {
|
||||||
|
return nil, errors.New("mxgateway: item definition is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
|
||||||
|
Payload: &pb.MxCommand_AddItem2{
|
||||||
|
AddItem2: &pb.AddItem2Command{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemDefinition: itemDefinition,
|
||||||
|
ItemContext: itemContext,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advise invokes MXAccess Advise.
|
||||||
|
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||||
|
_, err := s.AdviseRaw(ctx, serverHandle, itemHandle)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdviseRaw invokes MXAccess Advise and returns the raw reply.
|
||||||
|
func (s *Session) AdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
|
||||||
|
Payload: &pb.MxCommand_Advise{
|
||||||
|
Advise: &pb.AdviseCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write invokes MXAccess Write.
|
||||||
|
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||||
|
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRaw invokes MXAccess Write and returns the raw reply.
|
||||||
|
func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) (*MxCommandReply, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, errors.New("mxgateway: write value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE,
|
||||||
|
Payload: &pb.MxCommand_Write{
|
||||||
|
Write: &pb.WriteCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
Value: value,
|
||||||
|
UserId: userID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events streams ordered session events until the server ends the stream,
|
||||||
|
// context cancellation stops Recv, or a terminal error is sent.
|
||||||
|
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
|
||||||
|
return s.EventsAfter(ctx, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsAfter streams ordered session events after the given worker sequence.
|
||||||
|
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
|
||||||
|
stream, err := s.client.StreamEventsRaw(ctx, &pb.StreamEventsRequest{
|
||||||
|
SessionId: s.ID(),
|
||||||
|
AfterWorkerSequence: afterWorkerSequence,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan EventResult, 16)
|
||||||
|
go func() {
|
||||||
|
defer close(results)
|
||||||
|
for {
|
||||||
|
event, err := stream.Recv()
|
||||||
|
if err == nil {
|
||||||
|
results <- EventResult{Event: event}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == io.EOF || status.Code(err) == codes.Canceled || ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- EventResult{Err: &GatewayError{Op: "stream events", Err: err}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
||||||
|
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
||||||
|
SessionId: s.ID(),
|
||||||
|
ClientCorrelationId: newCorrelationID(),
|
||||||
|
Command: command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCorrelationID() string {
|
||||||
|
var buffer [16]byte
|
||||||
|
if _, err := rand.Read(buffer[:]); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buffer[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
// StatusSucceeded reports whether an MXSTATUS_PROXY entry represents success.
|
||||||
|
func StatusSucceeded(status *MxStatusProxy) bool {
|
||||||
|
return status == nil || status.GetSuccess() != 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
|
||||||
|
// RawGatewayClient is the generated gRPC client interface exposed for callers
|
||||||
|
// that need direct contract access.
|
||||||
|
type RawGatewayClient = pb.MxAccessGatewayClient
|
||||||
|
|
||||||
|
// RawEventStream is the generated StreamEvents client stream.
|
||||||
|
type RawEventStream = pb.MxAccessGateway_StreamEventsClient
|
||||||
|
|
||||||
|
// Generated protobuf aliases keep raw contract access available from the public
|
||||||
|
// mxgateway package while generated code remains under internal/generated.
|
||||||
|
type (
|
||||||
|
OpenSessionRequest = pb.OpenSessionRequest
|
||||||
|
OpenSessionReply = pb.OpenSessionReply
|
||||||
|
CloseSessionRequest = pb.CloseSessionRequest
|
||||||
|
CloseSessionReply = pb.CloseSessionReply
|
||||||
|
StreamEventsRequest = pb.StreamEventsRequest
|
||||||
|
MxCommandRequest = pb.MxCommandRequest
|
||||||
|
MxCommandReply = pb.MxCommandReply
|
||||||
|
MxCommand = pb.MxCommand
|
||||||
|
MxEvent = pb.MxEvent
|
||||||
|
MxValue = pb.MxValue
|
||||||
|
Value = pb.MxValue
|
||||||
|
MxArray = pb.MxArray
|
||||||
|
MxStatusProxy = pb.MxStatusProxy
|
||||||
|
ProtocolStatus = pb.ProtocolStatus
|
||||||
|
RegisterCommand = pb.RegisterCommand
|
||||||
|
UnregisterCommand = pb.UnregisterCommand
|
||||||
|
AddItemCommand = pb.AddItemCommand
|
||||||
|
AddItem2Command = pb.AddItem2Command
|
||||||
|
AdviseCommand = pb.AdviseCommand
|
||||||
|
WriteCommand = pb.WriteCommand
|
||||||
|
Write2Command = pb.Write2Command
|
||||||
|
RegisterReply = pb.RegisterReply
|
||||||
|
AddItemReply = pb.AddItemReply
|
||||||
|
AddItem2Reply = pb.AddItem2Reply
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
MxCommandKind = pb.MxCommandKind
|
||||||
|
MxDataType = pb.MxDataType
|
||||||
|
MxEventFamily = pb.MxEventFamily
|
||||||
|
MxStatusCategory = pb.MxStatusCategory
|
||||||
|
MxStatusSource = pb.MxStatusSource
|
||||||
|
ProtocolStatusCode = pb.ProtocolStatusCode
|
||||||
|
SessionState = pb.SessionState
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommandKindRegister = pb.MxCommandKind_MX_COMMAND_KIND_REGISTER
|
||||||
|
CommandKindUnregister = pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER
|
||||||
|
CommandKindAddItem = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM
|
||||||
|
CommandKindAddItem2 = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2
|
||||||
|
CommandKindAdvise = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE
|
||||||
|
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||||
|
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
||||||
|
|
||||||
|
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
||||||
|
DataTypeBoolean = pb.MxDataType_MX_DATA_TYPE_BOOLEAN
|
||||||
|
DataTypeInteger = pb.MxDataType_MX_DATA_TYPE_INTEGER
|
||||||
|
DataTypeFloat = pb.MxDataType_MX_DATA_TYPE_FLOAT
|
||||||
|
DataTypeDouble = pb.MxDataType_MX_DATA_TYPE_DOUBLE
|
||||||
|
DataTypeString = pb.MxDataType_MX_DATA_TYPE_STRING
|
||||||
|
DataTypeTime = pb.MxDataType_MX_DATA_TYPE_TIME
|
||||||
|
|
||||||
|
ProtocolStatusOK = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK
|
||||||
|
ProtocolStatusMxAccessFailure = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BoolValue builds an MXAccess Boolean value.
|
||||||
|
func BoolValue(value bool) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_BOOLEAN,
|
||||||
|
VariantType: "VT_BOOL",
|
||||||
|
Kind: &pb.MxValue_BoolValue{BoolValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int32Value builds an MXAccess Int32 value.
|
||||||
|
func Int32Value(value int32) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
|
||||||
|
VariantType: "VT_I4",
|
||||||
|
Kind: &pb.MxValue_Int32Value{Int32Value: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int64Value builds an MXAccess Int64 value.
|
||||||
|
func Int64Value(value int64) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
|
||||||
|
VariantType: "VT_I8",
|
||||||
|
Kind: &pb.MxValue_Int64Value{Int64Value: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatValue builds an MXAccess Float value.
|
||||||
|
func FloatValue(value float32) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_FLOAT,
|
||||||
|
VariantType: "VT_R4",
|
||||||
|
Kind: &pb.MxValue_FloatValue{FloatValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleValue builds an MXAccess Double value.
|
||||||
|
func DoubleValue(value float64) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_DOUBLE,
|
||||||
|
VariantType: "VT_R8",
|
||||||
|
Kind: &pb.MxValue_DoubleValue{DoubleValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringValue builds an MXAccess String value.
|
||||||
|
func StringValue(value string) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_STRING,
|
||||||
|
VariantType: "VT_BSTR",
|
||||||
|
Kind: &pb.MxValue_StringValue{StringValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampValue builds an MXAccess timestamp value from a Go time.
|
||||||
|
func TimestampValue(value time.Time) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_TIME,
|
||||||
|
VariantType: "VT_DATE",
|
||||||
|
Kind: &pb.MxValue_TimestampValue{TimestampValue: timestamppb.New(value)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeValue converts a protobuf MxValue to the closest Go representation
|
||||||
|
// without discarding raw fallback data.
|
||||||
|
func NativeValue(value *MxValue) (any, error) {
|
||||||
|
if value == nil || value.GetIsNull() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind := value.GetKind().(type) {
|
||||||
|
case *pb.MxValue_BoolValue:
|
||||||
|
return kind.BoolValue, nil
|
||||||
|
case *pb.MxValue_Int32Value:
|
||||||
|
return kind.Int32Value, nil
|
||||||
|
case *pb.MxValue_Int64Value:
|
||||||
|
return kind.Int64Value, nil
|
||||||
|
case *pb.MxValue_FloatValue:
|
||||||
|
return kind.FloatValue, nil
|
||||||
|
case *pb.MxValue_DoubleValue:
|
||||||
|
return kind.DoubleValue, nil
|
||||||
|
case *pb.MxValue_StringValue:
|
||||||
|
return kind.StringValue, nil
|
||||||
|
case *pb.MxValue_TimestampValue:
|
||||||
|
if kind.TimestampValue == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return kind.TimestampValue.AsTime(), nil
|
||||||
|
case *pb.MxValue_ArrayValue:
|
||||||
|
return NativeArray(kind.ArrayValue)
|
||||||
|
case *pb.MxValue_RawValue:
|
||||||
|
return append([]byte(nil), kind.RawValue...), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mxgateway: unsupported value kind %T", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeArray converts a protobuf MxArray to the closest Go slice
|
||||||
|
// representation.
|
||||||
|
func NativeArray(array *MxArray) (any, error) {
|
||||||
|
if array == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch values := array.GetValues().(type) {
|
||||||
|
case *pb.MxArray_BoolValues:
|
||||||
|
return append([]bool(nil), values.BoolValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_Int32Values:
|
||||||
|
return append([]int32(nil), values.Int32Values.GetValues()...), nil
|
||||||
|
case *pb.MxArray_Int64Values:
|
||||||
|
return append([]int64(nil), values.Int64Values.GetValues()...), nil
|
||||||
|
case *pb.MxArray_FloatValues:
|
||||||
|
return append([]float32(nil), values.FloatValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_DoubleValues:
|
||||||
|
return append([]float64(nil), values.DoubleValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_StringValues:
|
||||||
|
return append([]string(nil), values.StringValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_TimestampValues:
|
||||||
|
result := make([]time.Time, 0, len(values.TimestampValues.GetValues()))
|
||||||
|
for _, value := range values.TimestampValues.GetValues() {
|
||||||
|
if value == nil {
|
||||||
|
result = append(result, time.Time{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, value.AsTime())
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case *pb.MxArray_RawValues:
|
||||||
|
rawValues := values.RawValues.GetValues()
|
||||||
|
result := make([][]byte, 0, len(rawValues))
|
||||||
|
for _, value := range rawValues {
|
||||||
|
result = append(result, append([]byte(nil), value...))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mxgateway: unsupported array value kind %T", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ClientVersion identifies this Go client scaffold before package releases
|
||||||
|
// assign semantic versions.
|
||||||
|
ClientVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||||
|
// in the shared .NET contracts.
|
||||||
|
GatewayProtocolVersion uint32 = 1
|
||||||
|
|
||||||
|
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
|
||||||
|
// and is exposed for fake-worker and parity tests.
|
||||||
|
WorkerProtocolVersion uint32 = 1
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Java Client
|
||||||
|
|
||||||
|
The Java client workspace contains the MXAccess Gateway client library,
|
||||||
|
generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/java/
|
||||||
|
settings.gradle
|
||||||
|
build.gradle
|
||||||
|
src/main/generated/
|
||||||
|
mxgateway-client/
|
||||||
|
mxgateway-cli/
|
||||||
|
```
|
||||||
|
|
||||||
|
`mxgateway-client` generates Java protobuf and gRPC sources from
|
||||||
|
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
|
||||||
|
generated sources under `src/main/generated`, which matches the client proto
|
||||||
|
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
|
||||||
|
|
||||||
|
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
|
||||||
|
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
|
||||||
|
generated stubs, and generated protobuf messages for parity tests.
|
||||||
|
|
||||||
|
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
|
||||||
|
application entry point. The CLI supports version, session, command, event
|
||||||
|
streaming, write, and smoke-test commands with deterministic JSON output.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Java checks from `clients/java`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gradle test
|
||||||
|
```
|
||||||
|
|
||||||
|
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
|
||||||
|
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
|
||||||
|
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [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)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
plugins {
|
||||||
|
id 'base'
|
||||||
|
}
|
||||||
|
|
||||||
|
ext {
|
||||||
|
guavaVersion = '33.5.0-jre'
|
||||||
|
gsonVersion = '2.13.2'
|
||||||
|
grpcVersion = '1.76.0'
|
||||||
|
junitVersion = '5.14.1'
|
||||||
|
picocliVersion = '4.7.7'
|
||||||
|
protobufVersion = '4.33.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
group = 'com.dohertylan.mxgateway'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
pluginManager.withPlugin('java') {
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
options.release = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(Test).configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation platform("org.junit:junit-bom:${junitVersion}")
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
plugins {
|
||||||
|
id 'application'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':mxgateway-client')
|
||||||
|
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||||
|
implementation "info.picocli:picocli:${picocliVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli'
|
||||||
|
}
|
||||||
+644
@@ -0,0 +1,644 @@
|
|||||||
|
package com.dohertylan.mxgateway.cli;
|
||||||
|
|
||||||
|
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 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.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
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 picocli.CommandLine;
|
||||||
|
import picocli.CommandLine.Command;
|
||||||
|
import picocli.CommandLine.Mixin;
|
||||||
|
import picocli.CommandLine.Model.CommandSpec;
|
||||||
|
import picocli.CommandLine.Option;
|
||||||
|
import picocli.CommandLine.Spec;
|
||||||
|
|
||||||
|
@Command(
|
||||||
|
name = "mxgw-java",
|
||||||
|
mixinStandardHelpOptions = true,
|
||||||
|
description = "MXAccess Gateway Java test CLI.")
|
||||||
|
public final class MxGatewayCli implements Callable<Integer> {
|
||||||
|
private final MxGatewayCliClientFactory clientFactory;
|
||||||
|
|
||||||
|
@Spec
|
||||||
|
private CommandSpec spec;
|
||||||
|
|
||||||
|
public MxGatewayCli() {
|
||||||
|
this(new GrpcMxGatewayCliClientFactory());
|
||||||
|
}
|
||||||
|
|
||||||
|
MxGatewayCli(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
this.clientFactory = clientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
|
||||||
|
System.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int execute(PrintWriter out, PrintWriter err, String... args) {
|
||||||
|
return execute(new GrpcMxGatewayCliClientFactory(), out, err, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int execute(MxGatewayCliClientFactory clientFactory, PrintWriter out, PrintWriter err, String... args) {
|
||||||
|
CommandLine commandLine = commandLine(clientFactory);
|
||||||
|
commandLine.setOut(out);
|
||||||
|
commandLine.setErr(err);
|
||||||
|
return commandLine.execute(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
spec.commandLine().usage(spec.commandLine().getOut());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
||||||
|
commandLine.addSubcommand("version", new VersionCommand());
|
||||||
|
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
|
||||||
|
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
|
||||||
|
return commandLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "version", description = "Prints the Java client version.")
|
||||||
|
public static final class VersionCommand implements Callable<Integer> {
|
||||||
|
@Spec
|
||||||
|
private CommandSpec spec;
|
||||||
|
|
||||||
|
@Option(names = "--json", description = "Write JSON output.")
|
||||||
|
private boolean json;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
Map<String, Object> values = new LinkedHashMap<>();
|
||||||
|
values.put("clientVersion", MxGatewayClientVersion.clientVersion());
|
||||||
|
values.put("gatewayProtocolVersion", MxGatewayClientVersion.gatewayProtocolVersion());
|
||||||
|
values.put("workerProtocolVersion", MxGatewayClientVersion.workerProtocolVersion());
|
||||||
|
if (json) {
|
||||||
|
spec.commandLine().getOut().println(jsonObject(values));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
spec.commandLine()
|
||||||
|
.getOut()
|
||||||
|
.printf(
|
||||||
|
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
|
||||||
|
MxGatewayClientVersion.clientVersion(),
|
||||||
|
MxGatewayClientVersion.gatewayProtocolVersion(),
|
||||||
|
MxGatewayClientVersion.workerProtocolVersion());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract static class GatewayCommand implements Callable<Integer> {
|
||||||
|
final MxGatewayCliClientFactory clientFactory;
|
||||||
|
|
||||||
|
@Mixin
|
||||||
|
CommonOptions common = new CommonOptions();
|
||||||
|
|
||||||
|
@Option(names = "--json", description = "Write JSON output.")
|
||||||
|
boolean json;
|
||||||
|
|
||||||
|
GatewayCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
this.clientFactory = clientFactory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "open-session", description = "Opens a gateway session.")
|
||||||
|
static final class OpenSessionCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--client-session-name", description = "Client session name.")
|
||||||
|
String clientSessionName = "";
|
||||||
|
|
||||||
|
@Option(names = "--backend", description = "Requested gateway backend.")
|
||||||
|
String backend = "";
|
||||||
|
|
||||||
|
OpenSessionCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
var reply = client.openSession(OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName(clientSessionName)
|
||||||
|
.setRequestedBackend(backend)
|
||||||
|
.build());
|
||||||
|
writeOutput("open-session", common, json, reply, () -> reply.getSessionId());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "close-session", description = "Closes a gateway session.")
|
||||||
|
static final class CloseSessionCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||||
|
String sessionId;
|
||||||
|
|
||||||
|
CloseSessionCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
var reply = client.closeSession(CloseSessionRequest.newBuilder()
|
||||||
|
.setSessionId(sessionId)
|
||||||
|
.build());
|
||||||
|
writeOutput("close-session", common, json, reply, () -> reply.getFinalState().name());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "register", description = "Invokes MXAccess Register.")
|
||||||
|
static final class RegisterCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||||
|
String sessionId;
|
||||||
|
|
||||||
|
@Option(names = "--client-name", required = true, description = "MXAccess client name.")
|
||||||
|
String clientName;
|
||||||
|
|
||||||
|
RegisterCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
MxCommandReply reply = client.session(sessionId).registerRaw(clientName);
|
||||||
|
writeOutput("register", common, json, reply, () -> reply.getKind().name());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "add-item", description = "Invokes MXAccess AddItem.")
|
||||||
|
static final class AddItemCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||||
|
String sessionId;
|
||||||
|
|
||||||
|
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
||||||
|
int serverHandle;
|
||||||
|
|
||||||
|
@Option(names = "--item", required = true, description = "Item definition.")
|
||||||
|
String item;
|
||||||
|
|
||||||
|
AddItemCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
MxCommandReply reply = client.session(sessionId).addItemRaw(serverHandle, item);
|
||||||
|
writeOutput("add-item", common, json, reply, () -> reply.getKind().name());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "advise", description = "Invokes MXAccess Advise.")
|
||||||
|
static final class AdviseCommand extends GatewayCommand {
|
||||||
|
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||||
|
String sessionId;
|
||||||
|
|
||||||
|
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
||||||
|
int serverHandle;
|
||||||
|
|
||||||
|
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
|
||||||
|
int itemHandle;
|
||||||
|
|
||||||
|
AdviseCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
|
super(clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer call() {
|
||||||
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
MxCommandReply reply = client.session(sessionId).adviseRaw(serverHandle, itemHandle);
|
||||||
|
writeOutput("advise", common, json, reply, () -> reply.getKind().name());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command(name = "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());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 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 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 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);
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
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 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 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=1"));
|
||||||
|
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\":1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
|
||||||
|
StringWriter output = new StringWriter();
|
||||||
|
StringWriter errors = new StringWriter();
|
||||||
|
int exitCode = MxGatewayCli.execute(
|
||||||
|
factory,
|
||||||
|
new PrintWriter(output, true),
|
||||||
|
new PrintWriter(errors, true),
|
||||||
|
args);
|
||||||
|
return new CliRun(exitCode, output.toString(), errors.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CliRun(int exitCode, String output, String errors) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
||||||
|
private FakeClient client;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
||||||
|
client = new FakeClient(options.spec.commandLine().getOut());
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
|
private final PrintWriter out;
|
||||||
|
private final FakeSession session = new FakeSession();
|
||||||
|
private boolean closeCalled;
|
||||||
|
|
||||||
|
private FakeClient(PrintWriter out) {
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrintWriter out() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpenSessionReply openSession(OpenSessionRequest request) {
|
||||||
|
return OpenSessionReply.newBuilder()
|
||||||
|
.setSessionId("session-cli")
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
||||||
|
closeCalled = true;
|
||||||
|
return CloseSessionReply.newBuilder()
|
||||||
|
.setSessionId(request.getSessionId())
|
||||||
|
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
|
||||||
|
private boolean registerCalled;
|
||||||
|
private boolean addItemCalled;
|
||||||
|
private boolean adviseCalled;
|
||||||
|
private MxValue lastWriteValue;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int register(String clientName) {
|
||||||
|
registerCalled = true;
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply registerRaw(String clientName) {
|
||||||
|
registerCalled = true;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int addItem(int serverHandle, String itemDefinition) {
|
||||||
|
addItemCalled = true;
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
|
||||||
|
addItemCalled = true;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setAddItem(AddItemReply.newBuilder().setItemHandle(7))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void advise(int serverHandle, int itemHandle) {
|
||||||
|
adviseCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
|
||||||
|
adviseCalled = true;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
|
||||||
|
lastWriteValue = value;
|
||||||
|
return MxCommandReply.newBuilder()
|
||||||
|
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||||
|
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProtocolStatus ok() {
|
||||||
|
return ProtocolStatus.newBuilder()
|
||||||
|
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'com.google.protobuf'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||||
|
api "com.google.protobuf:protobuf-java:${protobufVersion}"
|
||||||
|
api "io.grpc:grpc-protobuf:${grpcVersion}"
|
||||||
|
api "io.grpc:grpc-stub:${grpcVersion}"
|
||||||
|
|
||||||
|
implementation "com.google.guava:guava:${guavaVersion}"
|
||||||
|
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
|
||||||
|
|
||||||
|
compileOnly 'javax.annotation:javax.annotation-api:1.3.2'
|
||||||
|
|
||||||
|
testImplementation "com.google.code.gson:gson:${gsonVersion}"
|
||||||
|
testImplementation "io.grpc:grpc-inprocess:${grpcVersion}"
|
||||||
|
testImplementation "io.grpc:grpc-testing:${grpcVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
proto {
|
||||||
|
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
|
||||||
|
include 'mxaccess_gateway.proto'
|
||||||
|
include 'mxaccess_worker.proto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc {
|
||||||
|
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
grpc {
|
||||||
|
artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedFilesBaseDir = rootProject.file('src/main/generated').absolutePath
|
||||||
|
|
||||||
|
generateProtoTasks {
|
||||||
|
all().configureEach {
|
||||||
|
plugins {
|
||||||
|
grpc {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
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) {
|
||||||
|
queue.offer(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
queue.put(value);
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
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 withDeadline(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);
|
||||||
|
rawAsyncStub().streamEvents(request, stream.observer());
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxGatewayEventSubscription streamEventsAsync(
|
||||||
|
StreamEventsRequest request, StreamObserver<MxEvent> observer) {
|
||||||
|
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
|
||||||
|
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();
|
||||||
|
ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
|
||||||
|
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||||
|
.maxInboundMessageSize(16 * 1024 * 1024);
|
||||||
|
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 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());
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ProtocolStatusCode okStatusCode() {
|
||||||
|
return ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
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 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "MxGatewayClientOptions{"
|
||||||
|
+ "endpoint='"
|
||||||
|
+ endpoint
|
||||||
|
+ '\''
|
||||||
|
+ ", apiKey='"
|
||||||
|
+ redactedApiKey()
|
||||||
|
+ '\''
|
||||||
|
+ ", plaintext="
|
||||||
|
+ plaintext
|
||||||
|
+ ", caCertificatePath="
|
||||||
|
+ caCertificatePath
|
||||||
|
+ ", serverNameOverride='"
|
||||||
|
+ serverNameOverride
|
||||||
|
+ '\''
|
||||||
|
+ ", connectTimeout="
|
||||||
|
+ connectTimeout
|
||||||
|
+ ", callTimeout="
|
||||||
|
+ callTimeout
|
||||||
|
+ '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 MxGatewayClientOptions build() {
|
||||||
|
return new MxGatewayClientOptions(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
public final class MxGatewayClientVersion {
|
||||||
|
private static final int GATEWAY_PROTOCOL_VERSION = 1;
|
||||||
|
private static final int WORKER_PROTOCOL_VERSION = 1;
|
||||||
|
private static final String CLIENT_VERSION = "0.1.0";
|
||||||
|
|
||||||
|
private MxGatewayClientVersion() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String clientVersion() {
|
||||||
|
return CLIENT_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int gatewayProtocolVersion() {
|
||||||
|
return GATEWAY_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int workerProtocolVersion() {
|
||||||
|
return WORKER_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
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 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<>();
|
||||||
|
|
||||||
|
ClientResponseObserver<StreamEventsRequest, MxEvent> wrap(StreamObserver<MxEvent> observer) {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> stream) {
|
||||||
|
requestStream.set(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Objects;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
|
||||||
|
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.StreamEventsRequest;
|
||||||
|
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 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 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
|
import mxaccess_worker.v1.MxaccessWorker.WorkerEnvelope;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class GeneratedContractSmokeTests {
|
||||||
|
@Test
|
||||||
|
void generatedGatewayAndWorkerContractsCompile() {
|
||||||
|
OpenSessionRequest request = OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName("junit")
|
||||||
|
.build();
|
||||||
|
WorkerEnvelope envelope = WorkerEnvelope.newBuilder()
|
||||||
|
.setProtocolVersion(MxGatewayClientVersion.workerProtocolVersion())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertEquals("junit", request.getClientSessionName());
|
||||||
|
assertEquals("mxaccess_gateway.v1.MxAccessGateway", MxAccessGatewayGrpc.SERVICE_NAME);
|
||||||
|
assertEquals(1, envelope.getProtocolVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void javaTwentyOneToolchainRunsTests() {
|
||||||
|
assertEquals(21, Runtime.version().feature());
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
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.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.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 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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'com.google.protobuf' version '0.9.5'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'mxaccessgw-java'
|
||||||
|
|
||||||
|
include 'mxgateway-client'
|
||||||
|
include 'mxgateway-cli'
|
||||||
+588
@@ -0,0 +1,588 @@
|
|||||||
|
package mxaccess_gateway.v1;
|
||||||
|
|
||||||
|
import static io.grpc.MethodDescriptor.generateFullMethodName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@io.grpc.stub.annotations.GrpcGenerated
|
||||||
|
public final class MxAccessGatewayGrpc {
|
||||||
|
|
||||||
|
private MxAccessGatewayGrpc() {}
|
||||||
|
|
||||||
|
public static final java.lang.String SERVICE_NAME = "mxaccess_gateway.v1.MxAccessGateway";
|
||||||
|
|
||||||
|
// Static method descriptors that strictly reflect the proto.
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> getOpenSessionMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "OpenSession",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> getOpenSessionMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest, mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> getOpenSessionMethod;
|
||||||
|
if ((getOpenSessionMethod = MxAccessGatewayGrpc.getOpenSessionMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getOpenSessionMethod = MxAccessGatewayGrpc.getOpenSessionMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getOpenSessionMethod = getOpenSessionMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest, mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "OpenSession"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("OpenSession"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getOpenSessionMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> getCloseSessionMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "CloseSession",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> getCloseSessionMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest, mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> getCloseSessionMethod;
|
||||||
|
if ((getCloseSessionMethod = MxAccessGatewayGrpc.getCloseSessionMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getCloseSessionMethod = MxAccessGatewayGrpc.getCloseSessionMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getCloseSessionMethod = getCloseSessionMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest, mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "CloseSession"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("CloseSession"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getCloseSessionMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> getInvokeMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "Invoke",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.MxCommandReply.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> getInvokeMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest, mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> getInvokeMethod;
|
||||||
|
if ((getInvokeMethod = MxAccessGatewayGrpc.getInvokeMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getInvokeMethod = MxAccessGatewayGrpc.getInvokeMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getInvokeMethod = getInvokeMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest, mxaccess_gateway.v1.MxaccessGateway.MxCommandReply>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "Invoke"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("Invoke"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getInvokeMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxEvent> getStreamEventsMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "StreamEvents",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.MxEvent.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxEvent> getStreamEventsMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest, mxaccess_gateway.v1.MxaccessGateway.MxEvent> getStreamEventsMethod;
|
||||||
|
if ((getStreamEventsMethod = MxAccessGatewayGrpc.getStreamEventsMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getStreamEventsMethod = MxAccessGatewayGrpc.getStreamEventsMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getStreamEventsMethod = getStreamEventsMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest, mxaccess_gateway.v1.MxaccessGateway.MxEvent>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamEvents"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxEvent.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("StreamEvents"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getStreamEventsMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new async stub that supports all call types for the service
|
||||||
|
*/
|
||||||
|
public static MxAccessGatewayStub newStub(io.grpc.Channel channel) {
|
||||||
|
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayStub> factory =
|
||||||
|
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayStub>() {
|
||||||
|
@java.lang.Override
|
||||||
|
public MxAccessGatewayStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayStub(channel, callOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return MxAccessGatewayStub.newStub(factory, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new blocking-style stub that supports all types of calls on the service
|
||||||
|
*/
|
||||||
|
public static MxAccessGatewayBlockingV2Stub newBlockingV2Stub(
|
||||||
|
io.grpc.Channel channel) {
|
||||||
|
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingV2Stub> factory =
|
||||||
|
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingV2Stub>() {
|
||||||
|
@java.lang.Override
|
||||||
|
public MxAccessGatewayBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayBlockingV2Stub(channel, callOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return MxAccessGatewayBlockingV2Stub.newStub(factory, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
|
||||||
|
*/
|
||||||
|
public static MxAccessGatewayBlockingStub newBlockingStub(
|
||||||
|
io.grpc.Channel channel) {
|
||||||
|
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingStub> factory =
|
||||||
|
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayBlockingStub>() {
|
||||||
|
@java.lang.Override
|
||||||
|
public MxAccessGatewayBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayBlockingStub(channel, callOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return MxAccessGatewayBlockingStub.newStub(factory, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new ListenableFuture-style stub that supports unary calls on the service
|
||||||
|
*/
|
||||||
|
public static MxAccessGatewayFutureStub newFutureStub(
|
||||||
|
io.grpc.Channel channel) {
|
||||||
|
io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayFutureStub> factory =
|
||||||
|
new io.grpc.stub.AbstractStub.StubFactory<MxAccessGatewayFutureStub>() {
|
||||||
|
@java.lang.Override
|
||||||
|
public MxAccessGatewayFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayFutureStub(channel, callOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return MxAccessGatewayFutureStub.newStub(factory, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public interface AsyncService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
default void openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getOpenSessionMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
default void closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getCloseSessionMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
default void invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getInvokeMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
default void streamEvents(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamEventsMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for the server implementation of the service MxAccessGateway.
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public static abstract class MxAccessGatewayImplBase
|
||||||
|
implements io.grpc.BindableService, AsyncService {
|
||||||
|
|
||||||
|
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
|
||||||
|
return MxAccessGatewayGrpc.bindService(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stub to allow clients to do asynchronous rpc calls to service MxAccessGateway.
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public static final class MxAccessGatewayStub
|
||||||
|
extends io.grpc.stub.AbstractAsyncStub<MxAccessGatewayStub> {
|
||||||
|
private MxAccessGatewayStub(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
super(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
protected MxAccessGatewayStub build(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayStub(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
|
getChannel().newCall(getOpenSessionMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
|
getChannel().newCall(getCloseSessionMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
|
getChannel().newCall(getInvokeMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void streamEvents(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||||
|
getChannel().newCall(getStreamEventsMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stub to allow clients to do synchronous rpc calls to service MxAccessGateway.
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public static final class MxAccessGatewayBlockingV2Stub
|
||||||
|
extends io.grpc.stub.AbstractBlockingStub<MxAccessGatewayBlockingV2Stub> {
|
||||||
|
private MxAccessGatewayBlockingV2Stub(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
super(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
protected MxAccessGatewayBlockingV2Stub build(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayBlockingV2Stub(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request) throws io.grpc.StatusException {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||||
|
getChannel(), getOpenSessionMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request) throws io.grpc.StatusException {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||||
|
getChannel(), getCloseSessionMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.MxCommandReply invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request) throws io.grpc.StatusException {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||||
|
getChannel(), getInvokeMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
||||||
|
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.MxEvent>
|
||||||
|
streamEvents(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||||
|
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stub to allow clients to do limited synchronous rpc calls to service MxAccessGateway.
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public static final class MxAccessGatewayBlockingStub
|
||||||
|
extends io.grpc.stub.AbstractBlockingStub<MxAccessGatewayBlockingStub> {
|
||||||
|
private MxAccessGatewayBlockingStub(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
super(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
protected MxAccessGatewayBlockingStub build(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayBlockingStub(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
|
getChannel(), getOpenSessionMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
|
getChannel(), getCloseSessionMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.MxCommandReply invoke(mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
|
getChannel(), getInvokeMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.MxEvent> streamEvents(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||||
|
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stub to allow clients to do ListenableFuture-style rpc calls to service MxAccessGateway.
|
||||||
|
* <pre>
|
||||||
|
* Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public static final class MxAccessGatewayFutureStub
|
||||||
|
extends io.grpc.stub.AbstractFutureStub<MxAccessGatewayFutureStub> {
|
||||||
|
private MxAccessGatewayFutureStub(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
super(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
protected MxAccessGatewayFutureStub build(
|
||||||
|
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
|
||||||
|
return new MxAccessGatewayFutureStub(channel, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply> openSession(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
|
getChannel().newCall(getOpenSessionMethod(), getCallOptions()), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply> closeSession(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
|
getChannel().newCall(getCloseSessionMethod(), getCallOptions()), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply> invoke(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
|
getChannel().newCall(getInvokeMethod(), getCallOptions()), request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int METHODID_OPEN_SESSION = 0;
|
||||||
|
private static final int METHODID_CLOSE_SESSION = 1;
|
||||||
|
private static final int METHODID_INVOKE = 2;
|
||||||
|
private static final int METHODID_STREAM_EVENTS = 3;
|
||||||
|
|
||||||
|
private static final class MethodHandlers<Req, Resp> implements
|
||||||
|
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||||
|
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
|
||||||
|
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
|
||||||
|
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
|
||||||
|
private final AsyncService serviceImpl;
|
||||||
|
private final int methodId;
|
||||||
|
|
||||||
|
MethodHandlers(AsyncService serviceImpl, int methodId) {
|
||||||
|
this.serviceImpl = serviceImpl;
|
||||||
|
this.methodId = methodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
@java.lang.SuppressWarnings("unchecked")
|
||||||
|
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
|
||||||
|
switch (methodId) {
|
||||||
|
case METHODID_OPEN_SESSION:
|
||||||
|
serviceImpl.openSession((mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply>) responseObserver);
|
||||||
|
break;
|
||||||
|
case METHODID_CLOSE_SESSION:
|
||||||
|
serviceImpl.closeSession((mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply>) responseObserver);
|
||||||
|
break;
|
||||||
|
case METHODID_INVOKE:
|
||||||
|
serviceImpl.invoke((mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxCommandReply>) responseObserver);
|
||||||
|
break;
|
||||||
|
case METHODID_STREAM_EVENTS:
|
||||||
|
serviceImpl.streamEvents((mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent>) responseObserver);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
@java.lang.SuppressWarnings("unchecked")
|
||||||
|
public io.grpc.stub.StreamObserver<Req> invoke(
|
||||||
|
io.grpc.stub.StreamObserver<Resp> responseObserver) {
|
||||||
|
switch (methodId) {
|
||||||
|
default:
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
|
||||||
|
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
|
||||||
|
.addMethod(
|
||||||
|
getOpenSessionMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply>(
|
||||||
|
service, METHODID_OPEN_SESSION)))
|
||||||
|
.addMethod(
|
||||||
|
getCloseSessionMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply>(
|
||||||
|
service, METHODID_CLOSE_SESSION)))
|
||||||
|
.addMethod(
|
||||||
|
getInvokeMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxCommandReply>(
|
||||||
|
service, METHODID_INVOKE)))
|
||||||
|
.addMethod(
|
||||||
|
getStreamEventsMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.MxEvent>(
|
||||||
|
service, METHODID_STREAM_EVENTS)))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static abstract class MxAccessGatewayBaseDescriptorSupplier
|
||||||
|
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
|
||||||
|
MxAccessGatewayBaseDescriptorSupplier() {}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
|
||||||
|
return mxaccess_gateway.v1.MxaccessGateway.getDescriptor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
|
||||||
|
return getFileDescriptor().findServiceByName("MxAccessGateway");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MxAccessGatewayFileDescriptorSupplier
|
||||||
|
extends MxAccessGatewayBaseDescriptorSupplier {
|
||||||
|
MxAccessGatewayFileDescriptorSupplier() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MxAccessGatewayMethodDescriptorSupplier
|
||||||
|
extends MxAccessGatewayBaseDescriptorSupplier
|
||||||
|
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
|
||||||
|
private final java.lang.String methodName;
|
||||||
|
|
||||||
|
MxAccessGatewayMethodDescriptorSupplier(java.lang.String methodName) {
|
||||||
|
this.methodName = methodName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@java.lang.Override
|
||||||
|
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
|
||||||
|
return getServiceDescriptor().findMethodByName(methodName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
|
||||||
|
|
||||||
|
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
|
||||||
|
io.grpc.ServiceDescriptor result = serviceDescriptor;
|
||||||
|
if (result == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
result = serviceDescriptor;
|
||||||
|
if (result == null) {
|
||||||
|
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayFileDescriptorSupplier())
|
||||||
|
.addMethod(getOpenSessionMethod())
|
||||||
|
.addMethod(getCloseSessionMethod())
|
||||||
|
.addMethod(getInvokeMethod())
|
||||||
|
.addMethod(getStreamEventsMethod())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"fixtureSet": "mxaccess-gateway-parity-fixture-matrix",
|
||||||
|
"contractName": "mxaccess-gateway",
|
||||||
|
"gatewayProtocolVersion": 1,
|
||||||
|
"workerProtocolVersion": 1,
|
||||||
|
"sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures",
|
||||||
|
"sourceDocs": [
|
||||||
|
"C:/Users/dohertj2/Desktop/mxaccess/docs/MXAccess-Public-API.md",
|
||||||
|
"C:/Users/dohertj2/Desktop/mxaccess/docs/Current-Sprint-State.md"
|
||||||
|
],
|
||||||
|
"comparisonFormat": {
|
||||||
|
"description": "Each parity run records the same command against direct MXAccess and the gateway-backed worker, then compares raw parity fields instead of client wrapper behavior.",
|
||||||
|
"directMxAccess": {
|
||||||
|
"requiredFields": [
|
||||||
|
"method",
|
||||||
|
"arguments",
|
||||||
|
"returnedValue",
|
||||||
|
"hresult",
|
||||||
|
"exceptionType",
|
||||||
|
"statuses",
|
||||||
|
"events"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gatewayResult": {
|
||||||
|
"requiredFields": [
|
||||||
|
"kind",
|
||||||
|
"protocolStatus",
|
||||||
|
"returnValue",
|
||||||
|
"hresult",
|
||||||
|
"statuses",
|
||||||
|
"diagnosticMessage",
|
||||||
|
"events"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"eventFields": [
|
||||||
|
"family",
|
||||||
|
"serverHandle",
|
||||||
|
"itemHandle",
|
||||||
|
"value",
|
||||||
|
"quality",
|
||||||
|
"sourceTimestamp",
|
||||||
|
"statuses",
|
||||||
|
"workerSequence",
|
||||||
|
"workerTimestamp",
|
||||||
|
"gatewayReceiveTimestamp",
|
||||||
|
"hresult",
|
||||||
|
"rawStatus"
|
||||||
|
],
|
||||||
|
"comparisonKeys": [
|
||||||
|
"hresult",
|
||||||
|
"exceptionType",
|
||||||
|
"returnedValue",
|
||||||
|
"statusArrayShape",
|
||||||
|
"statusRawFields",
|
||||||
|
"eventFamilyOrder",
|
||||||
|
"eventPayloadShape",
|
||||||
|
"valueProjection",
|
||||||
|
"rawFallbackMetadata"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"methodFixtures": [
|
||||||
|
{
|
||||||
|
"id": "method.register.basic",
|
||||||
|
"method": "Register",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_REGISTER",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/001-register/harness.log",
|
||||||
|
"captures/047-frida-com-proxy-register/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve returned server handle in returnValue and RegisterReply",
|
||||||
|
"preserve success HRESULT as 0",
|
||||||
|
"do not emit MXAccess events for register"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.unregister.basic",
|
||||||
|
"method": "Unregister",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_UNREGISTER",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/001-register/harness.log",
|
||||||
|
"captures/109-native-post-remove-errors/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve void return shape with explicit protocol success",
|
||||||
|
"preserve HRESULT or COM exception details for invalid server handle",
|
||||||
|
"close registered handle only after MXAccess succeeds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.add-item.scalar",
|
||||||
|
"method": "AddItem",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ADD_ITEM",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/002-add-remove-scalar/harness.log",
|
||||||
|
"captures/006-add-invalid/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve returned item handle in returnValue and AddItemReply",
|
||||||
|
"preserve invalid item reference HRESULT/status details",
|
||||||
|
"do not prevalidate item definition in the gateway"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.add-item2.context",
|
||||||
|
"method": "AddItem2",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ADD_ITEM2",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/mxaccess-additem2-testint-context.log",
|
||||||
|
"captures/121-frida-buffered-history-testhistoryvalue-context/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"pass item_definition and item_context exactly as supplied",
|
||||||
|
"preserve returned item handle in returnValue and AddItem2Reply",
|
||||||
|
"compare context-bearing reference resolution against direct MXAccess"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.remove-item.basic",
|
||||||
|
"method": "RemoveItem",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_REMOVE_ITEM",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/002-add-remove-scalar/harness.log",
|
||||||
|
"captures/109-native-post-remove-errors/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve void return shape with explicit protocol success",
|
||||||
|
"preserve post-remove and invalid-handle HRESULT/status behavior",
|
||||||
|
"remove diagnostic handle state only after MXAccess succeeds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.advise.supervisory-data-change",
|
||||||
|
"method": "Advise",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ADVISE",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/003-subscribe-scalars/harness.log",
|
||||||
|
"captures/058-frida-subscribe-testint/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve successful command reply shape",
|
||||||
|
"forward OnDataChange with value, quality, timestamp, and status array",
|
||||||
|
"preserve per-worker event order"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.unadvise.basic",
|
||||||
|
"method": "UnAdvise",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_UN_ADVISE",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/058-frida-subscribe-testint/harness.log",
|
||||||
|
"captures/007-subscribe-invalid/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve void return shape with explicit protocol success",
|
||||||
|
"preserve invalid item handle HRESULT/status behavior",
|
||||||
|
"do not distinguish plain and supervisory cleanup beyond MXAccess behavior"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.advise-supervisory.basic",
|
||||||
|
"method": "AdviseSupervisory",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ADVISE_SUPERVISORY",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/058-frida-subscribe-testint/harness.log",
|
||||||
|
"captures/105-frida-advise-shortdesc-prebound-fixed/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"keep AdviseSupervisory distinct from plain Advise in command kind",
|
||||||
|
"forward native OnDataChange only when MXAccess emits it",
|
||||||
|
"compare supervisory item status arrays without normalization"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.add-buffered-item.context",
|
||||||
|
"method": "AddBufferedItem",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ADD_BUFFERED_ITEM",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/079-frida-add-buffered-advise-testint/harness.log",
|
||||||
|
"captures/120-frida-buffered-history-testhistoryvalue/harness.log",
|
||||||
|
"captures/121-frida-buffered-history-testhistoryvalue-context/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"pass item_definition and item_context exactly as supplied",
|
||||||
|
"preserve returned buffered item handle in returnValue and AddBufferedItemReply",
|
||||||
|
"keep buffered registration distinct from normal AddItem2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.set-buffered-update-interval.basic",
|
||||||
|
"method": "SetBufferedUpdateInterval",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/mxaccess-set-buffered-interval-1000.log",
|
||||||
|
"captures/079-frida-add-buffered-advise-testint/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve requested update interval without clamping in the gateway",
|
||||||
|
"preserve void return shape with explicit protocol success",
|
||||||
|
"compare buffered event cadence only in opt-in live runs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.suspend.scan-state",
|
||||||
|
"method": "Suspend",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_SUSPEND",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/077-frida-suspend-advised-scanstate/harness.log",
|
||||||
|
"captures/118-frida-suspend-advised-scanstate-long/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve out MxStatus in SuspendReply and repeated statuses",
|
||||||
|
"preserve HRESULT separately from status detail",
|
||||||
|
"do not synthesize OperationComplete if native MXAccess does not raise it"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.activate.scan-state",
|
||||||
|
"method": "Activate",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ACTIVATE",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/078-frida-activate-advised-scanstate/harness.log",
|
||||||
|
"captures/119-frida-activate-advised-scanstate-long/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve out MxStatus in ActivateReply and repeated statuses",
|
||||||
|
"preserve HRESULT separately from status detail",
|
||||||
|
"do not synthesize OperationComplete if native MXAccess does not raise it"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.write.value-status-matrix",
|
||||||
|
"method": "Write",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_WRITE",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/023-frida-write-test-int-sequence-109-111/harness.log",
|
||||||
|
"captures/024-frida-write-test-bool-sequence/harness.log",
|
||||||
|
"captures/089-frida-write-testint-wrong-type/harness.log",
|
||||||
|
"captures/090-frida-write-invalid-reference/harness.log",
|
||||||
|
"captures/107-native-write-testint-current/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve scalar and array value projections plus raw fallback metadata",
|
||||||
|
"preserve wrong-type and invalid-reference HRESULT/status arrays",
|
||||||
|
"forward OnWriteComplete only when native MXAccess emits it"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.write2.timestamped",
|
||||||
|
"method": "Write2",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_WRITE2",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/042-frida-write2-test-int-timestamp/harness.log",
|
||||||
|
"captures/066-frida-write2-test-bool-timestamp/harness.log",
|
||||||
|
"captures/075-frida-write2-test-datetime-array-timestamp/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve timestamp_value as an MXAccess VARIANT projection",
|
||||||
|
"preserve write value shape and HRESULT/status arrays",
|
||||||
|
"compare timestamped write completion events against direct MXAccess"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.write-secured.rejection-gap",
|
||||||
|
"method": "WriteSecured",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_WRITE_SECURED",
|
||||||
|
"status": "documented_gap",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/036-frida-write-secured-test-int/harness.log",
|
||||||
|
"captures/111-frida-write-secured-auth-protectedvalue/harness.log",
|
||||||
|
"captures/112-frida-write-secured-auth-verified-protectedvalue1/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve observed 0x80004021 rejection before a value-bearing NMX body",
|
||||||
|
"preserve current_user_id and verifier_user_id only as command inputs, not logs",
|
||||||
|
"upgrade this gap to planned_fixture when a successful direct WriteSecured path is observed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.write-secured2.authenticated",
|
||||||
|
"method": "WriteSecured2",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_WRITE_SECURED2",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/113-frida-write-secured2-auth-protectedvalue/harness.log",
|
||||||
|
"captures/116-frida-write-secured2-auth-verified-protectedvalue1/harness.log",
|
||||||
|
"captures/117-frida-write-secured2-auth-testint/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve authenticated timestamped secured write body shape",
|
||||||
|
"preserve HRESULT/status arrays without logging credential-bearing values",
|
||||||
|
"do not synthesize OnWriteComplete when direct MXAccess does not emit it"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.authenticate-user.basic",
|
||||||
|
"method": "AuthenticateUser",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_AUTHENTICATE_USER",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/087-frida-authenticate-administrator-empty/harness.log",
|
||||||
|
"captures/088-frida-authenticate-invalid-empty/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve returned user id in returnValue and AuthenticateUserReply",
|
||||||
|
"preserve invalid credential HRESULT/status behavior",
|
||||||
|
"redact verify_user_password from logs and diagnostics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "method.archestra-user-to-id.basic",
|
||||||
|
"method": "ArchestrAUserToId",
|
||||||
|
"commandKind": "MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/mxaccess-user-map-administrator.log",
|
||||||
|
"captures/mxaccess-user-map-invalid.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve returned user id in returnValue and ArchestrAUserToIdReply",
|
||||||
|
"preserve invalid user GUID HRESULT/status behavior",
|
||||||
|
"compare raw mapping behavior without normalizing unknown users"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"eventFixtures": [
|
||||||
|
{
|
||||||
|
"id": "event.on-data-change.scalar",
|
||||||
|
"family": "MX_EVENT_FAMILY_ON_DATA_CHANGE",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/003-subscribe-scalars/harness.log",
|
||||||
|
"captures/106-native-subscribe-testint-current/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve value, quality, timestamp, status array, and worker sequence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "event.on-write-complete.status",
|
||||||
|
"family": "MX_EVENT_FAMILY_ON_WRITE_COMPLETE",
|
||||||
|
"status": "planned_fixture",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/008-write-test-int-same-value/harness.log",
|
||||||
|
"captures/107-native-write-testint-current/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve write-complete status array and optional HRESULT"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "event.operation-complete.native-trigger-gap",
|
||||||
|
"family": "MX_EVENT_FAMILY_OPERATION_COMPLETE",
|
||||||
|
"status": "documented_gap",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/077-frida-suspend-advised-scanstate/harness.log",
|
||||||
|
"captures/118-frida-suspend-advised-scanstate-long/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"do not synthesize OperationComplete from Write or OnWriteComplete",
|
||||||
|
"upgrade this gap when a public MXAccess trigger emits event family 3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "event.on-buffered-data-change.batch-gap",
|
||||||
|
"family": "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE",
|
||||||
|
"status": "documented_gap",
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/120-frida-buffered-history-testhistoryvalue/harness.log",
|
||||||
|
"captures/122-frida-buffered-history-testhistoryvalue-plainadvise/harness.log"
|
||||||
|
],
|
||||||
|
"assertions": [
|
||||||
|
"preserve raw buffered metadata until a public multi-sample event payload is observed",
|
||||||
|
"upgrade this gap when OnBufferedDataChange batches are captured from MXAccess"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scenarioGroups": [
|
||||||
|
{
|
||||||
|
"id": "invalid_handles",
|
||||||
|
"description": "Invalid server, item, post-remove, and invalid-reference cases keep MXAccess-owned HRESULT and status behavior.",
|
||||||
|
"fixtureIds": [
|
||||||
|
"method.add-item.scalar",
|
||||||
|
"method.remove-item.basic",
|
||||||
|
"method.unadvise.basic",
|
||||||
|
"method.write.value-status-matrix",
|
||||||
|
"method.unregister.basic"
|
||||||
|
],
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/006-add-invalid/harness.log",
|
||||||
|
"captures/007-subscribe-invalid/harness.log",
|
||||||
|
"captures/109-native-post-remove-errors/harness.log",
|
||||||
|
"captures/110-native-invalid-handle-errors/harness.log"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write_statuses",
|
||||||
|
"description": "Write success, wrong type, invalid reference, scalar arrays, and completion-status cases compare HRESULT, status array, value projection, and event shape.",
|
||||||
|
"fixtureIds": [
|
||||||
|
"method.write.value-status-matrix",
|
||||||
|
"method.write2.timestamped",
|
||||||
|
"event.on-write-complete.status"
|
||||||
|
],
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/089-frida-write-testint-wrong-type/harness.log",
|
||||||
|
"captures/090-frida-write-invalid-reference/harness.log",
|
||||||
|
"captures/091-frida-write-testint-double-type/harness.log",
|
||||||
|
"captures/097-frida-write-bool-array-pattern/harness.log",
|
||||||
|
"captures/107-native-write-testint-current/harness.log"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "secured_writes",
|
||||||
|
"description": "Secured writes include observed WriteSecured rejection and authenticated WriteSecured2 success paths without logging credential-bearing values.",
|
||||||
|
"fixtureIds": [
|
||||||
|
"method.write-secured.rejection-gap",
|
||||||
|
"method.write-secured2.authenticated",
|
||||||
|
"method.authenticate-user.basic"
|
||||||
|
],
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/036-frida-write-secured-test-int/harness.log",
|
||||||
|
"captures/111-frida-write-secured-auth-protectedvalue/harness.log",
|
||||||
|
"captures/113-frida-write-secured2-auth-protectedvalue/harness.log",
|
||||||
|
"captures/117-frida-write-secured2-auth-testint/harness.log"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "add_item_context",
|
||||||
|
"description": "Context-bearing item registration compares AddItem2 and buffered AddBufferedItem argument preservation.",
|
||||||
|
"fixtureIds": [
|
||||||
|
"method.add-item2.context",
|
||||||
|
"method.add-buffered-item.context"
|
||||||
|
],
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/mxaccess-additem2-testint-context.log",
|
||||||
|
"captures/121-frida-buffered-history-testhistoryvalue-context/harness.log"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "buffered_registration",
|
||||||
|
"description": "Buffered registration and interval setup are tracked separately from normal advice until a public buffered data-change batch is captured.",
|
||||||
|
"fixtureIds": [
|
||||||
|
"method.add-buffered-item.context",
|
||||||
|
"method.set-buffered-update-interval.basic",
|
||||||
|
"event.on-buffered-data-change.batch-gap"
|
||||||
|
],
|
||||||
|
"captureReferences": [
|
||||||
|
"captures/079-frida-add-buffered-advise-testint/harness.log",
|
||||||
|
"captures/120-frida-buffered-history-testhistoryvalue/harness.log",
|
||||||
|
"captures/122-frida-buffered-history-testhistoryvalue-plainadvise/harness.log"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Python Client
|
||||||
|
|
||||||
|
The Python client package contains generated MXAccess Gateway protobuf
|
||||||
|
bindings, the async `mxgateway` package, and the `mxgw-py` test CLI. The
|
||||||
|
package uses the shared proto inputs documented in
|
||||||
|
`../../docs/client-proto-generation.md` so gateway and client contracts stay in
|
||||||
|
sync.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/python/
|
||||||
|
pyproject.toml
|
||||||
|
generate-proto.ps1
|
||||||
|
src/mxgateway/
|
||||||
|
src/mxgateway/generated/
|
||||||
|
src/mxgateway_cli/
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/mxgateway/generated` contains code produced by `grpc_tools.protoc`. Do not
|
||||||
|
edit generated files by hand.
|
||||||
|
|
||||||
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
|
Run generation after the shared `.proto` files or the Python output path
|
||||||
|
changes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./generate-proto.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses the Python tool path recorded in
|
||||||
|
`../../docs/toolchain-links.md`.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Python checks from `clients/python`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
python -m pytest
|
||||||
|
python -m pip wheel . --no-deps --wheel-dir "$env:TEMP\mxgateway-python-wheel"
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
The library is async-first:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mxgateway import GatewayClient
|
||||||
|
|
||||||
|
async with await GatewayClient.connect(
|
||||||
|
endpoint="localhost:5000",
|
||||||
|
api_key="mxgw_example",
|
||||||
|
plaintext=True,
|
||||||
|
) as client:
|
||||||
|
session = await client.open_session(client_session_name="python-client")
|
||||||
|
try:
|
||||||
|
server_handle = await session.register("python-client")
|
||||||
|
item_handle = await session.add_item(server_handle, "Object.Attribute")
|
||||||
|
await session.advise(server_handle, item_handle)
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
`GatewayClient.open_session_raw`, `GatewayClient.invoke_raw`, and
|
||||||
|
`GatewayClient.stream_events_raw` keep the generated protobuf replies and
|
||||||
|
events available for parity tests. `Session` helpers call the method-specific
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Authentication And TLS
|
||||||
|
|
||||||
|
`ClientOptions.api_key` adds this metadata to unary calls and streams:
|
||||||
|
|
||||||
|
```text
|
||||||
|
authorization: Bearer <api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
The client supports plaintext channels for local development, TLS with system
|
||||||
|
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
||||||
|
API keys are redacted from option repr output and CLI error output.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The CLI emits deterministic JSON for automation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
mxgw-py version --json
|
||||||
|
mxgw-py open-session --endpoint localhost:5000 --plaintext --json
|
||||||
|
mxgw-py register --session-id <id> --client-name python-client --json
|
||||||
|
mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --json
|
||||||
|
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
|
||||||
|
mxgw-py stream-events --session-id <id> --max-events 1 --json
|
||||||
|
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||||
|
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
||||||
|
$outputRoot = Join-Path $PSScriptRoot 'src\mxgateway\generated'
|
||||||
|
$python = 'C:\Users\dohertj2\AppData\Local\Programs\Python\Python312\python.exe'
|
||||||
|
|
||||||
|
if (-not (Test-Path $python)) {
|
||||||
|
throw "Python was not found at $python. See docs/toolchain-links.md."
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||||
|
Get-ChildItem -Path (Join-Path $outputRoot '*_pb2.py') -File | Remove-Item
|
||||||
|
Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
|
||||||
|
|
||||||
|
& $python -m grpc_tools.protoc `
|
||||||
|
"-I$protoRoot" `
|
||||||
|
"--python_out=$outputRoot" `
|
||||||
|
"--grpc_python_out=$outputRoot" `
|
||||||
|
mxaccess_gateway.proto `
|
||||||
|
mxaccess_worker.proto
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mxaccess-gateway-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Async Python client scaffold for MXAccess Gateway."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"click>=8.3,<9",
|
||||||
|
"grpcio>=1.80,<2",
|
||||||
|
"protobuf>=6.33,<7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"grpcio-tools>=1.80,<2",
|
||||||
|
"pytest>=9,<10",
|
||||||
|
"pytest-asyncio>=1.3,<2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mxgw-py = "mxgateway_cli.commands:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "-ra"
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""MXAccess Gateway Python client package."""
|
||||||
|
|
||||||
|
from .auth import ApiKey, auth_metadata
|
||||||
|
from .client import GatewayClient
|
||||||
|
from .errors import (
|
||||||
|
MxAccessError,
|
||||||
|
MxGatewayAuthenticationError,
|
||||||
|
MxGatewayAuthorizationError,
|
||||||
|
MxGatewayCommandError,
|
||||||
|
MxGatewayError,
|
||||||
|
MxGatewaySessionError,
|
||||||
|
MxGatewayTransportError,
|
||||||
|
MxGatewayWorkerError,
|
||||||
|
)
|
||||||
|
from .options import ClientOptions
|
||||||
|
from .session import Session
|
||||||
|
from .values import MxValueView, from_mx_value, to_mx_value
|
||||||
|
from .version import __version__
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApiKey",
|
||||||
|
"ClientOptions",
|
||||||
|
"GatewayClient",
|
||||||
|
"MxAccessError",
|
||||||
|
"MxGatewayAuthenticationError",
|
||||||
|
"MxGatewayAuthorizationError",
|
||||||
|
"MxGatewayCommandError",
|
||||||
|
"MxGatewayError",
|
||||||
|
"MxGatewaySessionError",
|
||||||
|
"MxGatewayTransportError",
|
||||||
|
"MxGatewayWorkerError",
|
||||||
|
"MxValueView",
|
||||||
|
"Session",
|
||||||
|
"__version__",
|
||||||
|
"auth_metadata",
|
||||||
|
"from_mx_value",
|
||||||
|
"to_mx_value",
|
||||||
|
]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Authentication metadata helpers for MXAccess Gateway clients."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
AUTHORIZATION_HEADER = "authorization"
|
||||||
|
REDACTED = "[redacted]"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ApiKey:
|
||||||
|
"""API key wrapper that avoids leaking the secret through repr output."""
|
||||||
|
|
||||||
|
value: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.value:
|
||||||
|
raise ValueError("api_key must not be empty")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"{type(self).__name__}({REDACTED!r})"
|
||||||
|
|
||||||
|
def bearer_value(self) -> str:
|
||||||
|
return f"Bearer {self.value}"
|
||||||
|
|
||||||
|
|
||||||
|
def auth_metadata(api_key: str | ApiKey | None) -> tuple[tuple[str, str], ...]:
|
||||||
|
"""Return gRPC metadata for API key auth."""
|
||||||
|
|
||||||
|
if api_key is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
key = api_key.value if isinstance(api_key, ApiKey) else api_key
|
||||||
|
if not key:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
return ((AUTHORIZATION_HEADER, f"Bearer {key}"),)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_metadata(
|
||||||
|
api_key: str | ApiKey | None,
|
||||||
|
metadata: Sequence[tuple[str, str]] | None = None,
|
||||||
|
) -> tuple[tuple[str, str], ...]:
|
||||||
|
"""Merge caller metadata with API key metadata."""
|
||||||
|
|
||||||
|
merged = list(metadata or ())
|
||||||
|
merged.extend(auth_metadata(api_key))
|
||||||
|
return tuple(merged)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_secret(text: str, secrets: Sequence[str | None]) -> str:
|
||||||
|
"""Replace known secret values with a stable redaction marker."""
|
||||||
|
|
||||||
|
redacted = text
|
||||||
|
for secret in secrets:
|
||||||
|
if secret:
|
||||||
|
redacted = redacted.replace(secret, REDACTED)
|
||||||
|
return redacted
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Async MXAccess Gateway client wrapper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
|
||||||
|
from .auth import merge_metadata
|
||||||
|
from .errors import ensure_protocol_success, map_rpc_error
|
||||||
|
from .generated import mxaccess_gateway_pb2 as pb
|
||||||
|
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||||
|
from .options import ClientOptions, create_channel
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayClient:
|
||||||
|
"""Async client for the public MXAccess Gateway gRPC API."""
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> "GatewayClient":
|
||||||
|
"""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=pb_grpc.MxAccessGatewayStub(channel),
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "GatewayClient":
|
||||||
|
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
|
||||||
|
|
||||||
|
self._closed = True
|
||||||
|
if self._channel is not None:
|
||||||
|
await self._channel.close()
|
||||||
|
|
||||||
|
async def open_session(
|
||||||
|
self,
|
||||||
|
request: pb.OpenSessionRequest | None = None,
|
||||||
|
*,
|
||||||
|
requested_backend: str = "",
|
||||||
|
client_session_name: str = "",
|
||||||
|
client_correlation_id: str = "",
|
||||||
|
) -> "Session":
|
||||||
|
"""Open a gateway session and return a high-level session wrapper."""
|
||||||
|
|
||||||
|
from .session import Session
|
||||||
|
|
||||||
|
raw_request = request or pb.OpenSessionRequest(
|
||||||
|
requested_backend=requested_backend,
|
||||||
|
client_session_name=client_session_name,
|
||||||
|
client_correlation_id=client_correlation_id,
|
||||||
|
)
|
||||||
|
reply = await self.open_session_raw(raw_request)
|
||||||
|
return Session(client=self, session_id=reply.session_id, open_reply=reply)
|
||||||
|
|
||||||
|
async def open_session_raw(self, request: pb.OpenSessionRequest) -> pb.OpenSessionReply:
|
||||||
|
reply = await self._unary("open session", self.raw_stub.OpenSession, request)
|
||||||
|
ensure_protocol_success("open session", reply.protocol_status, reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
async def close_session_raw(
|
||||||
|
self,
|
||||||
|
request: pb.CloseSessionRequest,
|
||||||
|
) -> pb.CloseSessionReply:
|
||||||
|
reply = await self._unary("close session", self.raw_stub.CloseSession, request)
|
||||||
|
ensure_protocol_success("close session", reply.protocol_status, reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
async def invoke_raw(self, request: pb.MxCommandRequest) -> pb.MxCommandReply:
|
||||||
|
reply = await self._unary("invoke", self.raw_stub.Invoke, request)
|
||||||
|
ensure_protocol_success("invoke", reply.protocol_status, reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def stream_events_raw(
|
||||||
|
self,
|
||||||
|
request: pb.StreamEventsRequest,
|
||||||
|
*,
|
||||||
|
metadata: Sequence[tuple[str, str]] | None = None,
|
||||||
|
) -> 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),
|
||||||
|
)
|
||||||
|
return _canceling_iterator(call)
|
||||||
|
|
||||||
|
async def _unary(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
method: Any,
|
||||||
|
request: Any,
|
||||||
|
*,
|
||||||
|
metadata: Sequence[tuple[str, str]] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
call = method(
|
||||||
|
request,
|
||||||
|
metadata=merge_metadata(self.options.api_key, metadata),
|
||||||
|
)
|
||||||
|
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[pb.MxEvent]:
|
||||||
|
try:
|
||||||
|
async for event in call:
|
||||||
|
yield event
|
||||||
|
except grpc.RpcError as error:
|
||||||
|
raise map_rpc_error("stream events", error) from error
|
||||||
|
finally:
|
||||||
|
cancel = getattr(call, "cancel", None)
|
||||||
|
if cancel is not None:
|
||||||
|
cancel()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Typed exception model for MXAccess Gateway Python clients."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
|
||||||
|
from .generated import mxaccess_gateway_pb2 as pb
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewayError(Exception):
|
||||||
|
"""Base class for client wrapper errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
protocol_status: pb.ProtocolStatus | None = None,
|
||||||
|
raw_reply: Any | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.protocol_status = protocol_status
|
||||||
|
self.raw_reply = raw_reply
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewayTransportError(MxGatewayError):
|
||||||
|
"""Transport-level gRPC failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewayAuthenticationError(MxGatewayTransportError):
|
||||||
|
"""Authentication failure reported by gRPC."""
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewayAuthorizationError(MxGatewayTransportError):
|
||||||
|
"""Authorization failure reported by gRPC."""
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewaySessionError(MxGatewayError):
|
||||||
|
"""Gateway session failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewayWorkerError(MxGatewayError):
|
||||||
|
"""Gateway worker process or protocol failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class MxGatewayCommandError(MxGatewayError):
|
||||||
|
"""Command failure that preserves the raw protobuf reply."""
|
||||||
|
|
||||||
|
|
||||||
|
class MxAccessError(MxGatewayCommandError):
|
||||||
|
"""MXAccess HRESULT or status failure."""
|
||||||
|
|
||||||
|
|
||||||
|
def map_rpc_error(operation: str, error: grpc.RpcError) -> MxGatewayTransportError:
|
||||||
|
"""Map a generated gRPC exception to the client exception hierarchy."""
|
||||||
|
|
||||||
|
code = error.code() if hasattr(error, "code") else None
|
||||||
|
details = error.details() if hasattr(error, "details") else str(error)
|
||||||
|
message = f"{operation} failed: {details}"
|
||||||
|
|
||||||
|
if code == grpc.StatusCode.UNAUTHENTICATED:
|
||||||
|
return MxGatewayAuthenticationError(message)
|
||||||
|
if code == grpc.StatusCode.PERMISSION_DENIED:
|
||||||
|
return MxGatewayAuthorizationError(message)
|
||||||
|
|
||||||
|
return MxGatewayTransportError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_protocol_success(
|
||||||
|
operation: str,
|
||||||
|
protocol_status: pb.ProtocolStatus | None,
|
||||||
|
raw_reply: Any | None = None,
|
||||||
|
) -> Any | None:
|
||||||
|
"""Raise typed gateway errors for non-OK protocol statuses."""
|
||||||
|
|
||||||
|
code = (
|
||||||
|
protocol_status.code
|
||||||
|
if protocol_status is not None
|
||||||
|
else pb.PROTOCOL_STATUS_CODE_UNSPECIFIED
|
||||||
|
)
|
||||||
|
|
||||||
|
if code in (
|
||||||
|
pb.PROTOCOL_STATUS_CODE_OK,
|
||||||
|
pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||||
|
):
|
||||||
|
return raw_reply
|
||||||
|
|
||||||
|
message_text = protocol_status.message if protocol_status else ""
|
||||||
|
message = f"{operation} failed: {message_text or pb.ProtocolStatusCode.Name(code)}"
|
||||||
|
|
||||||
|
if code in (
|
||||||
|
pb.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND,
|
||||||
|
pb.PROTOCOL_STATUS_CODE_SESSION_NOT_READY,
|
||||||
|
):
|
||||||
|
raise MxGatewaySessionError(
|
||||||
|
message,
|
||||||
|
protocol_status=protocol_status,
|
||||||
|
raw_reply=raw_reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
if code in (
|
||||||
|
pb.PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE,
|
||||||
|
pb.PROTOCOL_STATUS_CODE_TIMEOUT,
|
||||||
|
pb.PROTOCOL_STATUS_CODE_CANCELED,
|
||||||
|
pb.PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION,
|
||||||
|
):
|
||||||
|
raise MxGatewayWorkerError(
|
||||||
|
message,
|
||||||
|
protocol_status=protocol_status,
|
||||||
|
raw_reply=raw_reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise MxGatewayCommandError(
|
||||||
|
message,
|
||||||
|
protocol_status=protocol_status,
|
||||||
|
raw_reply=raw_reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCommandReply:
|
||||||
|
"""Raise `MxAccessError` when MXAccess returned HRESULT or status failure."""
|
||||||
|
|
||||||
|
status = reply.protocol_status
|
||||||
|
if status.code == pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE:
|
||||||
|
raise MxAccessError(
|
||||||
|
_mxaccess_message(operation, reply),
|
||||||
|
protocol_status=status,
|
||||||
|
raw_reply=reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply.HasField("hresult") and reply.hresult < 0:
|
||||||
|
raise MxAccessError(
|
||||||
|
_mxaccess_message(operation, reply),
|
||||||
|
protocol_status=status,
|
||||||
|
raw_reply=reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
for mx_status in reply.statuses:
|
||||||
|
if mx_status.success == 0:
|
||||||
|
raise MxAccessError(
|
||||||
|
_mxaccess_message(operation, reply),
|
||||||
|
protocol_status=status,
|
||||||
|
raw_reply=reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def _mxaccess_message(operation: str, reply: pb.MxCommandReply) -> str:
|
||||||
|
status_text = reply.protocol_status.message or "MXAccess command failed"
|
||||||
|
hresult = reply.hresult if reply.HasField("hresult") else None
|
||||||
|
return (
|
||||||
|
f"{operation} failed: {status_text}; "
|
||||||
|
f"session={reply.session_id}; correlation={reply.correlation_id}; "
|
||||||
|
f"hresult={hresult}; statuses={len(reply.statuses)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Generated protobuf and gRPC modules for MXAccess Gateway.
|
||||||
|
|
||||||
|
The Python protobuf generator emits absolute imports between generated modules.
|
||||||
|
This package initializer registers package-local aliases so callers can import
|
||||||
|
the generated stubs through `mxgateway.generated` without moving the modules to
|
||||||
|
the top-level import namespace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
import sys
|
||||||
|
|
||||||
|
mxaccess_gateway_pb2 = import_module(f"{__name__}.mxaccess_gateway_pb2")
|
||||||
|
sys.modules.setdefault("mxaccess_gateway_pb2", mxaccess_gateway_pb2)
|
||||||
|
|
||||||
|
mxaccess_gateway_pb2_grpc = import_module(f"{__name__}.mxaccess_gateway_pb2_grpc")
|
||||||
|
sys.modules.setdefault("mxaccess_gateway_pb2_grpc", mxaccess_gateway_pb2_grpc)
|
||||||
|
|
||||||
|
mxaccess_worker_pb2 = import_module(f"{__name__}.mxaccess_worker_pb2")
|
||||||
|
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)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"mxaccess_gateway_pb2",
|
||||||
|
"mxaccess_gateway_pb2_grpc",
|
||||||
|
"mxaccess_worker_pb2",
|
||||||
|
"mxaccess_worker_pb2_grpc",
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user