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 description:
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 {