Issue #48: implement Java client session values errors and CLI
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
|
||||
public final class MxAccessException extends MxGatewayCommandException {
|
||||
public MxAccessException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
|
||||
super(operation, protocolStatus, reply);
|
||||
}
|
||||
|
||||
public MxAccessException(String operation, MxCommandReply reply) {
|
||||
super(operation, reply == null ? null : reply.getProtocolStatus(), reply);
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import java.util.Iterator;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
|
||||
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
private static final Object END = new Object();
|
||||
|
||||
private final BlockingQueue<Object> queue;
|
||||
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
|
||||
private volatile boolean closed;
|
||||
private Object next;
|
||||
|
||||
MxEventStream(int capacity) {
|
||||
queue = new ArrayBlockingQueue<>(capacity);
|
||||
}
|
||||
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer() {
|
||||
return new ClientResponseObserver<>() {
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) {
|
||||
MxEventStream.this.requestStream = requestStream;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(MxEvent value) {
|
||||
offer(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
|
||||
offer(END);
|
||||
return;
|
||||
}
|
||||
offer(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
offer(END);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
if (next == END) {
|
||||
return false;
|
||||
}
|
||||
if (next == null) {
|
||||
next = take();
|
||||
}
|
||||
if (next instanceof RuntimeException runtimeException) {
|
||||
next = END;
|
||||
throw runtimeException;
|
||||
}
|
||||
if (next instanceof Throwable throwable) {
|
||||
next = END;
|
||||
throw new MxGatewayException("gateway stream events failed: " + throwable.getMessage(), throwable);
|
||||
}
|
||||
return next != END;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxEvent next() {
|
||||
if (!hasNext()) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
Object value = next;
|
||||
next = null;
|
||||
return (MxEvent) value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled event stream", null);
|
||||
}
|
||||
offer(END);
|
||||
}
|
||||
|
||||
private Object take() {
|
||||
while (true) {
|
||||
try {
|
||||
return queue.take();
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new StatusRuntimeException(Status.CANCELLED.withDescription("interrupted while reading events"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void offer(Object value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (value == END) {
|
||||
queue.offer(value);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
queue.put(value);
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import io.grpc.CallOptions;
|
||||
import io.grpc.Channel;
|
||||
import io.grpc.ClientCall;
|
||||
import io.grpc.ClientInterceptor;
|
||||
import io.grpc.ForwardingClientCall;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.MethodDescriptor;
|
||||
|
||||
public final class MxGatewayAuthInterceptor implements ClientInterceptor {
|
||||
static final Metadata.Key<String> AUTHORIZATION_HEADER =
|
||||
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
private final String apiKey;
|
||||
|
||||
public MxGatewayAuthInterceptor(String apiKey) {
|
||||
this.apiKey = apiKey == null ? "" : apiKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
|
||||
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
|
||||
ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);
|
||||
if (apiKey.isBlank()) {
|
||||
return call;
|
||||
}
|
||||
|
||||
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
|
||||
@Override
|
||||
public void start(Listener<RespT> responseListener, Metadata headers) {
|
||||
headers.put(AUTHORIZATION_HEADER, "Bearer " + apiKey);
|
||||
super.start(responseListener, headers);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public final class MxGatewayAuthenticationException extends MxGatewayException {
|
||||
public MxGatewayAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public final class MxGatewayAuthorizationException extends MxGatewayException {
|
||||
public MxGatewayAuthorizationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import com.google.protobuf.Duration;
|
||||
import io.grpc.Channel;
|
||||
import io.grpc.ClientInterceptors;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.net.ssl.SSLException;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
|
||||
public final class MxGatewayClient implements AutoCloseable {
|
||||
private final ManagedChannel ownedChannel;
|
||||
private final MxGatewayClientOptions options;
|
||||
private final MxAccessGatewayGrpc.MxAccessGatewayBlockingStub blockingStub;
|
||||
private final MxAccessGatewayGrpc.MxAccessGatewayFutureStub futureStub;
|
||||
private final MxAccessGatewayGrpc.MxAccessGatewayStub asyncStub;
|
||||
|
||||
private MxGatewayClient(ManagedChannel channel, MxGatewayClientOptions options) {
|
||||
this.ownedChannel = channel;
|
||||
this.options = options;
|
||||
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
|
||||
blockingStub = MxAccessGatewayGrpc.newBlockingStub(intercepted);
|
||||
futureStub = MxAccessGatewayGrpc.newFutureStub(intercepted);
|
||||
asyncStub = MxAccessGatewayGrpc.newStub(intercepted);
|
||||
}
|
||||
|
||||
public MxGatewayClient(Channel channel, MxGatewayClientOptions options) {
|
||||
this.ownedChannel = null;
|
||||
this.options = Objects.requireNonNull(options, "options");
|
||||
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
|
||||
blockingStub = MxAccessGatewayGrpc.newBlockingStub(intercepted);
|
||||
futureStub = MxAccessGatewayGrpc.newFutureStub(intercepted);
|
||||
asyncStub = MxAccessGatewayGrpc.newStub(intercepted);
|
||||
}
|
||||
|
||||
public static MxGatewayClient connect(MxGatewayClientOptions options) {
|
||||
return new MxGatewayClient(createChannel(options), options);
|
||||
}
|
||||
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayBlockingStub rawBlockingStub() {
|
||||
return withDeadline(blockingStub);
|
||||
}
|
||||
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayFutureStub rawFutureStub() {
|
||||
return withDeadline(futureStub);
|
||||
}
|
||||
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayStub rawAsyncStub() {
|
||||
return withDeadline(asyncStub);
|
||||
}
|
||||
|
||||
public MxGatewaySession openSession(OpenSessionRequest request) {
|
||||
OpenSessionReply reply = openSessionRaw(request);
|
||||
return new MxGatewaySession(this, reply);
|
||||
}
|
||||
|
||||
public MxGatewaySession openSession(String clientSessionName) {
|
||||
return openSession(OpenSessionRequest.newBuilder()
|
||||
.setClientSessionName(clientSessionName)
|
||||
.setCommandTimeout(Duration.newBuilder()
|
||||
.setSeconds(options.callTimeout().toSeconds())
|
||||
.setNanos(options.callTimeout().toNanosPart())
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
|
||||
public OpenSessionReply openSessionRaw(OpenSessionRequest request) {
|
||||
try {
|
||||
OpenSessionReply reply = rawBlockingStub().openSession(request);
|
||||
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("open session", error);
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<OpenSessionReply> openSessionAsync(OpenSessionRequest request) {
|
||||
CompletableFuture<OpenSessionReply> future = toCompletable(rawFutureStub().openSession(request));
|
||||
return future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
public MxCommandReply invoke(MxCommandRequest request) {
|
||||
try {
|
||||
MxCommandReply reply = rawBlockingStub().invoke(request);
|
||||
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
|
||||
MxGatewayErrors.ensureMxAccessSuccess("invoke", reply);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("invoke", error);
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<MxCommandReply> invokeAsync(MxCommandRequest request) {
|
||||
CompletableFuture<MxCommandReply> future = toCompletable(rawFutureStub().invoke(request));
|
||||
return future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
|
||||
MxGatewayErrors.ensureMxAccessSuccess("invoke", reply);
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
public CloseSessionReply closeSessionRaw(CloseSessionRequest request) {
|
||||
try {
|
||||
CloseSessionReply reply = rawBlockingStub().closeSession(request);
|
||||
MxGatewayErrors.ensureProtocolSuccess("close session", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("close session", error);
|
||||
}
|
||||
}
|
||||
|
||||
public MxEventStream streamEvents(StreamEventsRequest request) {
|
||||
MxEventStream stream = new MxEventStream(16);
|
||||
rawAsyncStub().streamEvents(request, stream.observer());
|
||||
return stream;
|
||||
}
|
||||
|
||||
public MxGatewayEventSubscription streamEventsAsync(
|
||||
StreamEventsRequest request, StreamObserver<MxEvent> observer) {
|
||||
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
|
||||
rawAsyncStub().streamEvents(request, subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
public void closeAndAwaitTermination() throws InterruptedException {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(16 * 1024 * 1024);
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
Math.toIntExact(options.connectTimeout().toMillis()));
|
||||
}
|
||||
if (options.plaintext()) {
|
||||
builder.usePlaintext();
|
||||
} else if (options.caCertificatePath() != null) {
|
||||
try {
|
||||
builder.sslContext(GrpcSslContexts.forClient()
|
||||
.trustManager(options.caCertificatePath().toFile())
|
||||
.build());
|
||||
} catch (SSLException error) {
|
||||
throw new MxGatewayException("failed to configure gateway TLS", error);
|
||||
}
|
||||
} else {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
if (!options.serverNameOverride().isBlank()) {
|
||||
builder.overrideAuthority(options.serverNameOverride());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
|
||||
CompletableFuture<T> target = new CompletableFuture<>();
|
||||
Futures.addCallback(
|
||||
source,
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
target.complete(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc("async call", runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
return target;
|
||||
}
|
||||
|
||||
static ProtocolStatusCode okStatusCode() {
|
||||
return ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK;
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class MxGatewayClientOptions {
|
||||
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofSeconds(30);
|
||||
|
||||
private final String endpoint;
|
||||
private final String apiKey;
|
||||
private final boolean plaintext;
|
||||
private final Path caCertificatePath;
|
||||
private final String serverNameOverride;
|
||||
private final Duration connectTimeout;
|
||||
private final Duration callTimeout;
|
||||
|
||||
private MxGatewayClientOptions(Builder builder) {
|
||||
endpoint = requireText(builder.endpoint, "endpoint");
|
||||
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
||||
plaintext = builder.plaintext;
|
||||
caCertificatePath = builder.caCertificatePath;
|
||||
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public String endpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public String apiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public String redactedApiKey() {
|
||||
return MxGatewaySecrets.redactApiKey(apiKey);
|
||||
}
|
||||
|
||||
public boolean plaintext() {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
public Path caCertificatePath() {
|
||||
return caCertificatePath;
|
||||
}
|
||||
|
||||
public String serverNameOverride() {
|
||||
return serverNameOverride;
|
||||
}
|
||||
|
||||
public Duration connectTimeout() {
|
||||
return connectTimeout;
|
||||
}
|
||||
|
||||
public Duration callTimeout() {
|
||||
return callTimeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MxGatewayClientOptions{"
|
||||
+ "endpoint='"
|
||||
+ endpoint
|
||||
+ '\''
|
||||
+ ", apiKey='"
|
||||
+ redactedApiKey()
|
||||
+ '\''
|
||||
+ ", plaintext="
|
||||
+ plaintext
|
||||
+ ", caCertificatePath="
|
||||
+ caCertificatePath
|
||||
+ ", serverNameOverride='"
|
||||
+ serverNameOverride
|
||||
+ '\''
|
||||
+ ", connectTimeout="
|
||||
+ connectTimeout
|
||||
+ ", callTimeout="
|
||||
+ callTimeout
|
||||
+ '}';
|
||||
}
|
||||
|
||||
private static String requireText(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private String endpoint;
|
||||
private String apiKey;
|
||||
private boolean plaintext;
|
||||
private Path caCertificatePath;
|
||||
private String serverNameOverride;
|
||||
private Duration connectTimeout;
|
||||
private Duration callTimeout;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
public Builder endpoint(String value) {
|
||||
endpoint = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder apiKey(String value) {
|
||||
apiKey = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder plaintext(boolean value) {
|
||||
plaintext = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder caCertificatePath(Path value) {
|
||||
caCertificatePath = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder serverNameOverride(String value) {
|
||||
serverNameOverride = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder connectTimeout(Duration value) {
|
||||
connectTimeout = Objects.requireNonNull(value, "connectTimeout");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder callTimeout(Duration value) {
|
||||
callTimeout = Objects.requireNonNull(value, "callTimeout");
|
||||
return this;
|
||||
}
|
||||
|
||||
public MxGatewayClientOptions build() {
|
||||
return new MxGatewayClientOptions(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
|
||||
public class MxGatewayCommandException extends MxGatewayException {
|
||||
private final ProtocolStatus protocolStatus;
|
||||
private final MxCommandReply reply;
|
||||
|
||||
public MxGatewayCommandException(String operation, ProtocolStatus protocolStatus, MxCommandReply reply) {
|
||||
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
|
||||
this.protocolStatus = protocolStatus;
|
||||
this.reply = reply;
|
||||
}
|
||||
|
||||
public ProtocolStatus protocolStatus() {
|
||||
return protocolStatus;
|
||||
}
|
||||
|
||||
public MxCommandReply reply() {
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
|
||||
final class MxGatewayErrors {
|
||||
private MxGatewayErrors() {
|
||||
}
|
||||
|
||||
static RuntimeException fromGrpc(String operation, RuntimeException error) {
|
||||
if (error instanceof StatusRuntimeException statusError) {
|
||||
Status status = statusError.getStatus();
|
||||
String message = MxGatewaySecrets.redactCredentials(status.getDescription());
|
||||
return switch (status.getCode()) {
|
||||
case UNAUTHENTICATED -> new MxGatewayAuthenticationException(
|
||||
"authentication failed: " + message, statusError);
|
||||
case PERMISSION_DENIED -> new MxGatewayAuthorizationException(
|
||||
"authorization failed: " + message, statusError);
|
||||
case DEADLINE_EXCEEDED -> new MxGatewayException("gateway call timed out: " + message, statusError);
|
||||
case CANCELLED -> new MxGatewayException("gateway call cancelled: " + message, statusError);
|
||||
default -> new MxGatewayException("gateway " + operation + " failed: " + message, statusError);
|
||||
};
|
||||
}
|
||||
|
||||
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
|
||||
}
|
||||
|
||||
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
|
||||
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw switch (status.getCode()) {
|
||||
case PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND, PROTOCOL_STATUS_CODE_SESSION_NOT_READY ->
|
||||
new MxGatewaySessionException(operation, status);
|
||||
case PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE, PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION ->
|
||||
new MxGatewayWorkerException(operation, status);
|
||||
case PROTOCOL_STATUS_CODE_MXACCESS_FAILURE -> new MxAccessException(operation, status, reply);
|
||||
default -> new MxGatewayCommandException(operation, status, reply);
|
||||
};
|
||||
}
|
||||
|
||||
static void ensureMxAccessSuccess(String operation, MxCommandReply reply) {
|
||||
if (reply == null) {
|
||||
return;
|
||||
}
|
||||
if (reply.hasHresult() && reply.getHresult() != 0) {
|
||||
throw new MxAccessException(operation, reply);
|
||||
}
|
||||
for (var status : reply.getStatusesList()) {
|
||||
if (!MxStatuses.succeeded(status)) {
|
||||
throw new MxAccessException(operation, reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String protocolStatusMessage(String operation, ProtocolStatus status) {
|
||||
if (status == null) {
|
||||
return "mxgateway " + operation + " failed with missing protocol status";
|
||||
}
|
||||
if (status.getMessage().isBlank()) {
|
||||
return "mxgateway " + operation + " failed with protocol status " + status.getCode();
|
||||
}
|
||||
return "mxgateway " + operation + " failed with protocol status "
|
||||
+ status.getCode()
|
||||
+ ": "
|
||||
+ status.getMessage();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
|
||||
public final class MxGatewayEventSubscription implements AutoCloseable {
|
||||
private final AtomicReference<ClientCallStreamObserver<StreamEventsRequest>> requestStream = new AtomicReference<>();
|
||||
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> wrap(StreamObserver<MxEvent> observer) {
|
||||
return new ClientResponseObserver<>() {
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> stream) {
|
||||
requestStream.set(stream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(MxEvent value) {
|
||||
observer.onNext(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
observer.onError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream.get();
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled event stream", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public class MxGatewayException extends RuntimeException {
|
||||
public MxGatewayException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MxGatewayException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
public final class MxGatewaySecrets {
|
||||
private MxGatewaySecrets() {
|
||||
}
|
||||
|
||||
public static String redactApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (apiKey.length() <= 8) {
|
||||
return "<redacted>";
|
||||
}
|
||||
|
||||
return apiKey.substring(0, 4)
|
||||
+ "*".repeat(apiKey.length() - 8)
|
||||
+ apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
public static String redactCredentials(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
String[] parts = value.split("\\s+");
|
||||
for (int index = 0; index < parts.length; index++) {
|
||||
if (parts[index].startsWith("mxgw_") || parts[index].equalsIgnoreCase("bearer")) {
|
||||
parts[index] = "<redacted>";
|
||||
}
|
||||
}
|
||||
return String.join(" ", parts);
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
|
||||
|
||||
public final class MxGatewaySession implements AutoCloseable {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final MxGatewayClient client;
|
||||
private final OpenSessionReply openReply;
|
||||
private CloseSessionReply closeReply;
|
||||
|
||||
MxGatewaySession(MxGatewayClient client, OpenSessionReply openReply) {
|
||||
this.client = Objects.requireNonNull(client, "client");
|
||||
this.openReply = Objects.requireNonNull(openReply, "openReply");
|
||||
}
|
||||
|
||||
public static MxGatewaySession forSessionId(MxGatewayClient client, String sessionId) {
|
||||
return new MxGatewaySession(
|
||||
client, OpenSessionReply.newBuilder().setSessionId(sessionId).build());
|
||||
}
|
||||
|
||||
public String sessionId() {
|
||||
return openReply.getSessionId();
|
||||
}
|
||||
|
||||
public OpenSessionReply openReply() {
|
||||
return openReply;
|
||||
}
|
||||
|
||||
public synchronized CloseSessionReply closeRaw() {
|
||||
if (closeReply == null) {
|
||||
closeReply = client.closeSessionRaw(CloseSessionRequest.newBuilder()
|
||||
.setSessionId(sessionId())
|
||||
.setClientCorrelationId(newCorrelationId())
|
||||
.build());
|
||||
}
|
||||
return closeReply;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closeRaw();
|
||||
}
|
||||
|
||||
public int register(String clientName) {
|
||||
MxCommandReply reply = registerRaw(clientName);
|
||||
if (reply.hasRegister()) {
|
||||
return reply.getRegister().getServerHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
|
||||
public MxCommandReply registerRaw(String clientName) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
|
||||
.setRegister(RegisterCommand.newBuilder().setClientName(clientName))
|
||||
.build());
|
||||
}
|
||||
|
||||
public void unregister(int serverHandle) {
|
||||
invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_UNREGISTER)
|
||||
.setUnregister(UnregisterCommand.newBuilder().setServerHandle(serverHandle))
|
||||
.build());
|
||||
}
|
||||
|
||||
public int addItem(int serverHandle, String itemDefinition) {
|
||||
MxCommandReply reply = addItemRaw(serverHandle, itemDefinition);
|
||||
if (reply.hasAddItem()) {
|
||||
return reply.getAddItem().getItemHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
|
||||
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
|
||||
.setAddItem(AddItemCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemDefinition(itemDefinition))
|
||||
.build());
|
||||
}
|
||||
|
||||
public int addItem2(int serverHandle, String itemDefinition, String itemContext) {
|
||||
MxCommandReply reply = addItem2Raw(serverHandle, itemDefinition, itemContext);
|
||||
if (reply.hasAddItem2()) {
|
||||
return reply.getAddItem2().getItemHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
|
||||
public MxCommandReply addItem2Raw(int serverHandle, String itemDefinition, String itemContext) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM2)
|
||||
.setAddItem2(AddItem2Command.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemDefinition(itemDefinition)
|
||||
.setItemContext(itemContext))
|
||||
.build());
|
||||
}
|
||||
|
||||
public void advise(int serverHandle, int itemHandle) {
|
||||
adviseRaw(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
|
||||
.setAdvise(AdviseCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle))
|
||||
.build());
|
||||
}
|
||||
|
||||
public void write(int serverHandle, int itemHandle, MxValue value, int userId) {
|
||||
writeRaw(serverHandle, itemHandle, value, userId);
|
||||
}
|
||||
|
||||
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
|
||||
.setWrite(WriteCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle)
|
||||
.setValue(value)
|
||||
.setUserId(userId))
|
||||
.build());
|
||||
}
|
||||
|
||||
public void write2(int serverHandle, int itemHandle, MxValue value, MxValue timestampValue, int userId) {
|
||||
invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2)
|
||||
.setWrite2(Write2Command.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle)
|
||||
.setValue(value)
|
||||
.setTimestampValue(timestampValue)
|
||||
.setUserId(userId))
|
||||
.build());
|
||||
}
|
||||
|
||||
public MxEventStream streamEvents() {
|
||||
return streamEventsAfter(0);
|
||||
}
|
||||
|
||||
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||
return client.streamEvents(StreamEventsRequest.newBuilder()
|
||||
.setSessionId(sessionId())
|
||||
.setAfterWorkerSequence(afterWorkerSequence)
|
||||
.build());
|
||||
}
|
||||
|
||||
public MxCommandReply invokeCommand(MxCommand command) {
|
||||
return client.invoke(MxCommandRequest.newBuilder()
|
||||
.setSessionId(sessionId())
|
||||
.setClientCorrelationId(newCorrelationId())
|
||||
.setCommand(command)
|
||||
.build());
|
||||
}
|
||||
|
||||
private static String newCorrelationId() {
|
||||
byte[] bytes = new byte[16];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
|
||||
public final class MxGatewaySessionException extends MxGatewayException {
|
||||
private final ProtocolStatus protocolStatus;
|
||||
|
||||
public MxGatewaySessionException(String operation, ProtocolStatus protocolStatus) {
|
||||
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
|
||||
this.protocolStatus = protocolStatus;
|
||||
}
|
||||
|
||||
public ProtocolStatus protocolStatus() {
|
||||
return protocolStatus;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
|
||||
public final class MxGatewayWorkerException extends MxGatewayException {
|
||||
private final ProtocolStatus protocolStatus;
|
||||
|
||||
public MxGatewayWorkerException(String operation, ProtocolStatus protocolStatus) {
|
||||
super(MxGatewayErrors.protocolStatusMessage(operation, protocolStatus));
|
||||
this.protocolStatus = protocolStatus;
|
||||
}
|
||||
|
||||
public ProtocolStatus protocolStatus() {
|
||||
return protocolStatus;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusSource;
|
||||
|
||||
public final class MxStatuses {
|
||||
private MxStatuses() {
|
||||
}
|
||||
|
||||
public static boolean succeeded(MxStatusProxy status) {
|
||||
return status == null || status.getSuccess() != 0;
|
||||
}
|
||||
|
||||
public static MxStatusView view(MxStatusProxy status) {
|
||||
return new MxStatusView(status);
|
||||
}
|
||||
|
||||
public record MxStatusView(MxStatusProxy raw) {
|
||||
public int success() {
|
||||
return raw.getSuccess();
|
||||
}
|
||||
|
||||
public MxStatusCategory category() {
|
||||
return raw.getCategory();
|
||||
}
|
||||
|
||||
public MxStatusSource detectedBy() {
|
||||
return raw.getDetectedBy();
|
||||
}
|
||||
|
||||
public int detail() {
|
||||
return raw.getDetail();
|
||||
}
|
||||
|
||||
public int rawCategory() {
|
||||
return raw.getRawCategory();
|
||||
}
|
||||
|
||||
public int rawDetectedBy() {
|
||||
return raw.getRawDetectedBy();
|
||||
}
|
||||
|
||||
public String diagnosticText() {
|
||||
return raw.getDiagnosticText();
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BoolArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.DoubleArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.FloatArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Int32Array;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Int64Array;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RawArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StringArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.TimestampArray;
|
||||
|
||||
public final class MxValues {
|
||||
private MxValues() {
|
||||
}
|
||||
|
||||
public static MxValue boolValue(boolean value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_BOOLEAN)
|
||||
.setVariantType("VT_BOOL")
|
||||
.setBoolValue(value)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxValue int32Value(int value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
|
||||
.setVariantType("VT_I4")
|
||||
.setInt32Value(value)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxValue int64Value(long value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
|
||||
.setVariantType("VT_I8")
|
||||
.setInt64Value(value)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxValue floatValue(float value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_FLOAT)
|
||||
.setVariantType("VT_R4")
|
||||
.setFloatValue(value)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxValue doubleValue(double value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_DOUBLE)
|
||||
.setVariantType("VT_R8")
|
||||
.setDoubleValue(value)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxValue stringValue(String value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_STRING)
|
||||
.setVariantType("VT_BSTR")
|
||||
.setStringValue(value)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxValue timestampValue(Instant value) {
|
||||
return MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_TIME)
|
||||
.setVariantType("VT_DATE")
|
||||
.setTimestampValue(Timestamp.newBuilder()
|
||||
.setSeconds(value.getEpochSecond())
|
||||
.setNanos(value.getNano())
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static Object nativeValue(MxValue value) {
|
||||
if (value == null || value.getIsNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (value.getKindCase()) {
|
||||
case BOOL_VALUE -> value.getBoolValue();
|
||||
case INT32_VALUE -> value.getInt32Value();
|
||||
case INT64_VALUE -> value.getInt64Value();
|
||||
case FLOAT_VALUE -> value.getFloatValue();
|
||||
case DOUBLE_VALUE -> value.getDoubleValue();
|
||||
case STRING_VALUE -> value.getStringValue();
|
||||
case TIMESTAMP_VALUE -> instant(value.getTimestampValue());
|
||||
case ARRAY_VALUE -> nativeArray(value.getArrayValue());
|
||||
case RAW_VALUE -> value.getRawValue().toByteArray();
|
||||
case KIND_NOT_SET -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static Object nativeArray(MxArray array) {
|
||||
if (array == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (array.getValuesCase()) {
|
||||
case BOOL_VALUES -> List.copyOf(array.getBoolValues().getValuesList());
|
||||
case INT32_VALUES -> List.copyOf(array.getInt32Values().getValuesList());
|
||||
case INT64_VALUES -> List.copyOf(array.getInt64Values().getValuesList());
|
||||
case FLOAT_VALUES -> List.copyOf(array.getFloatValues().getValuesList());
|
||||
case DOUBLE_VALUES -> List.copyOf(array.getDoubleValues().getValuesList());
|
||||
case STRING_VALUES -> List.copyOf(array.getStringValues().getValuesList());
|
||||
case TIMESTAMP_VALUES -> timestampValues(array.getTimestampValues());
|
||||
case RAW_VALUES -> rawValues(array.getRawValues());
|
||||
case VALUES_NOT_SET -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
public static MxArray stringArray(List<String> values) {
|
||||
return MxArray.newBuilder()
|
||||
.setElementDataType(MxDataType.MX_DATA_TYPE_STRING)
|
||||
.setVariantType("VT_ARRAY|VT_BSTR")
|
||||
.addDimensions(values.size())
|
||||
.setStringValues(StringArray.newBuilder().addAllValues(values))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static MxArray int32Array(List<Integer> values) {
|
||||
return MxArray.newBuilder()
|
||||
.setElementDataType(MxDataType.MX_DATA_TYPE_INTEGER)
|
||||
.setVariantType("VT_ARRAY|VT_I4")
|
||||
.addDimensions(values.size())
|
||||
.setInt32Values(Int32Array.newBuilder().addAllValues(values))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static String kindName(MxValue value) {
|
||||
return value == null ? "KIND_NOT_SET" : value.getKindCase().name();
|
||||
}
|
||||
|
||||
private static Instant instant(Timestamp timestamp) {
|
||||
return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos());
|
||||
}
|
||||
|
||||
private static List<Instant> timestampValues(TimestampArray array) {
|
||||
List<Instant> values = new ArrayList<>();
|
||||
for (Timestamp timestamp : array.getValuesList()) {
|
||||
values.add(instant(timestamp));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static List<byte[]> rawValues(RawArray array) {
|
||||
List<byte[]> values = new ArrayList<>();
|
||||
for (ByteString rawValue : array.getValuesList()) {
|
||||
values.add(rawValue.toByteArray());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static void generatedTypeReferences(
|
||||
BoolArray boolArray,
|
||||
Int64Array int64Array,
|
||||
FloatArray floatArray,
|
||||
DoubleArray doubleArray) {
|
||||
// Keeps generated repeated-value imports visible for javadocs and IDE navigation.
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.Context;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.ServerCallStreamObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class MxGatewayClientSessionTests {
|
||||
@Test
|
||||
void unaryCallsCarryAuthMetadataAndDeadline() throws Exception {
|
||||
AtomicReference<String> authorization = new AtomicReference<>();
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
AtomicReference<Boolean> deadlineSeen = new AtomicReference<>(false);
|
||||
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
||||
deadlineSeen.set(Context.current().getDeadline() != null);
|
||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||
.setSessionId("session-java")
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, authorization);
|
||||
MxGatewayClient client = gateway.client("mxgw_visible_secret", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = client.openSession("junit-session");
|
||||
|
||||
int serverHandle = session.register("java-test-client");
|
||||
|
||||
assertEquals(42, serverHandle);
|
||||
assertEquals("Bearer mxgw_visible_secret", authorization.get());
|
||||
assertEquals("session-java", commandRequest.get().getSessionId());
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_REGISTER, commandRequest.get().getCommand().getKind());
|
||||
assertTrue(deadlineSeen.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void methodHelpersReturnTypedHandlesAndRawReplies() throws Exception {
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
MxCommandReply.Builder reply = MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok());
|
||||
if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ADD_ITEM) {
|
||||
reply.setAddItem(AddItemReply.newBuilder().setItemHandle(7));
|
||||
}
|
||||
responseObserver.onNext(reply.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
|
||||
|
||||
int itemHandle = session.addItem(12, "TestObject.TestInt");
|
||||
MxCommandReply raw = session.adviseRaw(12, itemHandle);
|
||||
|
||||
assertEquals(7, itemHandle);
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_ADVISE, raw.getKind());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamCancellationCancelsServerCall() throws Exception {
|
||||
CountDownLatch cancelled = new CountDownLatch(1);
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
|
||||
ServerCallStreamObserver<MxEvent> serverObserver =
|
||||
(ServerCallStreamObserver<MxEvent>) responseObserver;
|
||||
serverObserver.setOnCancelHandler(cancelled::countDown);
|
||||
responseObserver.onNext(MxEvent.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setWorkerSequence(1)
|
||||
.build());
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxEventStream events = MxGatewaySession.forSessionId(client, "stream-session").streamEvents();
|
||||
|
||||
assertTrue(events.hasNext());
|
||||
assertEquals(1, events.next().getWorkerSequence());
|
||||
events.close();
|
||||
|
||||
assertTrue(cancelled.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandFailureKeepsRawReply() throws Exception {
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("MXAccess rejected the write."))
|
||||
.setHresult(-2147220992)
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "failure-session");
|
||||
|
||||
MxAccessException error = assertThrows(
|
||||
MxAccessException.class,
|
||||
() -> session.write(1, 2, MxValues.int32Value(123), 0));
|
||||
|
||||
assertNotNull(error.reply());
|
||||
assertEquals(-2147220992, error.reply().getHresult());
|
||||
}
|
||||
}
|
||||
|
||||
private static ProtocolStatus ok() {
|
||||
return ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
.build();
|
||||
}
|
||||
|
||||
private abstract static class TestGatewayService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||
@Override
|
||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||
.setSessionId("session-java")
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
||||
responseObserver.onNext(CloseSessionReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
private record InProcessGateway(Server server, ManagedChannel channel) implements AutoCloseable {
|
||||
static InProcessGateway start(
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service, AtomicReference<String> authorization)
|
||||
throws Exception {
|
||||
String serverName = "mxgw-java-" + UUID.randomUUID();
|
||||
ServerInterceptor interceptor = new ServerInterceptor() {
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
|
||||
ServerCall<ReqT, RespT> call,
|
||||
Metadata headers,
|
||||
ServerCallHandler<ReqT, RespT> next) {
|
||||
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(serverName)
|
||||
.directExecutor()
|
||||
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
|
||||
.build()
|
||||
.start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(serverName)
|
||||
.directExecutor()
|
||||
.build();
|
||||
return new InProcessGateway(server, channel);
|
||||
}
|
||||
|
||||
MxGatewayClient client(String apiKey, Duration callTimeout) {
|
||||
return new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.apiKey(apiKey)
|
||||
.plaintext(true)
|
||||
.callTimeout(callTimeout)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.protobuf.util.JsonFormat;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class MxGatewayFixtureTests {
|
||||
@Test
|
||||
void valueFixtureCasesExposeNativeProjectionAndRawMetadata() throws Exception {
|
||||
JsonArray cases = readFixture("values/value-conversion-cases.json").getAsJsonArray("cases");
|
||||
|
||||
for (var element : cases) {
|
||||
JsonObject testCase = element.getAsJsonObject();
|
||||
MxValue.Builder builder = MxValue.newBuilder();
|
||||
JsonFormat.parser().merge(testCase.getAsJsonObject("value").toString(), builder);
|
||||
MxValue value = builder.build();
|
||||
|
||||
assertEquals(testCase.get("expectedKind").getAsString(), lowerCamelKind(value));
|
||||
if ("timestamp.utc".equals(testCase.get("id").getAsString())) {
|
||||
assertEquals(Instant.parse("2026-01-01T00:00:04Z"), MxValues.nativeValue(value));
|
||||
}
|
||||
if ("string-array".equals(testCase.get("id").getAsString())) {
|
||||
assertEquals(List.of("alpha", "beta"), MxValues.nativeValue(value));
|
||||
}
|
||||
if ("raw-fallback.variant".equals(testCase.get("id").getAsString())) {
|
||||
assertEquals("No lossless typed projection exists for this VARIANT.", value.getRawDiagnostic());
|
||||
assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, (byte[]) MxValues.nativeValue(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusFixtureCasesPreserveRawFields() throws Exception {
|
||||
JsonArray cases = readFixture("statuses/status-conversion-cases.json").getAsJsonArray("cases");
|
||||
|
||||
for (var element : cases) {
|
||||
JsonObject testCase = element.getAsJsonObject();
|
||||
MxStatusProxy.Builder builder = MxStatusProxy.newBuilder();
|
||||
JsonFormat.parser().merge(testCase.getAsJsonObject("status").toString(), builder);
|
||||
MxStatusProxy status = builder.build();
|
||||
|
||||
assertEquals(
|
||||
testCase.getAsJsonObject("status").get("rawCategory").getAsInt(),
|
||||
status.getRawCategory());
|
||||
if ("ok.responding-lmx".equals(testCase.get("id").getAsString())) {
|
||||
assertTrue(MxStatuses.succeeded(status));
|
||||
}
|
||||
if ("security-error.requesting-lmx".equals(testCase.get("id").getAsString())) {
|
||||
assertFalse(MxStatuses.succeeded(status));
|
||||
assertEquals(MxStatusCategory.MX_STATUS_CATEGORY_SECURITY_ERROR, MxStatuses.view(status).category());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mxAccessFailureFixtureMapsToRichCommandException() throws Exception {
|
||||
MxCommandReply.Builder builder = MxCommandReply.newBuilder();
|
||||
JsonFormat.parser().merge(
|
||||
Files.readString(fixtureRoot().resolve("command-replies/write.mxaccess-failure.reply.json")),
|
||||
builder);
|
||||
MxCommandReply reply = builder.build();
|
||||
|
||||
try {
|
||||
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
|
||||
} catch (MxAccessException error) {
|
||||
assertEquals(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, error.protocolStatus().getCode());
|
||||
assertEquals(-2147220992, error.reply().getHresult());
|
||||
assertEquals(2, error.reply().getStatusesCount());
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AssertionError("expected MxAccessException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grpcAuthErrorsAreClassifiedAndRedacted() {
|
||||
RuntimeException authError = MxGatewayErrors.fromGrpc(
|
||||
"open session",
|
||||
new io.grpc.StatusRuntimeException(io.grpc.Status.UNAUTHENTICATED.withDescription(
|
||||
"invalid API key mxgw_visible_secret")));
|
||||
RuntimeException permissionError = MxGatewayErrors.fromGrpc(
|
||||
"write",
|
||||
new io.grpc.StatusRuntimeException(io.grpc.Status.PERMISSION_DENIED.withDescription(
|
||||
"missing scope mxaccess.write")));
|
||||
|
||||
assertInstanceOf(MxGatewayAuthenticationException.class, authError);
|
||||
assertInstanceOf(MxGatewayAuthorizationException.class, permissionError);
|
||||
assertTrue(authError.getMessage().contains("<redacted>"));
|
||||
assertFalse(authError.getMessage().contains("visible_secret"));
|
||||
}
|
||||
|
||||
private static JsonObject readFixture(String relativePath) throws Exception {
|
||||
return JsonParser.parseString(Files.readString(fixtureRoot().resolve(relativePath))).getAsJsonObject();
|
||||
}
|
||||
|
||||
private static Path fixtureRoot() {
|
||||
Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
|
||||
for (Path path = current; path != null; path = path.getParent()) {
|
||||
Path candidate = path.resolve("clients/proto/fixtures/behavior");
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = path.resolve("../proto/fixtures/behavior").normalize();
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("could not locate behavior fixtures from " + current);
|
||||
}
|
||||
|
||||
private static String lowerCamelKind(MxValue value) {
|
||||
String[] parts = value.getKindCase().name().toLowerCase().split("_");
|
||||
StringBuilder result = new StringBuilder(parts[0]);
|
||||
for (int index = 1; index < parts.length; index++) {
|
||||
result.append(Character.toUpperCase(parts[index].charAt(0))).append(parts[index].substring(1));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user