From d87a6b609e298050432fad1739e610bc03d06d30 Mon Sep 17 00:00:00 2001 From: Aman Mangal Date: Tue, 25 Mar 2025 16:04:12 +0530 Subject: [PATCH] refactor v25 code and add more tests --- alter.go => alter_v25.go | 8 +- client.go | 99 ++---------------- ns.go => client_v25.go | 212 +++++++++++++++++++++++---------------- client_v25_test.go | 63 ++++++++++++ dql.go | 86 ---------------- ns_v25.go | 139 +++++++++++++++++++++++++ 6 files changed, 343 insertions(+), 264 deletions(-) rename alter.go => alter_v25.go (87%) rename ns.go => client_v25.go (54%) create mode 100644 client_v25_test.go delete mode 100644 dql.go create mode 100644 ns_v25.go diff --git a/alter.go b/alter_v25.go similarity index 87% rename from alter.go rename to alter_v25.go index 6ba94c5..22d35e4 100644 --- a/alter.go +++ b/alter_v25.go @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + package dgo import ( @@ -55,8 +60,7 @@ func (d *Dgraph) SetSchema(ctx context.Context, nsName string, schema string) er } func (d *Dgraph) doAlter(ctx context.Context, req *apiv25.AlterRequest) error { - dc := d.anyClientv25() - _, err := doWithRetryLogin(ctx, d, func() (*apiv25.AlterResponse, error) { + _, err := doWithRetryLogin(ctx, d, func(dc apiv25.DgraphClient) (*apiv25.AlterResponse, error) { return dc.Alter(d.getContext(ctx), req) }) return err diff --git a/client.go b/client.go index 86d5c5a..0374010 100644 --- a/client.go +++ b/client.go @@ -18,7 +18,6 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -28,17 +27,7 @@ import ( ) const ( - cloudPort = "443" - dgraphScheme = "dgraph" - // optional parameter for providing a Dgraph Cloud API key - cloudAPIKeyParam = "apikey" - // optional parameter for providing an access token - bearerTokenParam = "bearertoken" - // optional parameter for providing a Dgraph SSL mode - sslModeParam = "sslmode" - sslModeDisable = "disable" - sslModeRequire = "require" - sslModeVerifyCA = "verify-ca" + cloudPort = "443" ) // Dgraph is a transaction-aware client to a Dgraph cluster. @@ -65,82 +54,6 @@ func (a *authCreds) RequireTransportSecurity() bool { return true } -// Open creates a new Dgraph client by parsing a connection string of the form: -// dgraph://:@:? -// For example `dgraph://localhost:9080?sslmode=require` -// -// Parameters: -// - apikey: a Dgraph Cloud API key for authentication -// - bearertoken: a token for bearer authentication -// - sslmode: SSL connection mode (options: disable, require, verify-ca) -// - disable: No TLS (default) -// - require: Use TLS but skip certificate verification -// - verify-ca: Use TLS and verify the certificate against system CA -// -// If credentials are provided, Open connects to the gRPC endpoint and authenticates the user. -// An error can be returned if the Dgraph cluster is not yet ready to accept requests--the text -// of the error in this case will contain the string "Please retry". -func Open(connStr string) (*Dgraph, error) { - u, err := url.Parse(connStr) - if err != nil { - return nil, fmt.Errorf("invalid connection string: %w", err) - } - - if u.Scheme != dgraphScheme { - return nil, fmt.Errorf("invalid scheme: must start with %s://", dgraphScheme) - } - - opts := []ClientOption{} - - apiKey := u.Query().Get(cloudAPIKeyParam) - bearerToken := u.Query().Get(bearerTokenParam) - sslMode := u.Query().Get(sslModeParam) - - if apiKey != "" && bearerToken != "" { - return nil, errors.New("invalid connection string: both apikey and bearertoken cannot be provided") - } - - if apiKey != "" { - opts = append(opts, WithDgraphAPIKey(apiKey)) - } - - if bearerToken != "" { - opts = append(opts, WithBearerToken(bearerToken)) - } - - if sslMode == "" { - sslMode = sslModeDisable - } - switch sslMode { - case sslModeDisable: - opts = append(opts, WithGrpcOption(grpc.WithTransportCredentials(insecure.NewCredentials()))) - case sslModeRequire: - opts = append(opts, WithSkipTLSVerify()) - case sslModeVerifyCA: - opts = append(opts, WithSystemCertPool()) - default: - return nil, fmt.Errorf("invalid SSL mode: %s (must be one of %s, %s, %s)", sslMode, sslModeDisable, sslModeRequire, sslModeVerifyCA) - } - - if u.User != nil { - username := u.User.Username() - password, _ := u.User.Password() - if username == "" || password == "" { - return nil, errors.New("invalid connection string: both username and password must be provided") - } - opts = append(opts, WithACLCreds(username, password)) - } - - return NewClient(u.Host, opts...) -} - -// Close shutdown down all the connections to the Dgraph Cluster. -func (d *Dgraph) Close() { - for _, conn := range d.conns { - _ = conn.Close() - } -} - // NewDgraphClient creates a new Dgraph (client) for interacting with Alphas. // The client is backed by multiple connections to the same or different // servers in a cluster. @@ -260,9 +173,13 @@ func (d *Dgraph) LoginIntoNamespace(ctx context.Context, // Deprecated: use DropAllNamespaces, DropAll, DropData, DropPredicate, DropType, SetSchema instead. func (d *Dgraph) Alter(ctx context.Context, op *api.Operation) error { dc := d.anyClient() - _, err := doWithRetryLogin(ctx, d, func() (*api.Payload, error) { - return dc.Alter(d.getContext(ctx), op) - }) + _, err := dc.Alter(d.getContext(ctx), op) + if isJwtExpired(err) { + if err := d.retryLogin(ctx); err != nil { + return err + } + _, err = dc.Alter(d.getContext(ctx), op) + } return err } diff --git a/ns.go b/client_v25.go similarity index 54% rename from ns.go rename to client_v25.go index 6e10060..31e45e2 100644 --- a/ns.go +++ b/client_v25.go @@ -9,19 +9,28 @@ import ( "context" "crypto/tls" "crypto/x509" + "errors" "fmt" - "math/rand" + "net/url" + "strings" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" "github.com/dgraph-io/dgo/v240/protos/api" apiv25 "github.com/dgraph-io/dgo/v240/protos/api.v25" ) const ( - RootNamespace = "root" + dgraphScheme = "dgraph" + cloudAPIKeyParam = "apikey" // optional parameter for providing a Dgraph Cloud API key + bearerTokenParam = "bearertoken" // optional parameter for providing an access token + sslModeParam = "sslmode" // optional parameter for providing a Dgraph SSL mode + sslModeDisable = "disable" + sslModeRequire = "require" + sslModeVerifyCA = "verify-ca" ) type bearerCreds struct { @@ -39,23 +48,28 @@ func (a *bearerCreds) RequireTransportSecurity() bool { } type clientOptions struct { - gopts []grpc.DialOption - username, password string + gopts []grpc.DialOption + username string + password string } // ClientOption is a function that modifies the client options. type ClientOption func(*clientOptions) error -// WithSystemCertPool will use the system cert pool and setup a TLS connection with Dgraph cluster. -func WithSystemCertPool() ClientOption { +// WithDgraphAPIKey will use the provided API key for authentication for Dgraph Cloud. +func WithDgraphAPIKey(apiKey string) ClientOption { return func(o *clientOptions) error { - pool, err := x509.SystemCertPool() - if err != nil { - return fmt.Errorf("failed to create system cert pool: %w", err) - } + o.gopts = append(o.gopts, grpc.WithPerRPCCredentials(&authCreds{token: apiKey})) + return nil + } +} - creds := credentials.NewClientTLSFromCert(pool, "") - o.gopts = append(o.gopts, grpc.WithTransportCredentials(creds)) +// WithBearerToken uses the provided token and presents it as a Bearer Token +// in the HTTP Authorization header for authentication against a Dgraph Cluster. +// This can be used to connect to Hypermode Cloud. +func WithBearerToken(token string) ClientOption { + return func(o *clientOptions) error { + o.gopts = append(o.gopts, grpc.WithPerRPCCredentials(&bearerCreds{token: token})) return nil } } @@ -67,20 +81,34 @@ func WithSkipTLSVerify() ClientOption { } } -// WithDgraphAPIKey will use the provided API key for authentication for Dgraph Cloud. -func WithDgraphAPIKey(apiKey string) ClientOption { +// WithSystemCertPool will use the system cert pool and setup a TLS connection with Dgraph cluster. +func WithSystemCertPool() ClientOption { return func(o *clientOptions) error { - o.gopts = append(o.gopts, grpc.WithPerRPCCredentials(&authCreds{token: apiKey})) + pool, err := x509.SystemCertPool() + if err != nil { + return fmt.Errorf("failed to create system cert pool: %w", err) + } + + creds := credentials.NewClientTLSFromCert(pool, "") + o.gopts = append(o.gopts, grpc.WithTransportCredentials(creds)) return nil } } -// WithBearerToken uses the provided token and presents it as a Bearer Token -// in the HTTP Authorization header for authentication against a Dgraph Cluster. -// This can be used to connect to Hypermode Cloud. -func WithBearerToken(token string) ClientOption { +// WithACLCreds will use the provided username and password for ACL authentication. +func WithACLCreds(username, password string) ClientOption { return func(o *clientOptions) error { - o.gopts = append(o.gopts, grpc.WithPerRPCCredentials(&bearerCreds{token: token})) + o.username = username + o.password = password + return nil + } +} + +// WithResponseFormat sets the response format for queries. By default, the +// response format is JSON. We can also specify RDF format. +func WithResponseFormat(respFormat apiv25.RespFormat) TxnOption { + return func(o *txnOptions) error { + o.respFormat = respFormat return nil } } @@ -94,13 +122,79 @@ func WithGrpcOption(opt grpc.DialOption) ClientOption { } } -// WithACLCreds will use the provided username and password for ACL authentication. -func WithACLCreds(username, password string) ClientOption { - return func(o *clientOptions) error { - o.username = username - o.password = password - return nil +// Open creates a new Dgraph client by parsing a connection string of the form: +// dgraph://:@:? +// For example `dgraph://localhost:9080?sslmode=require` +// +// Parameters: +// - apikey: a Dgraph Cloud API key for authentication +// - bearertoken: a token for bearer authentication +// - sslmode: SSL connection mode (options: disable, require, verify-ca) +// - disable: No TLS (default) +// - require: Use TLS but skip certificate verification +// - verify-ca: Use TLS and verify the certificate against system CA +// +// If credentials are provided, Open connects to the gRPC endpoint and authenticates the user. +// An error can be returned if the Dgraph cluster is not yet ready to accept requests--the text +// of the error in this case will contain the string "Please retry". +func Open(connStr string) (*Dgraph, error) { + u, err := url.Parse(connStr) + if err != nil { + return nil, fmt.Errorf("invalid connection string: %w", err) + } + + params, err := url.ParseQuery(u.RawQuery) + if err != nil { + return nil, fmt.Errorf("malformed connection string: %w", err) + } + + apiKey := params.Get(cloudAPIKeyParam) + bearerToken := params.Get(bearerTokenParam) + sslMode := params.Get(sslModeParam) + + if u.Scheme != dgraphScheme { + return nil, fmt.Errorf("invalid scheme: must start with %s://", dgraphScheme) + } + if apiKey != "" && bearerToken != "" { + return nil, errors.New("invalid connection string: both apikey and bearertoken cannot be provided") + } + if !strings.Contains(u.Host, ":") { + return nil, errors.New("invalid connection string: host url must have both host and port") + } + + opts := []ClientOption{} + if apiKey != "" { + opts = append(opts, WithDgraphAPIKey(apiKey)) + } + if bearerToken != "" { + opts = append(opts, WithBearerToken(bearerToken)) + } + + if sslMode == "" { + sslMode = sslModeDisable + } + switch sslMode { + case sslModeDisable: + opts = append(opts, WithGrpcOption(grpc.WithTransportCredentials(insecure.NewCredentials()))) + case sslModeRequire: + opts = append(opts, WithSkipTLSVerify()) + case sslModeVerifyCA: + opts = append(opts, WithSystemCertPool()) + default: + return nil, fmt.Errorf("invalid SSL mode: %s (must be one of %s, %s, %s)", + sslMode, sslModeDisable, sslModeRequire, sslModeVerifyCA) + } + + if u.User != nil { + username := u.User.Username() + password, _ := u.User.Password() + if username == "" || password == "" { + return nil, errors.New("invalid connection string: both username and password must be provided") + } + opts = append(opts, WithACLCreds(username, password)) } + + return NewClient(u.Host, opts...) } // NewClient creates a new Dgraph client for a single endpoint. @@ -140,7 +234,7 @@ func NewRoundRobinClient(endpoints []string, opts ...ClientOption) (*Dgraph, err ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := d.Login(ctx, co.username, co.password); err != nil { + if err := d.signInUser(ctx, co.username, co.password); err != nil { d.Close() return nil, fmt.Errorf("failed to sign in user: %w", err) } @@ -148,67 +242,15 @@ func NewRoundRobinClient(endpoints []string, opts ...ClientOption) (*Dgraph, err return d, nil } -func (d *Dgraph) anyClientv25() apiv25.DgraphClient { - //nolint:gosec - return d.dcv25[rand.Intn(len(d.dcv25))] -} - -// CreateNamespace creates a new namespace with the given name and password for groot user. -func (d *Dgraph) CreateNamespace(ctx context.Context, name string) error { - dc := d.anyClientv25() - req := &apiv25.CreateNamespaceRequest{NsName: name} - _, err := doWithRetryLogin(ctx, d, func() (*apiv25.CreateNamespaceResponse, error) { - return dc.CreateNamespace(d.getContext(ctx), req) - }) - return err -} - -// DropNamespace deletes the namespace with the given name. -func (d *Dgraph) DropNamespace(ctx context.Context, name string) error { - dc := d.anyClientv25() - req := &apiv25.DropNamespaceRequest{NsName: name} - _, err := doWithRetryLogin(ctx, d, func() (*apiv25.DropNamespaceResponse, error) { - return dc.DropNamespace(d.getContext(ctx), req) - }) - return err -} - -// RenameNamespace renames the namespace from the given name to the new name. -func (d *Dgraph) RenameNamespace(ctx context.Context, from string, to string) error { - dc := d.anyClientv25() - req := &apiv25.UpdateNamespaceRequest{NsName: from, RenameToNs: to} - _, err := doWithRetryLogin(ctx, d, func() (*apiv25.UpdateNamespaceResponse, error) { - return dc.UpdateNamespace(d.getContext(ctx), req) - }) - return err -} - -// ListNamespaces returns a map of namespace names to their details. -func (d *Dgraph) ListNamespaces(ctx context.Context) (map[string]*apiv25.Namespace, error) { - dc := d.anyClientv25() - resp, err := doWithRetryLogin(ctx, d, func() (*apiv25.ListNamespacesResponse, error) { - return dc.ListNamespaces(d.getContext(ctx), &apiv25.ListNamespacesRequest{}) - }) - if err != nil { - return nil, err - } - - return resp.NsList, nil -} - -func doWithRetryLogin[T any](ctx context.Context, d *Dgraph, f func() (*T, error)) (*T, error) { - resp, err := f() - if isJwtExpired(err) { - if err := d.retryLogin(ctx); err != nil { - return nil, err - } - return f() +// Close shutdown down all the connections to the Dgraph Cluster. +func (d *Dgraph) Close() { + for _, conn := range d.conns { + _ = conn.Close() } - return resp, err } -// SignInUser logs the user in using the provided username and password. -func (d *Dgraph) SignInUser(ctx context.Context, username, password string) error { +// signInUser logs the user in using the provided username and password. +func (d *Dgraph) signInUser(ctx context.Context, username, password string) error { d.jwtMutex.Lock() defer d.jwtMutex.Unlock() diff --git a/client_v25_test.go b/client_v25_test.go new file mode 100644 index 0000000..b501755 --- /dev/null +++ b/client_v25_test.go @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dgo_test + +import ( + "testing" + + "github.com/dgraph-io/dgo/v240" + + "github.com/stretchr/testify/require" +) + +func TestOpen(t *testing.T) { + var err error + + _, err = dgo.Open("127.0.0.1:9180") + require.ErrorContains(t, err, "first path segment in URL cannot contain colon") + + _, err = dgo.Open("localhost:9180") + require.ErrorContains(t, err, "invalid scheme: must start with dgraph://") + + _, err = dgo.Open("dgraph://localhost:9180") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost") + require.ErrorContains(t, err, "invalid connection string: host url must have both host and port") + + _, err = dgo.Open("dgraph://localhost:") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=verify-ca") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=prefer") + require.ErrorContains(t, err, "invalid SSL mode: prefer (must be one of disable, require, verify-ca)") + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=disable&bearertoken=abc") + require.ErrorContains(t, err, "grpc: the credentials require transport level security") + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=disable&apikey=abc") + require.ErrorContains(t, err, "grpc: the credentials require transport level security") + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=disable&apikey=abc&bearertoken=bgf") + require.ErrorContains(t, err, "invalid connection string: both apikey and bearertoken cannot be provided") + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=verify-ca&bearertoken=hfs") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=verify-ca&apikey=hfs") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=require&bearertoken=hfs") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost:9180?sslmode=require&apikey=hfs") + require.NoError(t, err) + + _, err = dgo.Open("dgraph://localhost:9180?sslm") + require.NoError(t, err) +} diff --git a/dql.go b/dql.go deleted file mode 100644 index b05e898..0000000 --- a/dql.go +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-FileCopyrightText: © Hypermode Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -package dgo - -import ( - "context" - - apiv25 "github.com/dgraph-io/dgo/v240/protos/api.v25" -) - -type txnOptions struct { - readOnly bool - bestEffort bool - respFormat apiv25.RespFormat -} - -// TxnOption is a function that modifies the txn options. -type TxnOption func(*txnOptions) error - -// WithReadOnly sets the txn to be read-only. -func WithReadOnly() TxnOption { - return func(o *txnOptions) error { - o.readOnly = true - return nil - } -} - -// WithBestEffort sets the txn to be best effort. -func WithBestEffort() TxnOption { - return func(o *txnOptions) error { - o.readOnly = true - o.bestEffort = true - return nil - } -} - -// WithResponseFormat sets the response format for queries. By default, the -// response format is JSON. We can also specify RDF format. -func WithResponseFormat(respFormat apiv25.RespFormat) TxnOption { - return func(o *txnOptions) error { - o.respFormat = respFormat - return nil - } -} - -func buildTxnOptions(opts ...TxnOption) (*txnOptions, error) { - topts := &txnOptions{} - for _, opt := range opts { - if err := opt(topts); err != nil { - return nil, err - } - } - if topts.bestEffort { - topts.readOnly = true - } - - return topts, nil -} - -// RunDQL runs a DQL query in the given namespace. A DQL query could be a mutation -// or a query or an upsert which is a combination of mutations and queries. -func (d *Dgraph) RunDQL(ctx context.Context, nsName string, q string, opts ...TxnOption) ( - *apiv25.RunDQLResponse, error) { - - return d.RunDQLWithVars(ctx, nsName, q, nil, opts...) -} - -// RunDQLWithVars is like RunDQL with variables. -func (d *Dgraph) RunDQLWithVars(ctx context.Context, nsName string, q string, - vars map[string]string, opts ...TxnOption) (*apiv25.RunDQLResponse, error) { - - topts, err := buildTxnOptions(opts...) - if err != nil { - return nil, err - } - - dc := d.anyClientv25() - req := &apiv25.RunDQLRequest{NsName: nsName, DqlQuery: q, Vars: vars, - ReadOnly: topts.readOnly, BestEffort: topts.bestEffort} - return doWithRetryLogin(ctx, d, func() (*apiv25.RunDQLResponse, error) { - return dc.RunDQL(d.getContext(ctx), req) - }) -} diff --git a/ns_v25.go b/ns_v25.go new file mode 100644 index 0000000..b313a51 --- /dev/null +++ b/ns_v25.go @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dgo + +import ( + "context" + "math/rand" + + apiv25 "github.com/dgraph-io/dgo/v240/protos/api.v25" +) + +const ( + RootNamespace = "root" +) + +type txnOptions struct { + readOnly bool + bestEffort bool + respFormat apiv25.RespFormat +} + +// TxnOption is a function that modifies the txn options. +type TxnOption func(*txnOptions) error + +// WithReadOnly sets the txn to be read-only. +func WithReadOnly() TxnOption { + return func(o *txnOptions) error { + o.readOnly = true + return nil + } +} + +// WithBestEffort sets the txn to be best effort. +func WithBestEffort() TxnOption { + return func(o *txnOptions) error { + o.readOnly = true + o.bestEffort = true + return nil + } +} + +func buildTxnOptions(opts ...TxnOption) (*txnOptions, error) { + topts := &txnOptions{} + for _, opt := range opts { + if err := opt(topts); err != nil { + return nil, err + } + } + if topts.bestEffort { + topts.readOnly = true + } + + return topts, nil +} + +// RunDQL runs a DQL query in the given namespace. A DQL query could be a mutation +// or a query or an upsert which is a combination of mutations and queries. +func (d *Dgraph) RunDQL(ctx context.Context, nsName string, q string, opts ...TxnOption) ( + *apiv25.RunDQLResponse, error) { + + return d.RunDQLWithVars(ctx, nsName, q, nil, opts...) +} + +// RunDQLWithVars is like RunDQL with variables. +func (d *Dgraph) RunDQLWithVars(ctx context.Context, nsName string, q string, + vars map[string]string, opts ...TxnOption) (*apiv25.RunDQLResponse, error) { + + topts, err := buildTxnOptions(opts...) + if err != nil { + return nil, err + } + + req := &apiv25.RunDQLRequest{NsName: nsName, DqlQuery: q, Vars: vars, + ReadOnly: topts.readOnly, BestEffort: topts.bestEffort} + return doWithRetryLogin(ctx, d, func(dc apiv25.DgraphClient) (*apiv25.RunDQLResponse, error) { + return dc.RunDQL(d.getContext(ctx), req) + }) +} + +// CreateNamespace creates a new namespace with the given name and password for groot user. +func (d *Dgraph) CreateNamespace(ctx context.Context, name string) error { + req := &apiv25.CreateNamespaceRequest{NsName: name} + _, err := doWithRetryLogin(ctx, d, func(dc apiv25.DgraphClient) (*apiv25.CreateNamespaceResponse, error) { + return dc.CreateNamespace(d.getContext(ctx), req) + }) + return err +} + +// DropNamespace deletes the namespace with the given name. +func (d *Dgraph) DropNamespace(ctx context.Context, name string) error { + req := &apiv25.DropNamespaceRequest{NsName: name} + _, err := doWithRetryLogin(ctx, d, func(dc apiv25.DgraphClient) (*apiv25.DropNamespaceResponse, error) { + return dc.DropNamespace(d.getContext(ctx), req) + }) + return err +} + +// RenameNamespace renames the namespace from the given name to the new name. +func (d *Dgraph) RenameNamespace(ctx context.Context, from string, to string) error { + req := &apiv25.UpdateNamespaceRequest{NsName: from, RenameToNs: to} + _, err := doWithRetryLogin(ctx, d, func(dc apiv25.DgraphClient) (*apiv25.UpdateNamespaceResponse, error) { + return dc.UpdateNamespace(d.getContext(ctx), req) + }) + return err +} + +// ListNamespaces returns a map of namespace names to their details. +func (d *Dgraph) ListNamespaces(ctx context.Context) (map[string]*apiv25.Namespace, error) { + resp, err := doWithRetryLogin(ctx, d, func(dc apiv25.DgraphClient) (*apiv25.ListNamespacesResponse, error) { + return dc.ListNamespaces(d.getContext(ctx), &apiv25.ListNamespacesRequest{}) + }) + if err != nil { + return nil, err + } + + return resp.NsList, nil +} + +func (d *Dgraph) anyClientv25() apiv25.DgraphClient { + //nolint:gosec + return d.dcv25[rand.Intn(len(d.dcv25))] +} + +func doWithRetryLogin[T any](ctx context.Context, d *Dgraph, + f func(dc apiv25.DgraphClient) (*T, error)) (*T, error) { + + dc := d.anyClientv25() + resp, err := f(dc) + if isJwtExpired(err) { + if err := d.retryLogin(ctx); err != nil { + return nil, err + } + return f(dc) + } + return resp, err +}