Repository: fsouza/fake-gcs-server
Pull request #1115 (2023-04-02 13:24:04)

feat(xml): add support for list objects
This pull request aims to add support for listing objects according to the [Google Cloud Storage XML API](https://cloud.google.com/storage/docs/xml-api/get-bucket-list)


goober: Thanks for the review @fsouza, and I agree that we should strive for not breaking the current api. So I have removed everything that is related to pagination in the current PR. And then we could have another look at that in a separate PR later on, if necessary. 

Patch:
diff --git a/fakestorage/object.go b/fakestorage/object.go
index 5430a48..ddeb299 100644
--- a/fakestorage/object.go
+++ b/fakestorage/object.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"compress/gzip"
 	"encoding/json"
+	"encoding/xml"
 	"errors"
 	"fmt"
 	"io"
@@ -571,6 +572,60 @@ func (s *Server) listObjects(r *http.Request) jsonResponse {
 	return jsonResponse{data: newListObjectsResponse(objs, prefixes)}
 }
 
+func (s *Server) xmlListObjects(r *http.Request) xmlResponse {
+	bucketName := unescapeMuxVars(mux.Vars(r))["bucketName"]
+
+	opts := ListOptions{
+		Prefix:    r.URL.Query().Get("prefix"),
+		Delimiter: r.URL.Query().Get("delimiter"),
+		Versions:  r.URL.Query().Get("versions") == "true",
+	}
+
+	objs, prefixes, err := s.ListObjectsWithOptions(bucketName, opts)
+	if err != nil {
+		return xmlResponse{
+			status:       http.StatusInternalServerError,
+			errorMessage: err.Error(),
+		}
+	}
+
+	result := ListBucketResult{
+		Name:      bucketName,
+		Delimiter: opts.Delimiter,
+		Prefix:    opts.Prefix,
+		KeyCount:  len(objs),
+	}
+
+	if opts.Delimiter != "" {
+		for _, prefix := range prefixes {
+			result.CommonPrefixes = append(result.CommonPrefixes, CommonPrefix{Prefix: prefix})
+		}
+	}
+
+	for _, obj := range objs {
+		result.Contents = append(result.Contents, Contents{
+			Key:          obj.Name,
+			Generation:   obj.Generation,
+			Size:         obj.Size,
+			LastModified: obj.Updated.Format(time.RFC3339),
+			ETag:         ETag{Value: obj.Etag},
+		})
+	}
+
+	raw, err := xml.Marshal(result)
+	if err != nil {
+		return xmlResponse{
+			status:       http.StatusInternalServerError,
+			errorMessage: err.Error(),
+		}
+	}
+
+	return xmlResponse{
+		status: http.StatusOK,
+		data:   []byte(xml.Header + string(raw)),
+	}
+}
+
 func (s *Server) getObject(w http.ResponseWriter, r *http.Request) {
 	if alt := r.URL.Query().Get("alt"); alt == "media" || r.Method == http.MethodHead {
 		s.downloadObject(w, r)
diff --git a/fakestorage/server.go b/fakestorage/server.go
index f93c883..289b5a9 100644
--- a/fakestorage/server.go
+++ b/fakestorage/server.go
@@ -266,6 +266,16 @@ func (s *Server) buildMuxer() {
 	s.mux.Path("/_internal/reseed").Methods(http.MethodPut, http.MethodPost).HandlerFunc(jsonToHTTPHandler(s.reseedServer))
 	// Internal - end
 
+	// XML API
+	xmlApiRouters := []*mux.Router{
+		s.mux.Host(fmt.Sprintf("{bucketName}.%s", s.publicHost)).Subrouter(),
+		s.mux.MatcherFunc(s.publicHostMatcher).PathPrefix(`/{bucketName}`).Subrouter(),
+	}
+	for _, r := range xmlApiRouters {
+		r.Path("/").Methods(http.MethodGet).HandlerFunc(xmlToHTTPHandler(s.xmlListObjects))
+		r.Path("").Methods(http.MethodGet).HandlerFunc(xmlToHTTPHandler(s.xmlListObjects))
+	}
+
 	bucketHost := fmt.Sprintf("{bucketName}.%s", s.publicHost)
 	s.mux.Host(bucketHost).Path("/{objectName:.+}").Methods(http.MethodGet, http.MethodHead).HandlerFunc(s.downloadObject)
 	s.mux.Path("/download/storage/v1/b/{bucketName}/o/{objectName:.+}").Methods(http.MethodGet).HandlerFunc(s.downloadObject)
diff --git a/fakestorage/xml_response.go b/fakestorage/xml_response.go
index 0014ff7..50d9661 100644
--- a/fakestorage/xml_response.go
+++ b/fakestorage/xml_response.go
@@ -3,6 +3,7 @@ package fakestorage
 import (
 	"encoding/xml"
 	"net/http"
+	"strings"
 )
 
 type xmlResponse struct {
@@ -22,6 +23,40 @@ type xmlResponseBody struct {
 	Location string
 }
 
+type ListBucketResult struct {
+	XMLName        xml.Name       `xml:"ListBucketResult"`
+	Name           string         `xml:"Name"`
+	CommonPrefixes []CommonPrefix `xml:"CommonPrefixes,omitempty"`
+	Delimiter      string         `xml:"Delimiter"`
+	Prefix         string         `xml:"Prefix"`
+	KeyCount       int            `xml:"KeyCount"`
+	Contents       []Contents     `xml:"Contents"`
+}
+
+type Contents struct {
+	XMLName      xml.Name `xml:"Contents"`
+	Key          string   `xml:"Key"`
+	Generation   int64    `xml:"Generation"`
+	LastModified string   `xml:"LastModified"`
+	ETag         ETag
+	Size         int64 `xml:"Size"`
+}
+
+type CommonPrefix struct {
+	Prefix string `xml:"Prefix"`
+}
+
+type ETag struct {
+	Value string `xml:",innerxml"`
+}
+
+func (e *ETag) Equals(etag string) bool {
+	trim := func(s string) string {
+		return strings.TrimPrefix(strings.TrimSuffix(s, "\""), "\"")
+	}
+	return trim(e.Value) == trim(etag)
+}
+
 type xmlHandler = func(r *http.Request) xmlResponse
 
 func xmlToHTTPHandler(h xmlHandler) http.HandlerFunc {
@@ -43,8 +78,9 @@ func xmlToHTTPHandler(h xmlHandler) http.HandlerFunc {
 		}
 
 		w.WriteHeader(status)
-		if status == 201 {
-			dataBytes, _ := data.([]byte)
+
+		dataBytes, ok := data.([]byte)
+		if ok {
 			w.Write(dataBytes)
 		} else {
 			xml.NewEncoder(w).Encode(data)
diff --git a/go.mod b/go.mod
index 6e6abd3..a7a1fa7 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/google/go-cmp v0.5.9
 	github.com/gorilla/handlers v1.5.1
 	github.com/gorilla/mux v1.8.0
+	github.com/minio/minio-go/v7 v7.0.52
 	github.com/pkg/xattr v0.4.9
 	github.com/sirupsen/logrus v1.9.0
 	github.com/stretchr/testify v1.8.2
@@ -15,8 +16,18 @@ require (
 
 require (
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/google/s2a-go v0.1.3 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/compress v1.16.0 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
+	github.com/minio/md5-simd v1.1.2 // indirect
+	github.com/minio/sha256-simd v1.0.0 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/rs/xid v1.4.0 // indirect
 	golang.org/x/crypto v0.7.0 // indirect
+	gopkg.in/ini.v1 v1.67.0 // indirect
 )
 
 require (
diff --git a/go.sum b/go.sum
index a34e027..eaba43d 100644
--- a/go.sum
+++ b/go.sum
@@ -28,6 +28,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -68,6 +70,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
 github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
 github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
@@ -85,17 +88,39 @@ github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
+github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
+github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go/v7 v7.0.52 h1:8XhG36F6oKQUDDSuz6dY3rioMzovKjW40W6ANuN0Dps=
+github.com/minio/minio-go/v7 v7.0.52/go.mod h1:IbbodHyjUAguneyucUaahv+VMNs/EOTV9du7A7/Z3HU=
+github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
+github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
 github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
+github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
 github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -154,6 +179,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
@@ -217,6 +243,8 @@ google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cn
 google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


Test patch:
diff --git a/fakestorage/object_test.go b/fakestorage/object_test.go
index ac3cd0a..3a58214 100644
--- a/fakestorage/object_test.go
+++ b/fakestorage/object_test.go
@@ -20,7 +20,9 @@ import (
 	"time"
 
 	"cloud.google.com/go/storage"
+
 	"github.com/fsouza/fake-gcs-server/internal/checksum"
+	minio "github.com/minio/minio-go/v7"
 	"google.golang.org/api/iterator"
 )
 
@@ -783,6 +785,77 @@ func getObjectsForListTests() []Object {
 	}
 }
 
+type xmlListTest struct {
+	testCase     string
+	bucketName   string
+	query        minio.ListObjectsOptions
+	expectedKeys []string
+}
+
+func getTestCasesForXMLListTests() []xmlListTest {
+	return []xmlListTest{
+		{
+			"no prefix, no delimiter, multiple objects",
+			"some-bucket",
+			minio.ListObjectsOptions{Recursive: true},
+			[]string{
+				"img/brand.jpg",
+				"img/hi-res/party-01.jpg",
+				"img/hi-res/party-02.jpg",
+				"img/hi-res/party-03.jpg",
+				"img/low-res/party-01.jpg",
+				"img/low-res/party-02.jpg",
+				"img/low-res/party-03.jpg",
+				"video/hi-res/some_video_1080p.mp4",
+			},
+		},
+		{
+			"no prefix, no delimiter, single object",
+			"other-bucket",
+			minio.ListObjectsOptions{Recursive: true},
+			[]string{"static/css/style.css"},
+		},
+		{
+			"no prefix, no delimiter, no objects",
+			"empty-bucket",
+			minio.ListObjectsOptions{Recursive: true},
+			[]string{},
+		},
+		{
+			"filtering prefix only",
+			"some-bucket",
+			minio.ListObjectsOptions{Recursive: true, Prefix: "img/"},
+			[]string{
+				"img/brand.jpg",
+				"img/hi-res/party-01.jpg",
+				"img/hi-res/party-02.jpg",
+				"img/hi-res/party-03.jpg",
+				"img/low-res/party-01.jpg",
+				"img/low-res/party-02.jpg",
+				"img/low-res/party-03.jpg",
+			},
+		},
+		{
+			"full prefix",
+			"some-bucket",
+			minio.ListObjectsOptions{Recursive: true, Prefix: "img/brand.jpg"},
+			[]string{"img/brand.jpg"},
+		},
+		{
+			"filtering prefix and delimiter",
+			"some-bucket",
+			minio.ListObjectsOptions{Prefix: "img/"},
+			[]string{"img/brand.jpg", "img/hi-res/", "img/low-res/"},
+		},
+		{
+			"filtering prefix, no objects",
+			"some-bucket",
+			minio.ListObjectsOptions{Recursive: true, Prefix: "static/"},
+			[]string{},
+		},
+	}
+}
+
 type listTest struct {
 	testCase         string
 	bucketName       string
@@ -913,6 +986,41 @@ func getTestCasesForListTests(versioningEnabled, withOverwrites bool) []listTest
 	}
 }
 
+func TestXMLClientListObjects(t *testing.T) {
+	runServersTest(t, runServersOptions{objs: getObjectsForListTests()}, func(t *testing.T, server *Server) {
+		server.CreateBucketWithOpts(CreateBucketOpts{Name: "empty-bucket"})
+		tests := getTestCasesForXMLListTests()
+
+		client, err := minio.New("storage.googleapis.com", &minio.Options{
+			Transport: server.transport,
+			Region:    "irrelevant",
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		for _, test := range tests {
+			test := test
+			t.Run(test.testCase, func(t *testing.T) {
+				keys := []string{}
+
+				objectCh := client.ListObjects(ctx, test.bucketName, test.query)
+				for obj := range objectCh {
+					if obj.Err != nil {
+						t.Fatal(obj.Err)
+					}
+					keys = append(keys, obj.Key)
+				}
+				if !reflect.DeepEqual(keys, test.expectedKeys) {
+					t.Errorf("wrong object keys returned\nwant %#v\ngot  %#v", test.expectedKeys, keys)
+				}
+			})
+		}
+	})
+}
+
 func TestServiceClientListObjects(t *testing.T) {
 	runServersTest(t, runServersOptions{objs: getObjectsForListTests()}, func(t *testing.T, server *Server) {
 		server.CreateBucketWithOpts(CreateBucketOpts{Name: "empty-bucket"})