Compare commits

...

2 Commits

Author SHA1 Message Date
Joseph Doherty d6939432f9 Issue #48: implement Java client session values errors and CLI 2026-04-26 20:59:28 -04:00
dohertj2 02143ef7e2 Merge pull request #98 from agent-2/issue-35-parity-fixture-matrix
Issue #35: add parity fixture matrix
2026-04-26 20:54:24 -04:00
25 changed files with 2447 additions and 22 deletions
+60 -5
View File
@@ -1,8 +1,7 @@
# Java Client
The Java client workspace contains the Gradle scaffold for the MXAccess Gateway
client library, generated protobuf/gRPC bindings, a test CLI project, and JUnit
tests.
The Java client workspace contains the MXAccess Gateway client library,
generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
## Layout
@@ -20,8 +19,63 @@ clients/java/
generated sources under `src/main/generated`, which matches the client proto
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests.
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
application entry point used by later CLI implementation work.
application entry point. The CLI supports version, session, command, event
streaming, write, and smoke-test commands with deterministic JSON output.
## 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
@@ -32,7 +86,8 @@ gradle test
```
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
code, and runs JUnit 5 tests for the scaffold and CLI entry point.
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
## Related Documentation
+2
View File
@@ -3,6 +3,8 @@ plugins {
}
ext {
guavaVersion = '33.5.0-jre'
gsonVersion = '2.13.2'
grpcVersion = '1.76.0'
junitVersion = '5.14.1'
picocliVersion = '4.7.7'
+1
View File
@@ -4,6 +4,7 @@ plugins {
dependencies {
implementation project(':mxgateway-client')
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "info.picocli:picocli:${picocliVersion}"
}
@@ -1,29 +1,61 @@
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.",
subcommands = MxGatewayCli.VersionCommand.class)
description = "MXAccess Gateway Java test CLI.")
public final class MxGatewayCli implements Callable<Integer> {
private final MxGatewayCliClientFactory clientFactory;
@Spec
private CommandSpec spec;
public MxGatewayCli() {
this(new GrpcMxGatewayCliClientFactory());
}
MxGatewayCli(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
public static void main(String[] args) {
int exitCode = new CommandLine(new MxGatewayCli()).execute(args);
int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
System.exit(exitCode);
}
public static int execute(PrintWriter out, PrintWriter err, String... args) {
CommandLine commandLine = new CommandLine(new MxGatewayCli());
return execute(new GrpcMxGatewayCliClientFactory(), out, err, args);
}
static int execute(MxGatewayCliClientFactory clientFactory, PrintWriter out, PrintWriter err, String... args) {
CommandLine commandLine = commandLine(clientFactory);
commandLine.setOut(out);
commandLine.setErr(err);
return commandLine.execute(args);
@@ -35,19 +67,578 @@ public final class MxGatewayCli implements Callable<Integer> {
return 0;
}
@Command(name = "version", description = "Prints the Java client scaffold version.")
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
commandLine.addSubcommand("version", new VersionCommand());
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory));
commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand("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() {
spec.commandLine().getOut().printf(
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
MxGatewayClientVersion.clientVersion(),
MxGatewayClientVersion.gatewayProtocolVersion(),
MxGatewayClientVersion.workerProtocolVersion());
Map<String, Object> values = new LinkedHashMap<>();
values.put("clientVersion", MxGatewayClientVersion.clientVersion());
values.put("gatewayProtocolVersion", MxGatewayClientVersion.gatewayProtocolVersion());
values.put("workerProtocolVersion", MxGatewayClientVersion.workerProtocolVersion());
if (json) {
spec.commandLine().getOut().println(jsonObject(values));
return 0;
}
spec.commandLine()
.getOut()
.printf(
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
MxGatewayClientVersion.clientVersion(),
MxGatewayClientVersion.gatewayProtocolVersion(),
MxGatewayClientVersion.workerProtocolVersion());
return 0;
}
}
abstract static class GatewayCommand implements Callable<Integer> {
final MxGatewayCliClientFactory clientFactory;
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GatewayCommand(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
}
@Command(name = "open-session", description = "Opens a gateway session.")
static final class OpenSessionCommand extends GatewayCommand {
@Option(names = "--client-session-name", description = "Client session name.")
String clientSessionName = "";
@Option(names = "--backend", description = "Requested gateway backend.")
String backend = "";
OpenSessionCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var reply = client.openSession(OpenSessionRequest.newBuilder()
.setClientSessionName(clientSessionName)
.setRequestedBackend(backend)
.build());
writeOutput("open-session", common, json, reply, () -> reply.getSessionId());
}
return 0;
}
}
@Command(name = "close-session", description = "Closes a gateway session.")
static final class CloseSessionCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
CloseSessionCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var reply = client.closeSession(CloseSessionRequest.newBuilder()
.setSessionId(sessionId)
.build());
writeOutput("close-session", common, json, reply, () -> reply.getFinalState().name());
}
return 0;
}
}
@Command(name = "register", description = "Invokes MXAccess Register.")
static final class RegisterCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--client-name", required = true, description = "MXAccess client name.")
String clientName;
RegisterCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).registerRaw(clientName);
writeOutput("register", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "add-item", description = "Invokes MXAccess AddItem.")
static final class AddItemCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item", required = true, description = "Item definition.")
String item;
AddItemCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).addItemRaw(serverHandle, item);
writeOutput("add-item", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "advise", description = "Invokes MXAccess Advise.")
static final class AdviseCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
AdviseCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).adviseRaw(serverHandle, itemHandle);
writeOutput("advise", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "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) {
}
}
@@ -1,27 +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),
"version");
args);
return new CliRun(exitCode, output.toString(), errors.toString());
}
assertEquals(0, exitCode);
assertEquals("", errors.toString());
assertTrue(output.toString().contains("mxgateway-java 0.1.0"));
assertTrue(output.toString().contains("gatewayProtocolVersion=1"));
assertTrue(output.toString().contains("workerProtocolVersion=1"));
private record CliRun(int exitCode, String output, String errors) {
}
private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
private FakeClient client;
@Override
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
client = new FakeClient(options.spec.commandLine().getOut());
return client;
}
}
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
private final PrintWriter out;
private final FakeSession session = new FakeSession();
private boolean closeCalled;
private FakeClient(PrintWriter out) {
this.out = out;
}
@Override
public PrintWriter out() {
return out;
}
@Override
public OpenSessionReply openSession(OpenSessionRequest request) {
return OpenSessionReply.newBuilder()
.setSessionId("session-cli")
.setProtocolStatus(ok())
.build();
}
@Override
public CloseSessionReply closeSession(CloseSessionRequest request) {
closeCalled = true;
return CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setFinalState(SessionState.SESSION_STATE_CLOSED)
.setProtocolStatus(ok())
.build();
}
@Override
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
return session;
}
@Override
public void close() {
}
}
private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
private boolean registerCalled;
private boolean addItemCalled;
private boolean adviseCalled;
private MxValue lastWriteValue;
@Override
public int register(String clientName) {
registerCalled = true;
return 42;
}
@Override
public MxCommandReply registerRaw(String clientName) {
registerCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
.setProtocolStatus(ok())
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
.build();
}
@Override
public int addItem(int serverHandle, String itemDefinition) {
addItemCalled = true;
return 7;
}
@Override
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
addItemCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
.setProtocolStatus(ok())
.setAddItem(AddItemReply.newBuilder().setItemHandle(7))
.build();
}
@Override
public void advise(int serverHandle, int itemHandle) {
adviseCalled = true;
}
@Override
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
adviseCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
.setProtocolStatus(ok())
.build();
}
@Override
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
lastWriteValue = value;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
.setProtocolStatus(ok())
.build();
}
@Override
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
throw new UnsupportedOperationException("stream-events is covered by client tests");
}
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
}
@@ -4,13 +4,19 @@ plugins {
}
dependencies {
api "com.google.protobuf:protobuf-java-util:${protobufVersion}"
api "com.google.protobuf:protobuf-java:${protobufVersion}"
api "io.grpc:grpc-protobuf:${grpcVersion}"
api "io.grpc:grpc-stub:${grpcVersion}"
implementation "com.google.guava:guava:${guavaVersion}"
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
compileOnly 'javax.annotation:javax.annotation-api:1.3.2'
testImplementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "io.grpc:grpc-inprocess:${grpcVersion}"
testImplementation "io.grpc:grpc-testing:${grpcVersion}"
}
sourceSets {
@@ -0,0 +1,14 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public final class MxAccessException extends MxGatewayCommandException {
public MxAccessException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
super(operation, protocolStatus, reply);
}
public MxAccessException(String operation, MxCommandReply reply) {
super(operation, reply == null ? null : reply.getProtocolStatus(), reply);
}
}
@@ -0,0 +1,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();
}
}
}
@@ -0,0 +1,37 @@
package com.dohertylan.mxgateway.client;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
public final class MxGatewayAuthInterceptor implements ClientInterceptor {
static final Metadata.Key<String> AUTHORIZATION_HEADER =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final String apiKey;
public MxGatewayAuthInterceptor(String apiKey) {
this.apiKey = apiKey == null ? "" : apiKey;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);
if (apiKey.isBlank()) {
return call;
}
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
headers.put(AUTHORIZATION_HEADER, "Bearer " + apiKey);
super.start(responseListener, headers);
}
};
}
}
@@ -0,0 +1,7 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewayAuthenticationException extends MxGatewayException {
public MxGatewayAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,7 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewayAuthorizationException extends MxGatewayException {
public MxGatewayAuthorizationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,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;
}
}
@@ -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);
}
}
}
@@ -0,0 +1,23 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public class MxGatewayCommandException extends MxGatewayException {
private final ProtocolStatus protocolStatus;
private final MxCommandReply reply;
public MxGatewayCommandException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
this.protocolStatus = protocolStatus;
this.reply = reply;
}
public ProtocolStatus protocolStatus() {
return protocolStatus;
}
public MxCommandReply reply() {
return reply;
}
}
@@ -0,0 +1,72 @@
package com.dohertylan.mxgateway.client;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
final class MxGatewayErrors {
private MxGatewayErrors() {
}
static RuntimeException fromGrpc(String operation, RuntimeException error) {
if (error instanceof StatusRuntimeException statusError) {
Status status = statusError.getStatus();
String message = MxGatewaySecrets.redactCredentials(status.getDescription());
return switch (status.getCode()) {
case UNAUTHENTICATED -> new MxGatewayAuthenticationException(
"authentication failed: " + message, statusError);
case PERMISSION_DENIED -> new MxGatewayAuthorizationException(
"authorization failed: " + message, statusError);
case DEADLINE_EXCEEDED -> new MxGatewayException("gateway call timed out: " + message, statusError);
case CANCELLED -> new MxGatewayException("gateway call cancelled: " + message, statusError);
default -> new MxGatewayException("gateway " + operation + " failed: " + message, statusError);
};
}
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
}
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
return;
}
throw switch (status.getCode()) {
case PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND, PROTOCOL_STATUS_CODE_SESSION_NOT_READY ->
new MxGatewaySessionException(operation, status);
case PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE, PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION ->
new MxGatewayWorkerException(operation, status);
case PROTOCOL_STATUS_CODE_MXACCESS_FAILURE -> new MxAccessException(operation, status, reply);
default -> new MxGatewayCommandException(operation, status, reply);
};
}
static void ensureMxAccessSuccess(String operation, MxCommandReply reply) {
if (reply == null) {
return;
}
if (reply.hasHresult() && reply.getHresult() != 0) {
throw new MxAccessException(operation, reply);
}
for (var status : reply.getStatusesList()) {
if (!MxStatuses.succeeded(status)) {
throw new MxAccessException(operation, reply);
}
}
}
static String protocolStatusMessage(String operation, ProtocolStatus status) {
if (status == null) {
return "mxgateway " + operation + " failed with missing protocol status";
}
if (status.getMessage().isBlank()) {
return "mxgateway " + operation + " failed with protocol status " + status.getCode();
}
return "mxgateway " + operation + " failed with protocol status "
+ status.getCode()
+ ": "
+ status.getMessage();
}
}
@@ -0,0 +1,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();
}
}
@@ -0,0 +1,11 @@
package com.dohertylan.mxgateway.client;
public class MxGatewayException extends RuntimeException {
public MxGatewayException(String message) {
super(message);
}
public MxGatewayException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,33 @@
package com.dohertylan.mxgateway.client;
public final class MxGatewaySecrets {
private MxGatewaySecrets() {
}
public static String redactApiKey(String apiKey) {
if (apiKey == null || apiKey.isEmpty()) {
return "";
}
if (apiKey.length() <= 8) {
return "<redacted>";
}
return apiKey.substring(0, 4)
+ "*".repeat(apiKey.length() - 8)
+ apiKey.substring(apiKey.length() - 4);
}
public static String redactCredentials(String value) {
if (value == null || value.isBlank()) {
return value == null ? "" : value;
}
String[] parts = value.split("\\s+");
for (int index = 0; index < parts.length; index++) {
if (parts[index].startsWith("mxgw_") || parts[index].equalsIgnoreCase("bearer")) {
parts[index] = "<redacted>";
}
}
return String.join(" ", parts);
}
}
@@ -0,0 +1,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);
}
}
@@ -0,0 +1,16 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public final class MxGatewaySessionException extends MxGatewayException {
private final ProtocolStatus protocolStatus;
public MxGatewaySessionException(String operation, ProtocolStatus protocolStatus) {
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
this.protocolStatus = protocolStatus;
}
public ProtocolStatus protocolStatus() {
return protocolStatus;
}
}
@@ -0,0 +1,16 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
public final class MxGatewayWorkerException extends MxGatewayException {
private final ProtocolStatus protocolStatus;
public MxGatewayWorkerException(String operation, ProtocolStatus protocolStatus) {
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
this.protocolStatus = protocolStatus;
}
public ProtocolStatus protocolStatus() {
return protocolStatus;
}
}
@@ -0,0 +1,48 @@
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusSource;
public final class MxStatuses {
private MxStatuses() {
}
public static boolean succeeded(MxStatusProxy status) {
return status == null || status.getSuccess() != 0;
}
public static MxStatusView view(MxStatusProxy status) {
return new MxStatusView(status);
}
public record MxStatusView(MxStatusProxy raw) {
public int success() {
return raw.getSuccess();
}
public MxStatusCategory category() {
return raw.getCategory();
}
public MxStatusSource detectedBy() {
return raw.getDetectedBy();
}
public int detail() {
return raw.getDetail();
}
public int rawCategory() {
return raw.getRawCategory();
}
public int rawDetectedBy() {
return raw.getRawDetectedBy();
}
public String diagnosticText() {
return raw.getDiagnosticText();
}
}
}
@@ -0,0 +1,170 @@
package com.dohertylan.mxgateway.client;
import com.google.protobuf.ByteString;
import com.google.protobuf.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.BoolArray;
import mxaccess_gateway.v1.MxaccessGateway.DoubleArray;
import mxaccess_gateway.v1.MxaccessGateway.FloatArray;
import mxaccess_gateway.v1.MxaccessGateway.Int32Array;
import mxaccess_gateway.v1.MxaccessGateway.Int64Array;
import mxaccess_gateway.v1.MxaccessGateway.MxArray;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.RawArray;
import mxaccess_gateway.v1.MxaccessGateway.StringArray;
import mxaccess_gateway.v1.MxaccessGateway.TimestampArray;
public final class MxValues {
private MxValues() {
}
public static MxValue boolValue(boolean value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_BOOLEAN)
.setVariantType("VT_BOOL")
.setBoolValue(value)
.build();
}
public static MxValue int32Value(int value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
.setVariantType("VT_I4")
.setInt32Value(value)
.build();
}
public static MxValue int64Value(long value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
.setVariantType("VT_I8")
.setInt64Value(value)
.build();
}
public static MxValue floatValue(float value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_FLOAT)
.setVariantType("VT_R4")
.setFloatValue(value)
.build();
}
public static MxValue doubleValue(double value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_DOUBLE)
.setVariantType("VT_R8")
.setDoubleValue(value)
.build();
}
public static MxValue stringValue(String value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_STRING)
.setVariantType("VT_BSTR")
.setStringValue(value)
.build();
}
public static MxValue timestampValue(Instant value) {
return MxValue.newBuilder()
.setDataType(MxDataType.MX_DATA_TYPE_TIME)
.setVariantType("VT_DATE")
.setTimestampValue(Timestamp.newBuilder()
.setSeconds(value.getEpochSecond())
.setNanos(value.getNano())
.build())
.build();
}
public static Object nativeValue(MxValue value) {
if (value == null || value.getIsNull()) {
return null;
}
return switch (value.getKindCase()) {
case BOOL_VALUE -> value.getBoolValue();
case INT32_VALUE -> value.getInt32Value();
case INT64_VALUE -> value.getInt64Value();
case FLOAT_VALUE -> value.getFloatValue();
case DOUBLE_VALUE -> value.getDoubleValue();
case STRING_VALUE -> value.getStringValue();
case TIMESTAMP_VALUE -> instant(value.getTimestampValue());
case ARRAY_VALUE -> nativeArray(value.getArrayValue());
case RAW_VALUE -> value.getRawValue().toByteArray();
case KIND_NOT_SET -> null;
};
}
public static Object nativeArray(MxArray array) {
if (array == null) {
return null;
}
return switch (array.getValuesCase()) {
case BOOL_VALUES -> List.copyOf(array.getBoolValues().getValuesList());
case INT32_VALUES -> List.copyOf(array.getInt32Values().getValuesList());
case INT64_VALUES -> List.copyOf(array.getInt64Values().getValuesList());
case FLOAT_VALUES -> List.copyOf(array.getFloatValues().getValuesList());
case DOUBLE_VALUES -> List.copyOf(array.getDoubleValues().getValuesList());
case STRING_VALUES -> List.copyOf(array.getStringValues().getValuesList());
case TIMESTAMP_VALUES -> timestampValues(array.getTimestampValues());
case RAW_VALUES -> rawValues(array.getRawValues());
case VALUES_NOT_SET -> List.of();
};
}
public static MxArray stringArray(List<String> values) {
return MxArray.newBuilder()
.setElementDataType(MxDataType.MX_DATA_TYPE_STRING)
.setVariantType("VT_ARRAY|VT_BSTR")
.addDimensions(values.size())
.setStringValues(StringArray.newBuilder().addAllValues(values))
.build();
}
public static MxArray int32Array(List<Integer> values) {
return MxArray.newBuilder()
.setElementDataType(MxDataType.MX_DATA_TYPE_INTEGER)
.setVariantType("VT_ARRAY|VT_I4")
.addDimensions(values.size())
.setInt32Values(Int32Array.newBuilder().addAllValues(values))
.build();
}
public static String kindName(MxValue value) {
return value == null ? "KIND_NOT_SET" : value.getKindCase().name();
}
private static Instant instant(Timestamp timestamp) {
return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos());
}
private static List<Instant> timestampValues(TimestampArray array) {
List<Instant> values = new ArrayList<>();
for (Timestamp timestamp : array.getValuesList()) {
values.add(instant(timestamp));
}
return values;
}
private static List<byte[]> rawValues(RawArray array) {
List<byte[]> values = new ArrayList<>();
for (ByteString rawValue : array.getValuesList()) {
values.add(rawValue.toByteArray());
}
return values;
}
@SuppressWarnings("unused")
private static void generatedTypeReferences(
BoolArray boolArray,
Int64Array int64Array,
FloatArray floatArray,
DoubleArray doubleArray) {
// Keeps generated repeated-value imports visible for javadocs and IDE navigation.
}
}
@@ -0,0 +1,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();
}
}
}
@@ -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();
}
}