Repository: C2FO/vfs (go)

Issue:
Add function to return underlying client using backend Options.
Add GetClient functions for each backend to allow for direct access to the underlying client from the backend Options.


Pull request:
Add GetClient functions for each backend to allow for direct access t…
…o the underlying client.  Fixes #245.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d129ea4..c9eaaa9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+## [v7.1.0] - 2025-03-19
+### Added
+- Add GetClient functions for each backend to allow for direct access to the underlying client.  Fixes #245.
+
 ## [v7.0.1] - 2025-03-18
 ### Fixes
 - [#243](https://github.com/C2FO/vfs/issues/243) - gs backend fails with cloud.google.com/go/storage v1.51.0 due to new wrapped errors.
diff --git a/backend/azure/client.go b/backend/azure/client.go
index e299fa1..ba53d91 100644
--- a/backend/azure/client.go
+++ b/backend/azure/client.go
@@ -284,3 +284,13 @@ func (a *DefaultClient) getBlobVersions(cli *container.Client, blobName string)
 	}
 	return versions, nil
 }
+
+// GetClient returns a container.Client for the given container name and options
+func GetClient(container string, opts *Options) (*container.Client, error) {
+	client, err := NewClient(opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return client.newContainerClient(container)
+}
diff --git a/backend/ftp/fileSystem.go b/backend/ftp/fileSystem.go
index 1e7250b..25a5979 100644
--- a/backend/ftp/fileSystem.go
+++ b/backend/ftp/fileSystem.go
@@ -169,7 +169,9 @@ func (fs *FileSystem) WithClient(client types.Client) *FileSystem {
 }
 
 func init() {
-	defaultClientGetter = getClient
+	defaultClientGetter = func(ctx context.Context, auth authority.Authority, opts Options) (client types.Client, err error) {
+		return GetClient(ctx, auth, opts)
+	}
 	dataConnGetterFunc = getDataConn
 	// registers a default FileSystem
 	backend.Register(Scheme, NewFileSystem())
diff --git a/backend/ftp/options.go b/backend/ftp/options.go
index 9ecdbc6..4a34360 100644
--- a/backend/ftp/options.go
+++ b/backend/ftp/options.go
@@ -11,7 +11,6 @@ import (
 
 	_ftp "github.com/jlaffaye/ftp"
 
-	"github.com/c2fo/vfs/v7/backend/ftp/types"
 	"github.com/c2fo/vfs/v7/utils/authority"
 )
 
@@ -43,7 +42,8 @@ const (
 	envPassword    = "VFS_FTP_PASSWORD" //nolint:gosec
 )
 
-func getClient(ctx context.Context, authority authority.Authority, opts Options) (types.Client, error) {
+// GetClient returns a new FTP client with the given authority and options.
+func GetClient(ctx context.Context, authority authority.Authority, opts Options) (*_ftp.ServerConn, error) {
 	// dial connection
 	c, err := _ftp.Dial(fetchHostPortString(authority), fetchDialOptions(ctx, authority, opts)...)
 	if err != nil {
diff --git a/backend/gs/fileSystem.go b/backend/gs/fileSystem.go
index 26e9c41..a867468 100644
--- a/backend/gs/fileSystem.go
+++ b/backend/gs/fileSystem.go
@@ -195,3 +195,10 @@ func init() {
 	// registers a default FileSystem
 	backend.Register(Scheme, NewFileSystem())
 }
+
+// GetClient returns a new Google Cloud Storage client with the provided context and Options.
+func GetClient(ctx context.Context, opts Options) (*storage.Client, error) {
+	gsClientOpts := parseClientOptions(opts)
+	creator := &defaultClientCreator{}
+	return creator.NewClient(ctx, gsClientOpts...)
+}
diff --git a/backend/s3/fileSystem.go b/backend/s3/fileSystem.go
index c6f425b..ec32281 100644
--- a/backend/s3/fileSystem.go
+++ b/backend/s3/fileSystem.go
@@ -106,7 +106,7 @@ func (fs *FileSystem) Scheme() string {
 func (fs *FileSystem) Client() (Client, error) {
 	if fs.client == nil {
 		var err error
-		fs.client, err = getClient(fs.options)
+		fs.client, err = GetClient(fs.options)
 		if err != nil {
 			return nil, err
 		}
diff --git a/backend/s3/options.go b/backend/s3/options.go
index eaec2d4..dc5b88f 100644
--- a/backend/s3/options.go
+++ b/backend/s3/options.go
@@ -30,8 +30,8 @@ type Options struct {
 	UploadPartitionSize         int64 // Partition size in bytes used to multipart upload of large files using manager.Uploader
 }
 
-// getClient setup S3 client
-func getClient(opt Options) (Client, error) {
+// GetClient setup S3 client
+func GetClient(opt Options) (*s3.Client, error) {
 	// setup default config
 	awsConfig, err := config.LoadDefaultConfig(context.Background())
 	if err != nil {
@@ -56,6 +56,10 @@ func getClient(opt Options) (Client, error) {
 			opts.Retryer = opt.Retry
 		}
 
+		if opt.MaxRetries > 0 {
+			opts.RetryMaxAttempts = opt.MaxRetries
+		}
+
 		if opt.AccessKeyID != "" && opt.SecretAccessKey != "" {
 			opts.Credentials = credentials.NewStaticCredentialsProvider(
 				opt.AccessKeyID,
@@ -67,3 +71,26 @@ func getClient(opt Options) (Client, error) {
 		}
 	}), nil
 }
+
+// StringToACL converts a string to an ObjectCannedACL
+// see https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/s3/types#ObjectCannedACL
+func StringToACL(acl string) types.ObjectCannedACL {
+	switch acl {
+	case "private":
+		return types.ObjectCannedACLPrivate
+	case "public-read":
+		return types.ObjectCannedACLPublicRead
+	case "public-read-write":
+		return types.ObjectCannedACLPublicReadWrite
+	case "authenticated-read":
+		return types.ObjectCannedACLAuthenticatedRead
+	case "aws-exec-read":
+		return types.ObjectCannedACLAwsExecRead
+	case "bucket-owner-read":
+		return types.ObjectCannedACLBucketOwnerRead
+	case "bucket-owner-full-control":
+		return types.ObjectCannedACLBucketOwnerFullControl
+	default:
+		return types.ObjectCannedACLPrivate
+	}
+}
diff --git a/backend/sftp/fileSystem.go b/backend/sftp/fileSystem.go
index 2a66e4c..7c3cb0a 100644
--- a/backend/sftp/fileSystem.go
+++ b/backend/sftp/fileSystem.go
@@ -200,7 +200,9 @@ func (fs *FileSystem) WithClient(client interface{}) *FileSystem {
 }
 
 func init() {
-	defaultClientGetter = getClient
+	defaultClientGetter = func(auth authority.Authority, opts Options) (Client, io.Closer, error) {
+		return GetClient(auth, opts)
+	}
 
 	// registers a default FileSystem
 	backend.Register(Scheme, NewFileSystem())
diff --git a/backend/sftp/options.go b/backend/sftp/options.go
index faf8b4a..4f3867d 100644
--- a/backend/sftp/options.go
+++ b/backend/sftp/options.go
@@ -3,7 +3,6 @@ package sftp
 import (
 	"errors"
 	"fmt"
-	"io"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -105,7 +104,8 @@ var defaultSSHConfig = &ssh.ClientConfig{
 	},
 }
 
-func getClient(authority authority.Authority, opts Options) (Client, io.Closer, error) {
+// GetClient returns a new sftp client and underlying ssh client(io.Closer) using the given authority and options.
+func GetClient(authority authority.Authority, opts Options) (sftpclient *_sftp.Client, sshclient *ssh.Client, err error) {
 	// setup Authentication
 	authMethods, err := getAuthMethods(opts)
 	if err != nil {
diff --git a/go.mod b/go.mod
index 39a57e8..1531faa 100644
--- a/go.mod
+++ b/go.mod
@@ -22,7 +22,7 @@ require (
 	github.com/stretchr/testify v1.10.0
 	golang.org/x/crypto v0.36.0
 	golang.org/x/net v0.37.0
-	google.golang.org/api v0.226.0
+	google.golang.org/api v0.227.0
 )
 
 require (
diff --git a/go.sum b/go.sum
index 9ac1ecd..145d05e 100644
--- a/go.sum
+++ b/go.sum
@@ -21,8 +21,6 @@ cloud.google.com/go/monitoring v1.24.1 h1:vKiypZVFD/5a3BbQMvI4gZdl8445ITzXFh257X
 cloud.google.com/go/monitoring v1.24.1/go.mod h1:Z05d1/vn9NaujqY2voG6pVQXoJGbp+r3laV+LySt9K0=
 cloud.google.com/go/pubsub v1.48.0 h1:ntFpQVrr10Wj/GXSOpxGmexGynldv/bFp25H0jy8aOs=
 cloud.google.com/go/pubsub v1.48.0/go.mod h1:AAtyjyIT/+zaY1ERKFJbefOvkUxRDNp3nD6TdfdqUZk=
-cloud.google.com/go/storage v1.50.0 h1:3TbVkzTooBvnZsk7WaAQfOsNrdoM8QHusXA1cpk6QJs=
-cloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY=
 cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw=
 cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc=
 cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE=
@@ -398,8 +396,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.226.0 h1:9A29y1XUD+YRXfnHkO66KggxHBZWg9LsTGqm7TkUvtQ=
-google.golang.org/api v0.226.0/go.mod h1:WP/0Xm4LVvMOCldfvOISnWquSRWbG2kArDZcg+W2DbY=
+google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc=
+google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -418,7 +416,6 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
 google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
-google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3 h1:hUfOButuEtpc0UvYiaYRbNwxVYr0mQQOWq6X8beJ9Gc=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=


Test patch:
diff --git a/backend/ftp/dataconn_test.go b/backend/ftp/dataconn_test.go
index 2b27a2b..c00afe5 100644
--- a/backend/ftp/dataconn_test.go
+++ b/backend/ftp/dataconn_test.go
@@ -94,7 +94,9 @@ func (s *dataConnSuite) TestGetDataConn_errorClientSetup() {
 	s.Error(err, "error is expected")
 	s.ErrorIs(err, errClientGetter, "error is right kind of error")
 	s.Nil(dc, "dataconn should be nil on error")
-	defaultClientGetter = getClient
+	defaultClientGetter = func(ctx context.Context, auth authority.Authority, opts Options) (client types.Client, err error) {
+		return GetClient(ctx, auth, opts)
+	}
 }
 
 func (s *dataConnSuite) TestGetDataConn_ReadError() {
diff --git a/backend/ftp/file_test.go b/backend/ftp/file_test.go
index aa2611c..fb2a681 100644
--- a/backend/ftp/file_test.go
+++ b/backend/ftp/file_test.go
@@ -700,7 +700,9 @@ func (ts *fileTestSuite) TestMoveToFile_sameAuthority() {
 	err = sourceFile.MoveToFile(targetFile)
 	ts.Error(err, "error is expected")
 	ts.ErrorIs(err, errClientGetter, "error is the right kind of error")
-	defaultClientGetter = getClient
+	defaultClientGetter = func(ctx context.Context, auth authority.Authority, opts Options) (client types.Client, err error) {
+		return GetClient(ctx, auth, opts)
+	}
 	targetFile.Location().FileSystem().(*FileSystem).ftpclient = tgtMockFTPClient
 	dataConnGetterFunc = getFakeDataConn
 
diff --git a/backend/s3/options_test.go b/backend/s3/options_test.go
index 91e01b0..c9af4e3 100644
--- a/backend/s3/options_test.go
+++ b/backend/s3/options_test.go
@@ -4,7 +4,6 @@ import (
 	"os"
 	"testing"
 
-	"github.com/aws/aws-sdk-go-v2/service/s3"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -19,10 +18,10 @@ func (o *optionsTestSuite) SetupTest() {
 func (o *optionsTestSuite) TestGetClient() {
 	// no options
 	opts := Options{}
-	client, err := getClient(opts)
+	client, err := GetClient(opts)
 	o.NoError(err)
 	o.NotNil(client, "client is set")
-	o.Empty(client.(*s3.Client).Options().AppID, "config is empty")
+	o.Empty(client.Options().AppID, "config is empty")
 
 	// options set
 	opts = Options{
@@ -31,19 +30,19 @@ func (o *optionsTestSuite) TestGetClient() {
 		Region:          "some-region",
 		ForcePathStyle:  true,
 	}
-	client, err = getClient(opts)
+	client, err = GetClient(opts)
 	o.NoError(err)
 	o.NotNil(client, "client is set")
-	o.Equal("some-region", client.(*s3.Client).Options().Region, "region is set")
-	o.Truef(client.(*s3.Client).Options().UsePathStyle, "region is set")
+	o.Equal("some-region", client.Options().Region, "region is set")
+	o.Truef(client.Options().UsePathStyle, "region is set")
 
 	// env var
 	_ = os.Setenv("AWS_DEFAULT_REGION", "set-by-envvar")
 	opts = Options{}
-	client, err = getClient(opts)
+	client, err = GetClient(opts)
 	o.NoError(err)
 	o.NotNil(client, "client is set")
-	o.Equal("set-by-envvar", client.(*s3.Client).Options().Region, "region is set by env var")
+	o.Equal("set-by-envvar", client.Options().Region, "region is set by env var")
 
 	// role ARN set
 	opts = Options{
@@ -52,11 +51,11 @@ func (o *optionsTestSuite) TestGetClient() {
 		Region:          "some-region",
 		RoleARN:         "arn:aws:iam::123456789012:role/my-role",
 	}
-	client, err = getClient(opts)
+	client, err = GetClient(opts)
 	o.NoError(err)
 	o.NotNil(client, "client is set")
-	o.Equal("some-region", client.(*s3.Client).Options().Region, "region is set")
-	o.NotNil(client.(*s3.Client).Options().Credentials, "credentials are set")
+	o.Equal("some-region", client.Options().Region, "region is set")
+	o.NotNil(client.Options().Credentials, "credentials are set")
 }
 
 func TestOptions(t *testing.T) {
diff --git a/backend/sftp/options_test.go b/backend/sftp/options_test.go
index 77786b8..78faa8c 100644
--- a/backend/sftp/options_test.go
+++ b/backend/sftp/options_test.go
@@ -464,7 +464,7 @@ func (o *optionsSuite) TestGetClient() {
 
 	for _, t := range tests { //nolint:gocritic // rangeValCopy
 		o.Run(t.message, func() {
-			_, _, err := getClient(t.authority, t.options)
+			_, _, err := GetClient(t.authority, t.options)
 			if t.hasError {
 				if o.Error(err, "error found") {
 					if t.err != nil {