From ebdf9863cbec3bc7a4ebf3da496392422b5f1c1b Mon Sep 17 00:00:00 2001
From: "maksim.konovalov" <maksim.konovalov@vk.team>
Date: Tue, 10 Dec 2024 11:10:04 +0300
Subject: [PATCH] box: first box and box.info implementation

Implemented the box interface for tarantool with a small number of fields, which in the future can be supplemented.
---
 CHANGELOG.md            |  1 +
 box/box.go              | 36 +++++++++++++++++
 box/box_test.go         | 29 ++++++++++++++
 box/example_test.go     | 60 ++++++++++++++++++++++++++++
 box/info.go             | 76 ++++++++++++++++++++++++++++++++++++
 box/request.go          | 38 ++++++++++++++++++
 box/tarantool_test.go   | 86 +++++++++++++++++++++++++++++++++++++++++
 box/testdata/config.lua | 13 +++++++
 8 files changed, 339 insertions(+)
 create mode 100644 box/box.go
 create mode 100644 box/box_test.go
 create mode 100644 box/example_test.go
 create mode 100644 box/info.go
 create mode 100644 box/request.go
 create mode 100644 box/tarantool_test.go
 create mode 100644 box/testdata/config.lua

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0eb0f9c9..4ae4da136 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release.
 - Methods that are implemented but not included in the pooler interface (#395).
 - Implemented stringer methods for pool.Role (#405).
 - Support the IPROTO_INSERT_ARROW request (#399).
+- A simple implementation of using the box interface was written (#410).
 
 ### Changed
 
diff --git a/box/box.go b/box/box.go
new file mode 100644
index 000000000..be7f288ad
--- /dev/null
+++ b/box/box.go
@@ -0,0 +1,36 @@
+package box
+
+import (
+	"github.com/tarantool/go-tarantool/v2"
+)
+
+// Box is a helper that wraps box.* requests.
+// It holds a connection to the Tarantool instance via the Doer interface.
+type Box struct {
+	conn tarantool.Doer // Connection interface for interacting with Tarantool.
+}
+
+// New returns a new instance of the box structure, which implements the Box interface.
+func New(conn tarantool.Doer) *Box {
+	return &Box{
+		conn: conn, // Assigns the provided Tarantool connection.
+	}
+}
+
+// Info retrieves the current information of the Tarantool instance.
+// It calls the "box.info" function and parses the result into the Info structure.
+func (b *Box) Info() (Info, error) {
+	var infoResp InfoResponse
+
+	// Call "box.info" to get instance information from Tarantool.
+	fut := b.conn.Do(NewInfoRequest())
+
+	// Parse the result into the Info structure.
+	err := fut.GetTyped(&infoResp)
+	if err != nil {
+		return Info{}, err
+	}
+
+	// Return the parsed info and any potential error.
+	return infoResp.Info, err
+}
diff --git a/box/box_test.go b/box/box_test.go
new file mode 100644
index 000000000..31e614c15
--- /dev/null
+++ b/box/box_test.go
@@ -0,0 +1,29 @@
+package box_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/tarantool/go-tarantool/v2/box"
+)
+
+func TestNew(t *testing.T) {
+	// Create a box instance with a nil connection. This should lead to a panic later.
+	b := box.New(nil)
+
+	// Ensure the box instance is not nil (which it shouldn't be), but this is not meaningful
+	// since we will panic when we call the Info method with the nil connection.
+	require.NotNil(t, b)
+
+	// We expect a panic because we are passing a nil connection (nil Doer) to the By function.
+	// The library does not control this zone, and the nil connection would cause a runtime error
+	// when we attempt to call methods (like Info) on it.
+	// This test ensures that such an invalid state is correctly handled by causing a panic,
+	// as it's outside the library's responsibility.
+	require.Panics(t, func() {
+
+		// Calling Info on a box with a nil connection will result in a panic, since the underlying
+		// connection (Doer) cannot perform the requested action (it's nil).
+		_, _ = b.Info()
+	})
+}
diff --git a/box/example_test.go b/box/example_test.go
new file mode 100644
index 000000000..461949760
--- /dev/null
+++ b/box/example_test.go
@@ -0,0 +1,60 @@
+// Run Tarantool Common Edition before example execution:
+//
+// Terminal 1:
+// $ cd box
+// $ TEST_TNT_LISTEN=127.0.0.1:3013 tarantool testdata/config.lua
+//
+// Terminal 2:
+// $ go test -v example_test.go
+package box_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"time"
+
+	"github.com/tarantool/go-tarantool/v2"
+	"github.com/tarantool/go-tarantool/v2/box"
+)
+
+func Example() {
+	dialer := tarantool.NetDialer{
+		Address:  "127.0.0.1:3013",
+		User:     "test",
+		Password: "test",
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+	client, err := tarantool.Connect(ctx, dialer, tarantool.Opts{})
+	cancel()
+	if err != nil {
+		log.Fatalf("Failed to connect: %s", err)
+	}
+
+	// You can use Info Request type.
+
+	fut := client.Do(box.NewInfoRequest())
+
+	resp := &box.InfoResponse{}
+
+	err = fut.GetTyped(resp)
+	if err != nil {
+		log.Fatalf("Failed get box info: %s", err)
+	}
+
+	// Or use simple Box implementation.
+
+	b := box.New(client)
+
+	info, err := b.Info()
+	if err != nil {
+		log.Fatalf("Failed get box info: %s", err)
+	}
+
+	if info.UUID != resp.Info.UUID {
+		log.Fatalf("Box info uuids are not equal")
+	}
+
+	fmt.Printf("Box info uuids are equal")
+	fmt.Printf("Current box info: %+v\n", resp.Info)
+}
diff --git a/box/info.go b/box/info.go
new file mode 100644
index 000000000..6e5ed1c92
--- /dev/null
+++ b/box/info.go
@@ -0,0 +1,76 @@
+package box
+
+import (
+	"fmt"
+
+	"github.com/tarantool/go-tarantool/v2"
+	"github.com/vmihailenco/msgpack/v5"
+)
+
+var _ tarantool.Request = (*InfoRequest)(nil)
+
+// Info represents detailed information about the Tarantool instance.
+// It includes version, node ID, read-only status, process ID, cluster information, and more.
+type Info struct {
+	// The Version of the Tarantool instance.
+	Version string `msgpack:"version"`
+	// The node ID (nullable).
+	ID *int `msgpack:"id"`
+	// Read-only (RO) status of the instance.
+	RO bool `msgpack:"ro"`
+	// UUID - Unique identifier of the instance.
+	UUID string `msgpack:"uuid"`
+	// Process ID of the instance.
+	PID int `msgpack:"pid"`
+	// Status - Current status of the instance (e.g., running, unconfigured).
+	Status string `msgpack:"status"`
+	// LSN - Log sequence number of the instance.
+	LSN uint64 `msgpack:"lsn"`
+}
+
+// InfoResponse represents the response structure
+// that holds the information of the Tarantool instance.
+// It contains a single field: Info, which holds the instance details (version, UUID, PID, etc.).
+type InfoResponse struct {
+	Info Info
+}
+
+func (ir *InfoResponse) DecodeMsgpack(d *msgpack.Decoder) error {
+	arrayLen, err := d.DecodeArrayLen()
+	if err != nil {
+		return err
+	}
+
+	if arrayLen != 1 {
+		return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen)
+	}
+
+	i := Info{}
+	err = d.Decode(&i)
+	if err != nil {
+		return err
+	}
+
+	ir.Info = i
+
+	return nil
+}
+
+// InfoRequest represents a request to retrieve information about the Tarantool instance.
+// It implements the tarantool.Request interface.
+type InfoRequest struct {
+	baseRequest
+}
+
+// Body method is used to serialize the request's body.
+// It is part of the tarantool.Request interface implementation.
+func (i InfoRequest) Body(res tarantool.SchemaResolver, enc *msgpack.Encoder) error {
+	return i.impl.Body(res, enc)
+}
+
+// NewInfoRequest returns a new empty info request.
+func NewInfoRequest() InfoRequest {
+	req := InfoRequest{}
+	req.impl = newCall("box.info")
+	return req
+}
diff --git a/box/request.go b/box/request.go
new file mode 100644
index 000000000..bf51a72f6
--- /dev/null
+++ b/box/request.go
@@ -0,0 +1,38 @@
+package box
+
+import (
+	"context"
+	"io"
+
+	"github.com/tarantool/go-iproto"
+	"github.com/tarantool/go-tarantool/v2"
+)
+
+type baseRequest struct {
+	impl *tarantool.CallRequest
+}
+
+func newCall(method string) *tarantool.CallRequest {
+	return tarantool.NewCallRequest(method)
+}
+
+// Type returns IPROTO type for request.
+func (req baseRequest) Type() iproto.Type {
+	return req.impl.Type()
+}
+
+// Ctx returns a context of request.
+func (req baseRequest) Ctx() context.Context {
+	return req.impl.Ctx()
+}
+
+// Async returns request expects a response.
+func (req baseRequest) Async() bool {
+	return req.impl.Async()
+}
+
+// Response creates a response for the baseRequest.
+func (req baseRequest) Response(header tarantool.Header,
+	body io.Reader) (tarantool.Response, error) {
+	return req.impl.Response(header, body)
+}
diff --git a/box/tarantool_test.go b/box/tarantool_test.go
new file mode 100644
index 000000000..3d638b5b5
--- /dev/null
+++ b/box/tarantool_test.go
@@ -0,0 +1,86 @@
+package box_test
+
+import (
+	"context"
+	"log"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/require"
+	"github.com/tarantool/go-tarantool/v2"
+	"github.com/tarantool/go-tarantool/v2/box"
+	"github.com/tarantool/go-tarantool/v2/test_helpers"
+)
+
+var server = "127.0.0.1:3013"
+var dialer = tarantool.NetDialer{
+	Address:  server,
+	User:     "test",
+	Password: "test",
+}
+
+func validateInfo(t testing.TB, info box.Info) {
+	var err error
+
+	// Check all fields run correctly.
+	_, err = uuid.Parse(info.UUID)
+	require.NoErrorf(t, err, "validate instance uuid is valid")
+
+	require.NotEmpty(t, info.Version)
+	// Check that pid parsed correctly.
+	require.NotEqual(t, info.PID, 0)
+}
+
+func TestBox_Sugar_Info(t *testing.T) {
+	ctx := context.TODO()
+
+	conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{})
+	require.NoError(t, err)
+
+	info, err := box.New(conn).Info()
+	require.NoError(t, err)
+
+	validateInfo(t, info)
+}
+
+func TestBox_Info(t *testing.T) {
+	ctx := context.TODO()
+
+	conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{})
+	require.NoError(t, err)
+
+	fut := conn.Do(box.NewInfoRequest())
+	require.NotNil(t, fut)
+
+	resp := &box.InfoResponse{}
+	err = fut.GetTyped(resp)
+	require.NoError(t, err)
+
+	validateInfo(t, resp.Info)
+}
+
+func runTestMain(m *testing.M) int {
+	instance, err := test_helpers.StartTarantool(test_helpers.StartOpts{
+		Dialer:       dialer,
+		InitScript:   "testdata/config.lua",
+		Listen:       server,
+		WaitStart:    100 * time.Millisecond,
+		ConnectRetry: 10,
+		RetryTimeout: 500 * time.Millisecond,
+	})
+	defer test_helpers.StopTarantoolWithCleanup(instance)
+
+	if err != nil {
+		log.Printf("Failed to prepare test Tarantool: %s", err)
+		return 1
+	}
+
+	return m.Run()
+}
+
+func TestMain(m *testing.M) {
+	code := runTestMain(m)
+	os.Exit(code)
+}
diff --git a/box/testdata/config.lua b/box/testdata/config.lua
new file mode 100644
index 000000000..f3ee1a7b2
--- /dev/null
+++ b/box/testdata/config.lua
@@ -0,0 +1,13 @@
+-- Do not set listen for now so connector won't be
+-- able to send requests until everything is configured.
+box.cfg{
+    work_dir = os.getenv("TEST_TNT_WORK_DIR"),
+}
+
+box.schema.user.create('test', { password = 'test' , if_not_exists = true })
+box.schema.user.grant('test', 'execute', 'universe', nil, { if_not_exists = true })
+
+-- Set listen only when every other thing is configured.
+box.cfg{
+    listen = os.getenv("TEST_TNT_LISTEN"),
+}