Improve gateway reliability and client e2e coverage
This commit is contained in:
@@ -86,6 +86,10 @@ public static class MxGatewayClientCli
|
||||
.ConfigureAwait(false),
|
||||
"advise" => await AdviseAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
@@ -289,6 +293,54 @@ public static class MxGatewayClientCli
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> SubscribeBulkAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SubscribeBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
};
|
||||
command.TagAddresses.Add(ParseStringList(arguments.GetRequired("items")));
|
||||
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SubscribeBulk,
|
||||
SubscribeBulk = command,
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> UnsubscribeBulkAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UnsubscribeBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
};
|
||||
command.ItemHandles.Add(ParseInt32List(arguments.GetRequired("item-handles")));
|
||||
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.UnsubscribeBulk,
|
||||
UnsubscribeBulk = command,
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> WriteAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
@@ -736,12 +788,40 @@ public static class MxGatewayClientCli
|
||||
or "register"
|
||||
or "add-item"
|
||||
or "advise"
|
||||
or "subscribe-bulk"
|
||||
or "unsubscribe-bulk"
|
||||
or "stream-events"
|
||||
or "write"
|
||||
or "write2"
|
||||
or "smoke";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseStringList(string value)
|
||||
{
|
||||
string[] items = value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (items.Length is 0)
|
||||
{
|
||||
throw new ArgumentException("At least one item is required.");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> ParseInt32List(string value)
|
||||
{
|
||||
string[] items = value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (items.Length is 0)
|
||||
{
|
||||
throw new ArgumentException("At least one item handle is required.");
|
||||
}
|
||||
|
||||
return items
|
||||
.Select(item => int.Parse(item, CultureInfo.InvariantCulture))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string CreateCorrelationId()
|
||||
{
|
||||
return Guid.NewGuid().ToString("N");
|
||||
@@ -756,6 +836,8 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet register --session-id <id> --client-name <name> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||
|
||||
@@ -76,10 +76,13 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||
```
|
||||
|
||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Raw protobuf
|
||||
messages remain available through the `mxgateway` package aliases and the
|
||||
`Raw` helper methods. Typed errors support `errors.As` for `GatewayError`,
|
||||
`CommandError`, and `MxAccessError`; command errors preserve the raw reply.
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||
goroutine cleanup. Raw protobuf messages remain available through the
|
||||
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||
errors preserve the raw reply.
|
||||
|
||||
## CLI
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
@@ -77,6 +78,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
return runAddItem(ctx, args[1:], stdout, stderr)
|
||||
case "advise":
|
||||
return runAdvise(ctx, args[1:], stdout, stderr)
|
||||
case "subscribe-bulk":
|
||||
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||
case "unsubscribe-bulk":
|
||||
return runUnsubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||
case "write":
|
||||
return runWrite(ctx, args[1:], stdout, stderr)
|
||||
case "stream-events":
|
||||
@@ -268,6 +273,60 @@ func runAdvise(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
return writeCommandOutput(stdout, *jsonOutput, "advise", options, reply, err)
|
||||
}
|
||||
|
||||
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
items := flags.String("items", "", "comma-separated item definitions")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *sessionID == "" || *items == "" {
|
||||
return errors.New("session-id and items are required")
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
results, err := session.SubscribeBulk(ctx, int32(*serverHandle), parseStringList(*items))
|
||||
return writeBulkOutput(stdout, *jsonOutput, "subscribe-bulk", options, results, err)
|
||||
}
|
||||
|
||||
func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("unsubscribe-bulk", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *sessionID == "" || *itemHandles == "" {
|
||||
return errors.New("session-id and item-handles are required")
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), parseInt32List(*itemHandles))
|
||||
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
||||
}
|
||||
|
||||
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -328,10 +387,12 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
events, err := session.EventsAfter(streamCtx, *after)
|
||||
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer subscription.Close()
|
||||
events := subscription.Events()
|
||||
|
||||
count := 0
|
||||
for result := range events {
|
||||
@@ -426,6 +487,35 @@ func closeSmokeSession(ctx context.Context, session *mxgateway.Session, primaryE
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func parseStringList(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
item := strings.TrimSpace(part)
|
||||
if item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func parseInt32List(value string) []int32 {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]int32, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
item := strings.TrimSpace(part)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.ParseInt(item, 10, 32)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, int32(parsed))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||
common := &commonOptions{}
|
||||
flags.StringVar(&common.Endpoint, "endpoint", "localhost:5000", "gateway endpoint")
|
||||
@@ -527,6 +617,21 @@ func writeCommandOutput(stdout io.Writer, jsonOutput bool, command string, optio
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.SubscribeResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return writeJSON(stdout, map[string]any{
|
||||
"command": command,
|
||||
"options": options,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
fmt.Fprintln(stdout, len(results))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(writer io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetIndent("", " ")
|
||||
@@ -546,5 +651,5 @@ type protojsonMessage interface {
|
||||
}
|
||||
|
||||
func writeUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|write|stream-events|smoke>")
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke>")
|
||||
}
|
||||
|
||||
@@ -77,6 +77,42 @@ func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscriptionCloseStopsStream(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
streamDone: make(chan struct{}),
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
subscription, err := session.SubscribeEvents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("SubscribeEvents() error = %v", err)
|
||||
}
|
||||
<-fake.streamStarted
|
||||
first := <-subscription.Events()
|
||||
if first.Err != nil {
|
||||
t.Fatalf("first event error = %v", first.Err)
|
||||
}
|
||||
|
||||
subscription.Close()
|
||||
|
||||
select {
|
||||
case <-fake.streamDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("event stream did not stop after subscription close")
|
||||
}
|
||||
select {
|
||||
case _, ok := <-subscription.Events():
|
||||
if ok {
|
||||
t.Fatal("subscription channel remained open after close")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("subscription channel did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
@@ -235,6 +271,7 @@ type fakeGatewayServer struct {
|
||||
openAuth string
|
||||
streamAuth string
|
||||
streamStarted chan struct{}
|
||||
streamDone chan struct{}
|
||||
invokeReply *pb.MxCommandReply
|
||||
invokeRequest *pb.MxCommandRequest
|
||||
}
|
||||
@@ -277,6 +314,9 @@ func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest
|
||||
|
||||
func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grpc.ServerStreamingServer[pb.MxEvent]) error {
|
||||
s.streamAuth = authorizationFromContext(stream.Context())
|
||||
if s.streamDone != nil {
|
||||
defer close(s.streamDone)
|
||||
}
|
||||
if s.streamStarted != nil {
|
||||
close(s.streamStarted)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,30 @@ type EventResult struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// EventSubscription owns a running gateway event stream.
|
||||
type EventSubscription struct {
|
||||
results <-chan EventResult
|
||||
cancel context.CancelFunc
|
||||
done <-chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Events returns the stream results channel.
|
||||
func (s *EventSubscription) Events() <-chan EventResult {
|
||||
return s.results
|
||||
}
|
||||
|
||||
// Close cancels the stream and waits for the receive goroutine to stop.
|
||||
func (s *EventSubscription) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.once.Do(func() {
|
||||
s.cancel()
|
||||
<-s.done
|
||||
})
|
||||
}
|
||||
|
||||
// Session represents one gateway-backed MXAccess session.
|
||||
type Session struct {
|
||||
client *Client
|
||||
@@ -394,34 +418,56 @@ func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
|
||||
|
||||
// EventsAfter streams ordered session events after the given worker sequence.
|
||||
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
|
||||
stream, err := s.client.StreamEventsRaw(ctx, &pb.StreamEventsRequest{
|
||||
subscription, err := s.SubscribeEventsAfter(ctx, afterWorkerSequence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return subscription.Events(), nil
|
||||
}
|
||||
|
||||
// SubscribeEvents starts an owned event subscription.
|
||||
func (s *Session) SubscribeEvents(ctx context.Context) (*EventSubscription, error) {
|
||||
return s.SubscribeEventsAfter(ctx, 0)
|
||||
}
|
||||
|
||||
// SubscribeEventsAfter starts an owned event subscription after the given worker sequence.
|
||||
func (s *Session) SubscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64) (*EventSubscription, error) {
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
stream, err := s.client.StreamEventsRaw(streamCtx, &pb.StreamEventsRequest{
|
||||
SessionId: s.ID(),
|
||||
AfterWorkerSequence: afterWorkerSequence,
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make(chan EventResult, 16)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(results)
|
||||
defer close(done)
|
||||
for {
|
||||
event, err := stream.Recv()
|
||||
if err == nil {
|
||||
if !sendEventResult(ctx, results, EventResult{Event: event}) {
|
||||
if !sendEventResult(streamCtx, results, EventResult{Event: event}) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == io.EOF || status.Code(err) == codes.Canceled || ctx.Err() != nil {
|
||||
if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
sendEventResult(ctx, results, EventResult{Err: &GatewayError{Op: "stream events", Err: err}})
|
||||
sendEventResult(streamCtx, results, EventResult{Err: &GatewayError{Op: "stream events", Err: err}})
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return results, nil
|
||||
return &EventSubscription{
|
||||
results: results,
|
||||
cancel: cancel,
|
||||
done: done,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureBulkSize(name string, length int) error {
|
||||
|
||||
+120
@@ -12,7 +12,9 @@ import com.google.protobuf.util.JsonFormat;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
@@ -20,6 +22,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
import picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
@@ -75,6 +78,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
|
||||
commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
|
||||
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
|
||||
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
|
||||
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
|
||||
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
|
||||
@@ -243,6 +248,58 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
|
||||
static final class SubscribeBulkCommand extends GatewayCommand {
|
||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||
String sessionId;
|
||||
|
||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
||||
int serverHandle;
|
||||
|
||||
@Option(names = "--items", required = true, description = "Comma-separated item definitions.")
|
||||
String items;
|
||||
|
||||
SubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<SubscribeResult> results =
|
||||
client.session(sessionId).subscribeBulk(serverHandle, parseStringList(items));
|
||||
writeBulkOutput("subscribe-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "unsubscribe-bulk", description = "Invokes MXAccess UnsubscribeBulk.")
|
||||
static final class UnsubscribeBulkCommand extends GatewayCommand {
|
||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||
String sessionId;
|
||||
|
||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
||||
int serverHandle;
|
||||
|
||||
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
|
||||
String itemHandles;
|
||||
|
||||
UnsubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<SubscribeResult> results =
|
||||
client.session(sessionId).unsubscribeBulk(serverHandle, parseIntList(itemHandles));
|
||||
writeBulkOutput("unsubscribe-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "write", description = "Invokes MXAccess Write.")
|
||||
static final class WriteCommand extends GatewayCommand {
|
||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||
@@ -454,6 +511,10 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
|
||||
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
|
||||
|
||||
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
|
||||
|
||||
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
||||
|
||||
MxEventStream streamEventsAfter(long afterWorkerSequence);
|
||||
}
|
||||
|
||||
@@ -535,6 +596,16 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return session.writeRaw(serverHandle, itemHandle, value, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
||||
return session.subscribeBulk(serverHandle, items);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
|
||||
return session.unsubscribeBulk(serverHandle, itemHandles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||
return session.streamEventsAfter(afterWorkerSequence);
|
||||
@@ -559,6 +630,30 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
out.println(textSupplier.get());
|
||||
}
|
||||
|
||||
private static void writeBulkOutput(
|
||||
String command, CommonOptions common, boolean json, List<SubscribeResult> results) {
|
||||
PrintWriter out = common.spec.commandLine().getOut();
|
||||
if (json) {
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("command", command);
|
||||
output.put("options", common.redactedJsonMap());
|
||||
output.put("results", results.stream().map(MxGatewayCli::subscribeResultMap).toList());
|
||||
out.println(jsonObject(output));
|
||||
return;
|
||||
}
|
||||
out.println(results.size());
|
||||
}
|
||||
|
||||
private static Map<String, Object> subscribeResultMap(SubscribeResult result) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
values.put("serverHandle", result.getServerHandle());
|
||||
values.put("tagAddress", result.getTagAddress());
|
||||
values.put("itemHandle", result.getItemHandle());
|
||||
values.put("wasSuccessful", result.getWasSuccessful());
|
||||
values.put("errorMessage", result.getErrorMessage());
|
||||
return values;
|
||||
}
|
||||
|
||||
private static MxValue parseValue(String type, String text) {
|
||||
return switch (type) {
|
||||
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
|
||||
@@ -571,6 +666,17 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
};
|
||||
}
|
||||
|
||||
private static List<String> parseStringList(String value) {
|
||||
return Arrays.stream(value.split(","))
|
||||
.map(String::trim)
|
||||
.filter(item -> !item.isBlank())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static List<Integer> parseIntList(String value) {
|
||||
return parseStringList(value).stream().map(Integer::parseInt).toList();
|
||||
}
|
||||
|
||||
private static Duration parseDuration(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Duration.ofSeconds(30);
|
||||
@@ -630,6 +736,20 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return jsonObject((Map<String, Object>) map);
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append('[');
|
||||
boolean first = true;
|
||||
for (Object item : iterable) {
|
||||
if (!first) {
|
||||
builder.append(',');
|
||||
}
|
||||
first = false;
|
||||
builder.append(jsonValue(item));
|
||||
}
|
||||
builder.append(']');
|
||||
return builder.toString();
|
||||
}
|
||||
return jsonString(value.toString());
|
||||
}
|
||||
|
||||
|
||||
+68
@@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
@@ -19,6 +21,7 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class MxGatewayCliTests {
|
||||
@@ -100,6 +103,44 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"itemHandle\":7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeBulkCommandPrintsResults() {
|
||||
CliRun run = execute(
|
||||
new FakeClientFactory(),
|
||||
"subscribe-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--items",
|
||||
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"command\":\"subscribe-bulk\""));
|
||||
assertTrue(run.output().contains("\"itemHandle\":100"));
|
||||
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsubscribeBulkCommandPrintsResults() {
|
||||
CliRun run = execute(
|
||||
new FakeClientFactory(),
|
||||
"unsubscribe-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--item-handles",
|
||||
"100,101",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"command\":\"unsubscribe-bulk\""));
|
||||
assertTrue(run.output().contains("\"itemHandle\":101"));
|
||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||
}
|
||||
|
||||
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
|
||||
StringWriter output = new StringWriter();
|
||||
StringWriter errors = new StringWriter();
|
||||
@@ -227,6 +268,33 @@ final class MxGatewayCliTests {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
||||
List<SubscribeResult> results = new ArrayList<>();
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
results.add(SubscribeResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setTagAddress(items.get(index))
|
||||
.setItemHandle(100 + index)
|
||||
.setWasSuccessful(true)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
|
||||
List<SubscribeResult> results = new ArrayList<>();
|
||||
for (Integer itemHandle : itemHandles) {
|
||||
results.add(SubscribeResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle)
|
||||
.setWasSuccessful(true)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
||||
|
||||
@@ -150,6 +150,40 @@ def advise(**kwargs: Any) -> None:
|
||||
_run(_advise(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("subscribe-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--items", required=True, help="Comma-separated MXAccess item definitions.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def subscribe_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess SubscribeBulk."""
|
||||
|
||||
_run(
|
||||
_subscribe_bulk(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command("unsubscribe-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def unsubscribe_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess UnsubscribeBulk."""
|
||||
|
||||
_run(
|
||||
_unsubscribe_bulk(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command("stream-events")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -282,6 +316,28 @@ async def _advise(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.subscribe_bulk(
|
||||
kwargs["server_handle"],
|
||||
_parse_string_list(kwargs["items"]),
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _unsubscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.unsubscribe_bulk(
|
||||
kwargs["server_handle"],
|
||||
_parse_int_list(kwargs["item_handles"]),
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
@@ -470,6 +526,20 @@ def _parse_datetime(raw_value: str) -> datetime:
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_string_list(raw_value: str) -> list[str]:
|
||||
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if not values:
|
||||
raise click.BadParameter("at least one item is required", param_hint="--items")
|
||||
return values
|
||||
|
||||
|
||||
def _parse_int_list(raw_value: str) -> list[int]:
|
||||
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if not values:
|
||||
raise click.BadParameter("at least one item handle is required", param_hint="--item-handles")
|
||||
return [int(item) for item in values]
|
||||
|
||||
|
||||
def _message_dict(message: Any) -> dict[str, Any]:
|
||||
return MessageToDict(
|
||||
message,
|
||||
|
||||
@@ -92,6 +92,30 @@ enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
SubscribeBulk {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
items: Vec<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
UnsubscribeBulk {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
item_handles: Vec<i32>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
StreamEvents {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
@@ -103,6 +127,8 @@ enum Command {
|
||||
max_events: usize,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg(long)]
|
||||
jsonl: bool,
|
||||
},
|
||||
Write {
|
||||
#[command(flatten)]
|
||||
@@ -226,7 +252,7 @@ async fn main() -> ExitCode {
|
||||
|
||||
async fn run(cli: Cli) -> Result<(), Error> {
|
||||
match cli.command {
|
||||
Command::Version { json } => print_version(json),
|
||||
Command::Version { json, .. } => print_version(json),
|
||||
Command::Ping {
|
||||
connection,
|
||||
message,
|
||||
@@ -323,6 +349,30 @@ async fn run(cli: Cli) -> Result<(), Error> {
|
||||
session.advise(server_handle, item_handle).await?;
|
||||
print_ok("advise", json);
|
||||
}
|
||||
Command::SubscribeBulk {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
items,
|
||||
json,
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
let results = session.subscribe_bulk(server_handle, items).await?;
|
||||
print_bulk_results("subscribe-bulk", &results, json);
|
||||
}
|
||||
Command::UnsubscribeBulk {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
item_handles,
|
||||
json,
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
let results = session
|
||||
.unsubscribe_bulk(server_handle, item_handles)
|
||||
.await?;
|
||||
print_bulk_results("unsubscribe-bulk", &results, json);
|
||||
}
|
||||
Command::StreamEvents {
|
||||
connection,
|
||||
session_id,
|
||||
@@ -527,6 +577,33 @@ fn print_ok(operation: &str, use_json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_bulk_results(
|
||||
operation: &str,
|
||||
results: &[mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
|
||||
use_json: bool,
|
||||
) {
|
||||
if use_json {
|
||||
let results_json: Vec<_> = results
|
||||
.iter()
|
||||
.map(|result| {
|
||||
json!({
|
||||
"serverHandle": result.server_handle,
|
||||
"tagAddress": result.tag_address,
|
||||
"itemHandle": result.item_handle,
|
||||
"wasSuccessful": result.was_successful,
|
||||
"errorMessage": result.error_message,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
json!({ "operation": operation, "results": results_json })
|
||||
);
|
||||
} else {
|
||||
println!("{}", results.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error> {
|
||||
let parsed = match value_type {
|
||||
CliValueType::Bool => MxValue::bool(parse_cli_value(value)?),
|
||||
|
||||
Reference in New Issue
Block a user