diff --git a/CHANGELOG.md b/CHANGELOG.md index c515f54c..b30ec160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,12 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release. ### Added +- Implemented box.schema.user operations requests and sugar interface. + ### Changed +- Box Info method now requires context and implements CallRequest type instead custom baseRequest. + ### Fixed ## [v2.2.1] - 2024-12-17 diff --git a/box/box.go b/box/box.go index be7f288a..4fa3c656 100644 --- a/box/box.go +++ b/box/box.go @@ -1,6 +1,8 @@ package box import ( + "context" + "github.com/tarantool/go-tarantool/v2" ) @@ -17,13 +19,19 @@ func New(conn tarantool.Doer) *Box { } } +// Schema returns a new Schema instance, providing access to schema-related operations. +// It uses the connection from the Box instance to communicate with Tarantool. +func (b *Box) Schema() *Schema { + return NewSchema(b.conn) +} + // 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) { +func (b *Box) Info(ctx context.Context) (Info, error) { var infoResp InfoResponse // Call "box.info" to get instance information from Tarantool. - fut := b.conn.Do(NewInfoRequest()) + fut := b.conn.Do(NewInfoRequest().Context(ctx)) // Parse the result into the Info structure. err := fut.GetTyped(&infoResp) diff --git a/box/box_test.go b/box/box_test.go index 31e614c1..e71a0f9a 100644 --- a/box/box_test.go +++ b/box/box_test.go @@ -1,6 +1,7 @@ package box_test import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -24,6 +25,6 @@ func TestNew(t *testing.T) { // 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() + _, _ = b.Info(context.TODO()) }) } diff --git a/box/example_test.go b/box/example_test.go index 46194976..9c790fe6 100644 --- a/box/example_test.go +++ b/box/example_test.go @@ -46,7 +46,7 @@ func Example() { b := box.New(client) - info, err := b.Info() + info, err := b.Info(ctx) if err != nil { log.Fatalf("Failed get box info: %s", err) } diff --git a/box/info.go b/box/info.go index 6e5ed1c9..45bc3ec8 100644 --- a/box/info.go +++ b/box/info.go @@ -59,18 +59,14 @@ func (ir *InfoResponse) DecodeMsgpack(d *msgpack.Decoder) error { // 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) + *tarantool.CallRequest // Underlying Tarantool call request. } // NewInfoRequest returns a new empty info request. func NewInfoRequest() InfoRequest { - req := InfoRequest{} - req.impl = newCall("box.info") - return req + callReq := tarantool.NewCallRequest("box.info") + + return InfoRequest{ + callReq, + } } diff --git a/box/request.go b/box/request.go deleted file mode 100644 index bf51a72f..00000000 --- a/box/request.go +++ /dev/null @@ -1,38 +0,0 @@ -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/schema.go b/box/schema.go new file mode 100644 index 00000000..10fa9b35 --- /dev/null +++ b/box/schema.go @@ -0,0 +1,18 @@ +package box + +import "github.com/tarantool/go-tarantool/v2" + +// Schema represents the schema-related operations in Tarantool. +// It holds a connection to interact with the Tarantool instance. +type Schema struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +func NewSchema(conn tarantool.Doer) *Schema { + return &Schema{conn: conn} // Pass the connection to the Schema. +} + +// User returns a new SchemaUser instance, allowing schema-related user operations. +func (s *Schema) User() *SchemaUser { + return NewSchemaUser(s.conn) +} diff --git a/box/schema_user.go b/box/schema_user.go new file mode 100644 index 00000000..5811ac73 --- /dev/null +++ b/box/schema_user.go @@ -0,0 +1,219 @@ +package box + +import ( + "context" + "fmt" + + "github.com/tarantool/go-tarantool/v2" + "github.com/vmihailenco/msgpack/v5" +) + +// SchemaUser provides methods to interact with schema-related user operations in Tarantool. +type SchemaUser struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +func NewSchemaUser(conn tarantool.Doer) *SchemaUser { + return &SchemaUser{conn: conn} +} + +// UserExistsRequest represents a request to check if a user exists in Tarantool. +type UserExistsRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// UserExistsResponse represents the response to a user existence check. +type UserExistsResponse struct { + Exists bool // True if the user exists, false otherwise. +} + +// DecodeMsgpack decodes the response from a Msgpack-encoded byte slice. +func (uer *UserExistsResponse) DecodeMsgpack(d *msgpack.Decoder) error { + arrayLen, err := d.DecodeArrayLen() + if err != nil { + return err + } + + // Ensure that the response array contains exactly 1 element (the "Exists" field). + if arrayLen != 1 { + return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen) + } + + // Decode the boolean value indicating whether the user exists. + uer.Exists, err = d.DecodeBool() + + return err +} + +// NewUserExistsRequest creates a new request to check if a user exists. +func NewUserExistsRequest(username string) UserExistsRequest { + callReq := tarantool.NewCallRequest("box.schema.user.exists").Args([]interface{}{username}) + + return UserExistsRequest{ + callReq, + } +} + +// Exists checks if the specified user exists in Tarantool. +func (u *SchemaUser) Exists(ctx context.Context, username string) (bool, error) { + // Create a request and send it to Tarantool. + req := NewUserExistsRequest(username).Context(ctx) + resp := &UserExistsResponse{} + + // Execute the request and parse the response. + err := u.conn.Do(req).GetTyped(resp) + + return resp.Exists, err +} + +// UserCreateOptions represents options for creating a user in Tarantool. +type UserCreateOptions struct { + // IfNotExists - if true, prevents an error if the user already exists. + IfNotExists bool `msgpack:"if_not_exists"` + // Password for the new user. + Password string `msgpack:"password"` +} + +// UserCreateRequest represents a request to create a new user in Tarantool. +type UserCreateRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserCreateRequest creates a new request to create a user with specified options. +func NewUserCreateRequest(username string, options UserCreateOptions) UserCreateRequest { + callReq := tarantool.NewCallRequest("box.schema.user.create"). + Args([]interface{}{username, options}) + + return UserCreateRequest{ + callReq, + } +} + +// UserCreateResponse represents the response to a user creation request. +type UserCreateResponse struct { +} + +// DecodeMsgpack decodes the response for a user creation request. +// In this case, the response does not contain any data. +func (uer *UserCreateResponse) DecodeMsgpack(_ *msgpack.Decoder) error { + return nil +} + +// Create creates a new user in Tarantool with the given username and options. +func (u *SchemaUser) Create(ctx context.Context, username string, options UserCreateOptions) error { + // Create a request and send it to Tarantool. + req := NewUserCreateRequest(username, options).Context(ctx) + resp := &UserCreateResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserDropOptions represents options for dropping a user in Tarantool. +type UserDropOptions struct { + IfExists bool `msgpack:"if_exists"` // If true, prevents an error if the user does not exist. +} + +// UserDropRequest represents a request to drop a user from Tarantool. +type UserDropRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserDropRequest creates a new request to drop a user with specified options. +func NewUserDropRequest(username string, options UserDropOptions) UserDropRequest { + callReq := tarantool.NewCallRequest("box.schema.user.drop"). + Args([]interface{}{username, options}) + + return UserDropRequest{ + callReq, + } +} + +// UserDropResponse represents the response to a user drop request. +type UserDropResponse struct{} + +// Drop drops the specified user from Tarantool, with optional conditions. +func (u *SchemaUser) Drop(ctx context.Context, username string, options UserDropOptions) error { + // Create a request and send it to Tarantool. + req := NewUserDropRequest(username, options).Context(ctx) + resp := &UserCreateResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserPasswordRequest represents a request to retrieve a user's password from Tarantool. +type UserPasswordRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserPasswordRequest creates a new request to fetch the user's password. +// It takes the username and constructs the request to Tarantool. +func NewUserPasswordRequest(username string) UserPasswordRequest { + // Create a request to get the user's password. + callReq := tarantool.NewCallRequest("box.schema.user.password").Args([]interface{}{username}) + + return UserPasswordRequest{ + callReq, + } +} + +// UserPasswordResponse represents the response to the user password request. +// It contains the password hash. +type UserPasswordResponse struct { + Hash string // The password hash of the user. +} + +// DecodeMsgpack decodes the response from Tarantool in Msgpack format. +// It expects the response to be an array of length 1, containing the password hash string. +func (upr *UserPasswordResponse) DecodeMsgpack(d *msgpack.Decoder) error { + // Decode the array length. + arrayLen, err := d.DecodeArrayLen() + if err != nil { + return err + } + + // Ensure the array contains exactly 1 element (the password hash). + if arrayLen != 1 { + return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen) + } + + // Decode the string containing the password hash. + upr.Hash, err = d.DecodeString() + + return err +} + +// Password sends a request to retrieve the user's password from Tarantool. +// It returns the password hash as a string or an error if the request fails. +func (u *SchemaUser) Password(ctx context.Context, username string) (string, error) { + // Create the request and send it to Tarantool. + req := NewUserPasswordRequest(username).Context(ctx) + resp := &UserPasswordResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + // Get the decoded response. + err := fut.GetTyped(resp) + if err != nil { + return "", err + } + + // Return the password hash. + return resp.Hash, nil +} diff --git a/box/tarantool_test.go b/box/tarantool_test.go index 3d638b5b..854a5e2c 100644 --- a/box/tarantool_test.go +++ b/box/tarantool_test.go @@ -2,6 +2,7 @@ package box_test import ( "context" + "errors" "log" "os" "testing" @@ -9,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/tarantool/go-iproto" "github.com/tarantool/go-tarantool/v2" "github.com/tarantool/go-tarantool/v2/box" "github.com/tarantool/go-tarantool/v2/test_helpers" @@ -39,7 +41,7 @@ func TestBox_Sugar_Info(t *testing.T) { conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) require.NoError(t, err) - info, err := box.New(conn).Info() + info, err := box.New(conn).Info(context.TODO()) require.NoError(t, err) validateInfo(t, info) @@ -61,6 +63,148 @@ func TestBox_Info(t *testing.T) { validateInfo(t, resp.Info) } +func TestBox_Sugar_Schema_UserCreate(t *testing.T) { + const ( + username = "exists" + password = "exists" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + // Create new user + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + t.Run("can connect with new credentials", func(t *testing.T) { + t.Parallel() + // Check that password is valid and we can connect to tarantool with such credentials + var newUserDialer = tarantool.NetDialer{ + Address: server, + User: username, + Password: password, + } + + // We can connect with our new credentials + newUserConn, err := tarantool.Connect(ctx, newUserDialer, tarantool.Opts{}) + require.NoError(t, err) + require.NotNil(t, newUserConn) + require.NoError(t, newUserConn.Close()) + }) + t.Run("create user already exists error", func(t *testing.T) { + t.Parallel() + // Get error that user already exists + err := b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.Error(t, err) + + // Require that error code is ER_USER_EXISTS + var boxErr tarantool.Error + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_USER_EXISTS, boxErr.Code) + }) + + t.Run("exists method return true", func(t *testing.T) { + t.Parallel() + // Check that already exists by exists call procedure + exists, err := b.Schema().User().Exists(ctx, username) + require.True(t, exists) + require.NoError(t, err) + }) + + t.Run("no error if IfNotExists option is true", func(t *testing.T) { + t.Parallel() + + err := b.Schema().User().Create(ctx, username, box.UserCreateOptions{ + Password: password, + IfNotExists: true, + }) + + require.NoError(t, err) + }) +} + +func TestBox_Sugar_Schema_UserPassword(t *testing.T) { + const ( + username = "passwd" + password = "passwd" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + // Require password hash + hash, err := b.Schema().User().Password(ctx, username) + require.NoError(t, err) + require.NotEmpty(t, hash) +} + +func TestBox_Sugar_Schema_UserDrop(t *testing.T) { + const ( + username = "to_drop" + password = "to_drop" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + t.Run("drop user after create", func(t *testing.T) { + t.Parallel() + // Create new user + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + // Try to drop user + err = b.Schema().User().Drop(ctx, username, box.UserDropOptions{}) + require.NoError(t, err) + + t.Run("error double drop without IfExists option", func(t *testing.T) { + t.Parallel() + // Require error cause user already deleted + err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", + box.UserDropOptions{}) + require.Error(t, err) + + var boxErr tarantool.Error + + // Require that error code is ER_NO_SUCH_USER + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_NO_SUCH_USER, boxErr.Code) + }) + t.Run("ok double drop with IfExists option", func(t *testing.T) { + t.Parallel() + // Require no error with IfExists: true option + err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", + box.UserDropOptions{IfExists: true}) + require.NoError(t, err) + }) + }) + + t.Run("drop not existing user", func(t *testing.T) { + t.Parallel() + // Require error cause user already deleted + err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", box.UserDropOptions{}) + require.Error(t, err) + + var boxErr tarantool.Error + + // Require that error code is ER_NO_SUCH_USER + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_NO_SUCH_USER, boxErr.Code) + }) +} + func runTestMain(m *testing.M) int { instance, err := test_helpers.StartTarantool(test_helpers.StartOpts{ Dialer: dialer, diff --git a/box/testdata/config.lua b/box/testdata/config.lua index f3ee1a7b..5833908f 100644 --- a/box/testdata/config.lua +++ b/box/testdata/config.lua @@ -5,9 +5,9 @@ box.cfg{ } box.schema.user.create('test', { password = 'test' , if_not_exists = true }) -box.schema.user.grant('test', 'execute', 'universe', nil, { if_not_exists = true }) +box.schema.user.grant('test', 'super', nil) -- Set listen only when every other thing is configured. box.cfg{ listen = os.getenv("TEST_TNT_LISTEN"), -} +} \ No newline at end of file