From 90529dce6e830728e324a0061982726a2438c2e0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 09:41:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(go):=20add=20ping=20CLI=20subcommand=20(?= =?UTF-8?q?=C2=A74.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PingRaw to Session (session.go), runPing to the CLI dispatch (main.go), and three tests covering plain-text echo, JSON output, and missing-session-id validation (main_test.go). Default message is "ping"; gateway echo is read from DiagnosticMessage, falling back to the kind string if absent. --- clients/go/cmd/mxgw-go/main.go | 45 +++++++++++++- clients/go/cmd/mxgw-go/main_test.go | 96 +++++++++++++++++++++++++++++ clients/go/mxgateway/session.go | 11 ++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index cfab46c..768ea8c 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -121,6 +121,8 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err return runGalaxyDiscover(ctx, args[1:], stdout, stderr) case "galaxy-watch": return runGalaxyWatch(ctx, args[1:], stdout, stderr) + case "ping": + return runPing(ctx, args[1:], stdout, stderr) case "batch": return runBatch(ctx, os.Stdin, stdout, stderr) default: @@ -228,6 +230,47 @@ func runCloseSession(ctx context.Context, args []string, stdout, stderr io.Write return nil } +func runPing(ctx context.Context, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("ping", flag.ContinueOnError) + flags.SetOutput(stderr) + common := bindCommonFlags(flags) + jsonOutput := flags.Bool("json", false, "write JSON output") + sessionID := flags.String("session-id", "", "gateway session id") + message := flags.String("message", "ping", "ping payload message") + + if err := flags.Parse(args); err != nil { + return err + } + if *sessionID == "" { + return errors.New("session-id is required") + } + + client, options, err := dialForCommand(ctx, common) + if err != nil { + return err + } + defer client.Close() + + session := mxgateway.NewSessionForID(client, *sessionID) + reply, err := session.PingRaw(ctx, *message) + if err != nil { + return err + } + if *jsonOutput { + return writeJSON(stdout, commandReplyOutput{ + Command: "ping", + Options: options, + Reply: mustMarshalProto(reply), + }) + } + echo := reply.GetDiagnosticMessage() + if echo == "" { + echo = reply.GetKind().String() + } + fmt.Fprintln(stdout, echo) + return nil +} + func runRegister(ctx context.Context, args []string, stdout, stderr io.Writer) error { flags := flag.NewFlagSet("register", flag.ContinueOnError) flags.SetOutput(stderr) @@ -1196,7 +1239,7 @@ type protojsonMessage interface { } func writeUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: mxgw-go ") + fmt.Fprintln(writer, "usage: mxgw-go ") } // batchEOR is the end-of-result sentinel emitted to stdout after every command diff --git a/clients/go/cmd/mxgw-go/main_test.go b/clients/go/cmd/mxgw-go/main_test.go index 67551dc..4e02a39 100644 --- a/clients/go/cmd/mxgw-go/main_test.go +++ b/clients/go/cmd/mxgw-go/main_test.go @@ -190,6 +190,102 @@ func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) { } } +// TestRunPingPlainText verifies the ping subcommand round-trips through the +// fake gateway and prints the echo (diagnostic_message) in plain-text mode. +func TestRunPingPlainText(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := grpc.NewServer() + fake := &pingFakeGateway{} + pb.RegisterMxAccessGatewayServer(server, fake) + go func() { _ = server.Serve(listener) }() + defer server.Stop() + defer listener.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "ping", + "-endpoint", listener.Addr().String(), + "-plaintext", + "-api-key", "test", + "-session-id", "test-session", + "-message", "hello", + } + if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil { + t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String()) + } + got := strings.TrimSpace(stdout.String()) + if got != "pong:hello" { + t.Fatalf("ping plain-text output = %q, want %q", got, "pong:hello") + } +} + +// TestRunPingJSON verifies the ping subcommand emits valid JSON in --json mode. +func TestRunPingJSON(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := grpc.NewServer() + fake := &pingFakeGateway{} + pb.RegisterMxAccessGatewayServer(server, fake) + go func() { _ = server.Serve(listener) }() + defer server.Stop() + defer listener.Close() + + var stdout, stderr bytes.Buffer + args := []string{ + "ping", + "-endpoint", listener.Addr().String(), + "-plaintext", + "-api-key", "test", + "-session-id", "test-session", + "-message", "hello", + "-json", + } + if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil { + t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String()) + } + var out commandReplyOutput + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String()) + } + if out.Command != "ping" { + t.Fatalf("command = %q, want %q", out.Command, "ping") + } +} + +// TestRunPingRequiresSessionID verifies the ping subcommand rejects missing session-id. +func TestRunPingRequiresSessionID(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runWithIO(t.Context(), []string{"ping", "-plaintext", "-api-key", "test"}, &stdout, &stderr) + if err == nil { + t.Fatalf("runWithIO(ping without --session-id) returned no error") + } + if !strings.Contains(err.Error(), "session-id is required") { + t.Fatalf("error = %v; want 'session-id is required'", err) + } +} + +// pingFakeGateway handles Invoke for MX_COMMAND_KIND_PING by echoing the +// message back in the diagnostic_message field so the CLI plain-text path +// has a deterministic, non-empty string to assert on. +type pingFakeGateway struct { + pb.UnimplementedMxAccessGatewayServer +} + +func (g *pingFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) { + echo := "pong:" + req.GetCommand().GetPing().GetMessage() + return &pb.MxCommandReply{ + SessionId: req.GetSessionId(), + Kind: pb.MxCommandKind_MX_COMMAND_KIND_PING, + DiagnosticMessage: echo, + ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK}, + }, nil +} + // benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the // bench-read-bulk session-setup sequence (OpenSession + Invoke for Register // / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession). diff --git a/clients/go/mxgateway/session.go b/clients/go/mxgateway/session.go index e2b9a46..6220acd 100644 --- a/clients/go/mxgateway/session.go +++ b/clients/go/mxgateway/session.go @@ -580,6 +580,17 @@ func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32, }) } +// PingRaw sends a diagnostic PING command and returns the raw reply. +// The message is echoed back by the gateway in the reply's DiagnosticMessage field. +func (s *Session) PingRaw(ctx context.Context, message string) (*MxCommandReply, error) { + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_PING, + Payload: &pb.MxCommand_Ping{ + Ping: &pb.PingCommand{Message: message}, + }, + }) +} + // Write2 invokes MXAccess Write2 (timestamped single-item write). func (s *Session) Write2(ctx context.Context, serverHandle, itemHandle int32, value, timestampValue *MxValue, userID int32) error { _, err := s.Write2Raw(ctx, serverHandle, itemHandle, value, timestampValue, userID)