From d6939432f905dd2905166f02c52e2f02a230df0d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:59:28 -0400 Subject: [PATCH] Issue #48: implement Java client session values errors and CLI --- clients/java/README.md | 65 +- clients/java/build.gradle | 2 + clients/java/mxgateway-cli/build.gradle | 1 + .../mxgateway/cli/MxGatewayCli.java | 611 +++++++++++++++++- .../mxgateway/cli/MxGatewayCliTests.java | 228 ++++++- clients/java/mxgateway-client/build.gradle | 6 + .../mxgateway/client/MxAccessException.java | 14 + .../mxgateway/client/MxEventStream.java | 117 ++++ .../client/MxGatewayAuthInterceptor.java | 37 ++ .../MxGatewayAuthenticationException.java | 7 + .../MxGatewayAuthorizationException.java | 7 + .../mxgateway/client/MxGatewayClient.java | 228 +++++++ .../client/MxGatewayClientOptions.java | 146 +++++ .../client/MxGatewayCommandException.java | 23 + .../mxgateway/client/MxGatewayErrors.java | 72 +++ .../client/MxGatewayEventSubscription.java | 48 ++ .../mxgateway/client/MxGatewayException.java | 11 + .../mxgateway/client/MxGatewaySecrets.java | 33 + .../mxgateway/client/MxGatewaySession.java | 184 ++++++ .../client/MxGatewaySessionException.java | 16 + .../client/MxGatewayWorkerException.java | 16 + .../mxgateway/client/MxStatuses.java | 48 ++ .../dohertylan/mxgateway/client/MxValues.java | 170 +++++ .../client/MxGatewayClientSessionTests.java | 243 +++++++ .../client/MxGatewayFixtureTests.java | 136 ++++ 25 files changed, 2447 insertions(+), 22 deletions(-) create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxAccessException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthInterceptor.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthenticationException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthorizationException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayCommandException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayErrors.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayEventSubscription.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySecrets.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySession.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySessionException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayWorkerException.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxStatuses.java create mode 100644 clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxValues.java create mode 100644 clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayClientSessionTests.java create mode 100644 clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayFixtureTests.java diff --git a/clients/java/README.md b/clients/java/README.md index 63070e2..0f8c6c5 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -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` 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 --client-name java-cli --json" +gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-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 --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 --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 --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 diff --git a/clients/java/build.gradle b/clients/java/build.gradle index 41427c7..55d7248 100644 --- a/clients/java/build.gradle +++ b/clients/java/build.gradle @@ -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' diff --git a/clients/java/mxgateway-cli/build.gradle b/clients/java/mxgateway-cli/build.gradle index dcd0154..56c5814 100644 --- a/clients/java/mxgateway-cli/build.gradle +++ b/clients/java/mxgateway-cli/build.gradle @@ -4,6 +4,7 @@ plugins { dependencies { implementation project(':mxgateway-client') + implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}" implementation "info.picocli:picocli:${picocliVersion}" } diff --git a/clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java b/clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java index e1dc5ba..e7e2296 100644 --- a/clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java +++ b/clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java @@ -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 { + 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 { 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 { @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 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 { + 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 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 redactedJsonMap() { + Map 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 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 values) { + StringBuilder builder = new StringBuilder(); + builder.append('{'); + boolean first = true; + for (Map.Entry 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) 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) { + } } diff --git a/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java b/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java index b1fe17b..6de9073 100644 --- a/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java +++ b/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java @@ -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(); } } diff --git a/clients/java/mxgateway-client/build.gradle b/clients/java/mxgateway-client/build.gradle index cdb2cc9..7d59366 100644 --- a/clients/java/mxgateway-client/build.gradle +++ b/clients/java/mxgateway-client/build.gradle @@ -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 { diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxAccessException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxAccessException.java new file mode 100644 index 0000000..ecbe9ea --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxAccessException.java @@ -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); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java new file mode 100644 index 0000000..53a0759 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java @@ -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, AutoCloseable { + private static final Object END = new Object(); + + private final BlockingQueue queue; + private volatile ClientCallStreamObserver requestStream; + private volatile boolean closed; + private Object next; + + MxEventStream(int capacity) { + queue = new ArrayBlockingQueue<>(capacity); + } + + ClientResponseObserver observer() { + return new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver 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 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(); + } + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthInterceptor.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthInterceptor.java new file mode 100644 index 0000000..e92ba45 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthInterceptor.java @@ -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 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 ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + ClientCall call = next.newCall(method, callOptions); + if (apiKey.isBlank()) { + return call; + } + + return new ForwardingClientCall.SimpleForwardingClientCall<>(call) { + @Override + public void start(Listener responseListener, Metadata headers) { + headers.put(AUTHORIZATION_HEADER, "Bearer " + apiKey); + super.start(responseListener, headers); + } + }; + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthenticationException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthenticationException.java new file mode 100644 index 0000000..bdd3a69 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthenticationException.java @@ -0,0 +1,7 @@ +package com.dohertylan.mxgateway.client; + +public final class MxGatewayAuthenticationException extends MxGatewayException { + public MxGatewayAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthorizationException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthorizationException.java new file mode 100644 index 0000000..8bc6909 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayAuthorizationException.java @@ -0,0 +1,7 @@ +package com.dohertylan.mxgateway.client; + +public final class MxGatewayAuthorizationException extends MxGatewayException { + public MxGatewayAuthorizationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java new file mode 100644 index 0000000..1678d8e --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java @@ -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 openSessionAsync(OpenSessionRequest request) { + CompletableFuture 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 invokeAsync(MxCommandRequest request) { + CompletableFuture 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 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 withDeadline(T stub) { + if (options.callTimeout().isNegative()) { + return stub; + } + return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS); + } + + private static CompletableFuture toCompletable(com.google.common.util.concurrent.ListenableFuture source) { + CompletableFuture 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; + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java new file mode 100644 index 0000000..130c079 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java @@ -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); + } + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayCommandException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayCommandException.java new file mode 100644 index 0000000..ec645d1 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayCommandException.java @@ -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; + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayErrors.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayErrors.java new file mode 100644 index 0000000..b4aa11a --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayErrors.java @@ -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(); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayEventSubscription.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayEventSubscription.java new file mode 100644 index 0000000..60a3d10 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayEventSubscription.java @@ -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> requestStream = new AtomicReference<>(); + + ClientResponseObserver wrap(StreamObserver observer) { + return new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver 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 stream = requestStream.get(); + if (stream != null) { + stream.cancel("client cancelled event stream", null); + } + } + + @Override + public void close() { + cancel(); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayException.java new file mode 100644 index 0000000..01698d1 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayException.java @@ -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); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySecrets.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySecrets.java new file mode 100644 index 0000000..be7fa84 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySecrets.java @@ -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 ""; + } + + 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] = ""; + } + } + return String.join(" ", parts); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySession.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySession.java new file mode 100644 index 0000000..7ed9bd8 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySession.java @@ -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); + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySessionException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySessionException.java new file mode 100644 index 0000000..d9a7493 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewaySessionException.java @@ -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; + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayWorkerException.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayWorkerException.java new file mode 100644 index 0000000..ec45b05 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayWorkerException.java @@ -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; + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxStatuses.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxStatuses.java new file mode 100644 index 0000000..ff2f1d2 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxStatuses.java @@ -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(); + } + } +} diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxValues.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxValues.java new file mode 100644 index 0000000..6d44b93 --- /dev/null +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxValues.java @@ -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 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 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 timestampValues(TimestampArray array) { + List values = new ArrayList<>(); + for (Timestamp timestamp : array.getValuesList()) { + values.add(instant(timestamp)); + } + return values; + } + + private static List rawValues(RawArray array) { + List 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. + } +} diff --git a/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayClientSessionTests.java b/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayClientSessionTests.java new file mode 100644 index 0000000..3d4fd09 --- /dev/null +++ b/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayClientSessionTests.java @@ -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 authorization = new AtomicReference<>(); + AtomicReference commandRequest = new AtomicReference<>(); + AtomicReference deadlineSeen = new AtomicReference<>(false); + + TestGatewayService service = new TestGatewayService() { + @Override + public void openSession(OpenSessionRequest request, StreamObserver 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 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 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 responseObserver) { + ServerCallStreamObserver serverObserver = + (ServerCallStreamObserver) 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 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 responseObserver) { + responseObserver.onNext(OpenSessionReply.newBuilder() + .setSessionId("session-java") + .setProtocolStatus(ok()) + .build()); + responseObserver.onCompleted(); + } + + @Override + public void closeSession(CloseSessionRequest request, StreamObserver 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 authorization) + throws Exception { + String serverName = "mxgw-java-" + UUID.randomUUID(); + ServerInterceptor interceptor = new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler 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(); + } + } +} diff --git a/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayFixtureTests.java b/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayFixtureTests.java new file mode 100644 index 0000000..8cf37f5 --- /dev/null +++ b/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/MxGatewayFixtureTests.java @@ -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("")); + 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(); + } +}