Repository: fsouza/fake-gcs-server
Pull request #1258 (2023-07-22 07:26:46)

Additional bucket response attributes
Include additional attributes in Bucket responses, so that `fake-gcs-server` can be used with certain client libraries that rely on those attributes being there (like [googleCloudStorageR](https://cran.r-project.org/web/packages/googleCloudStorageR)).

With these changes we include three additional attributes: `timeCreated` (was already present when using the Memory backend),  `updated` and `storageClass`.

So we go from:

```shell
curl -s 'http://localhost:8080/storage/v1/b/?project=my-dummy-project-id' | jq
{
  "kind": "storage#buckets",
  "items": [
    {
      "kind": "storage#bucket",
      "id": "my-bucket",
      "name": "my-bucket",
      "versioning": {},
      "location": "US-CENTRAL1"
    }
  ]
}
```

to:

```shell
curl -s 'http://localhost:8080/storage/v1/b/?project=my-dummy-project-id' | jq
{
  "kind": "storage#buckets",
  "items": [
    {
      "kind": "storage#bucket",
      "id": "my-bucket",
      "name": "my-bucket",
      "versioning": {},
      "timeCreated": "2023-07-22T09:01:26.287332+02:00",
      "updated": "2023-07-22T09:01:26.287332+02:00",
      "location": "US-CENTRAL1",
      "storageClass": "STANDARD"
    }
  ]
}
```


Patch:
diff --git a/fakestorage/object.go b/fakestorage/object.go
index 47994f2..047629d 100644
--- a/fakestorage/object.go
+++ b/fakestorage/object.go
@@ -1121,6 +1121,14 @@ func (s *Server) composeObject(r *http.Request) jsonResponse {
 		}
 	}
 
+	const maxComposeObjects = 32
+	if len(composeRequest.SourceObjects) > maxComposeObjects {
+		return jsonResponse{
+			status:       http.StatusBadRequest,
+			errorMessage: fmt.Sprintf("The number of source components provided (%d) exceeds the maximum (%d)", len(composeRequest.SourceObjects), maxComposeObjects),
+		}
+	}
+
 	sourceNames := make([]string, 0, len(composeRequest.SourceObjects))
 	for _, n := range composeRequest.SourceObjects {
 		sourceNames = append(sourceNames, n.Name)
diff --git a/fakestorage/response.go b/fakestorage/response.go
index 3a583b4..e28b84e 100644
--- a/fakestorage/response.go
+++ b/fakestorage/response.go
@@ -43,7 +43,9 @@ type bucketResponse struct {
 	Name                  string            `json:"name"`
 	Versioning            *bucketVersioning `json:"versioning,omitempty"`
 	TimeCreated           string            `json:"timeCreated,omitempty"`
+	Updated               string            `json:"updated,omitempty"`
 	Location              string            `json:"location,omitempty"`
+	StorageClass          string            `json:"storageClass,omitempty"`
 }
 
 type bucketVersioning struct {
@@ -58,7 +60,9 @@ func newBucketResponse(bucket backend.Bucket, location string) bucketResponse {
 		DefaultEventBasedHold: bucket.DefaultEventBasedHold,
 		Versioning:            &bucketVersioning{bucket.VersioningEnabled},
 		TimeCreated:           formatTime(bucket.TimeCreated),
+		Updated:               formatTime(bucket.TimeCreated), // not tracking update times yet, reporting `updated` = `timeCreated`
 		Location:              location,
+		StorageClass:          "STANDARD",
 	}
 }
 
diff --git a/fakestorage/server.go b/fakestorage/server.go
index d851685..faa5053 100644
--- a/fakestorage/server.go
+++ b/fakestorage/server.go
@@ -264,7 +264,6 @@ func (s *Server) buildMuxer() {
 		r.Path("/b/{bucketName}").Methods(http.MethodPatch).HandlerFunc(jsonToHTTPHandler(s.updateBucket))
 		r.Path("/b/{bucketName}").Methods(http.MethodDelete).HandlerFunc(jsonToHTTPHandler(s.deleteBucket))
 		r.Path("/b/{bucketName}/o").Methods(http.MethodGet).HandlerFunc(jsonToHTTPHandler(s.listObjects))
-		r.Path("/b/{bucketName}/o").Methods(http.MethodPost).HandlerFunc(jsonToHTTPHandler(s.insertObject))
 		r.Path("/b/{bucketName}/o/{objectName:.+}").Methods(http.MethodPatch).HandlerFunc(jsonToHTTPHandler(s.patchObject))
 		r.Path("/b/{bucketName}/o/{objectName:.+}/acl").Methods(http.MethodGet).HandlerFunc(jsonToHTTPHandler(s.listObjectACL))
 		r.Path("/b/{bucketName}/o/{objectName:.+}/acl").Methods(http.MethodPost).HandlerFunc(jsonToHTTPHandler(s.setObjectACL))
diff --git a/internal/backend/fs.go b/internal/backend/fs.go
index 6a81fcf..688927d 100644
--- a/internal/backend/fs.go
+++ b/internal/backend/fs.go
@@ -113,7 +113,11 @@ func (s *storageFS) ListBuckets() ([]Bucket, error) {
 			if err != nil {
 				return nil, fmt.Errorf("failed to unescape object name %s: %w", info.Name(), err)
 			}
-			buckets = append(buckets, Bucket{Name: unescaped})
+			fileInfo, err := info.Info()
+			if err != nil {
+				return nil, fmt.Errorf("failed to get file info for %s: %w", info.Name(), err)
+			}
+			buckets = append(buckets, Bucket{Name: unescaped, TimeCreated: timespecToTime(createTimeFromFileInfo(fileInfo))})
 		}
 	}
 	return buckets, nil


Test patch:
diff --git a/fakestorage/bucket_test.go b/fakestorage/bucket_test.go
index 57f033f..ee73dea 100644
--- a/fakestorage/bucket_test.go
+++ b/fakestorage/bucket_test.go
@@ -124,6 +124,12 @@ func TestServerClientBucketAttrs(t *testing.T) {
 		if attrs.Created.Before(startTime.Truncate(time.Second)) || time.Now().Before(attrs.Created) {
 			t.Errorf("expecting bucket creation date between test start time %v and now %v, got %v", startTime, time.Now(), attrs.Created)
 		}
+		// TODO: Test `attrs.Updated` as soon as it is available in the [`storage.BucketAttrs`][1] type
+		//       (not available as of cloud.google.com/go/storage v1.31.0)
+		//       [1]: https://pkg.go.dev/cloud.google.com/go/storage#BucketAttrs
+		if attrs.StorageClass != "STANDARD" {
+			t.Errorf("wrong bucket storage class returned\nwant %q\ngot  %q", "STANDARD", attrs.StorageClass)
+		}
 	})
 }
 
diff --git a/fakestorage/object_test.go b/fakestorage/object_test.go
index 38c8adf..26dc27c 100644
--- a/fakestorage/object_test.go
+++ b/fakestorage/object_test.go
@@ -1992,6 +1992,7 @@ func TestServiceClientComposeObject(t *testing.T) {
 			destObjectName    string
 			sourceObjectNames []string
 			expectedContent   string
+			expectedError     string
 		}{
 			{
 				"destination file doesn't exist",
@@ -1999,6 +2000,7 @@ func TestServiceClientComposeObject(t *testing.T) {
 				"files/some-file.txt",
 				[]string{"files/source1.txt", "files/source2.txt"},
 				source1Content + source2Content,
+				"",
 			},
 			{
 				"destination file already exists",
@@ -2006,6 +2008,7 @@ func TestServiceClientComposeObject(t *testing.T) {
 				"files/destination.txt",
 				[]string{"files/source1.txt", "files/source2.txt"},
 				source1Content + source2Content,
+				"",
 			},
 			{
 				"destination is a source",
@@ -2013,6 +2016,15 @@ func TestServiceClientComposeObject(t *testing.T) {
 				"files/source3.txt",
 				[]string{"files/source2.txt", "files/source3.txt"},
 				source2Content + source3Content,
+				"",
+			},
+			{
+				"too many objects at once",
+				"first-bucket",
+				"files/destination.txt",
+				[]string{"01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33"},
+				"",
+				"googleapi: Error 400: The number of source components provided (33) exceeds the maximum (32)",
 			},
 		}
 		for _, test := range tests {
@@ -2033,6 +2045,9 @@ func TestServiceClientComposeObject(t *testing.T) {
 				composer.Metadata = map[string]string{"baz": "qux"}
 				attrs, err := composer.Run(context.TODO())
 				if err != nil {
+					if err.Error() == test.expectedError {
+						return
+					}
 					t.Fatal(err)
 				}
 
diff --git a/fakestorage/upload_test.go b/fakestorage/upload_test.go
index fe2ccef..79908fa 100644
--- a/fakestorage/upload_test.go
+++ b/fakestorage/upload_test.go
@@ -489,7 +489,7 @@ func TestServerClientSimpleUpload(t *testing.T) {
 
 	const data = "some nice content"
 	const contentType = "text/plain"
-	req, err := http.NewRequest("POST", server.URL()+"/storage/v1/b/other-bucket/o?uploadType=media&name=some/nice/object.txt", strings.NewReader(data))
+	req, err := http.NewRequest("POST", server.URL()+"/upload/storage/v1/b/other-bucket/o?uploadType=media&name=some/nice/object.txt", strings.NewReader(data))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -633,7 +633,7 @@ func TestServerClientUploadWithPredefinedAclPublicRead(t *testing.T) {
 	}
 	compressed := buf.Bytes()
 
-	req, err := http.NewRequest("POST", server.URL()+"/storage/v1/b/other-bucket/o?predefinedAcl=publicRead&uploadType=media&name=some/nice/object.txt&contentEncoding="+contentEncoding, bytes.NewReader(compressed))
+	req, err := http.NewRequest("POST", server.URL()+"/upload/storage/v1/b/other-bucket/o?predefinedAcl=publicRead&uploadType=media&name=some/nice/object.txt&contentEncoding="+contentEncoding, bytes.NewReader(compressed))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -690,7 +690,7 @@ func TestServerClientSimpleUploadNoName(t *testing.T) {
 	server.CreateBucketWithOpts(CreateBucketOpts{Name: "other-bucket"})
 
 	const data = "some nice content"
-	req, err := http.NewRequest("POST", server.URL()+"/storage/v1/b/other-bucket/o?uploadType=media", strings.NewReader(data))
+	req, err := http.NewRequest("POST", server.URL()+"/upload/storage/v1/b/other-bucket/o?uploadType=media", strings.NewReader(data))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -715,7 +715,7 @@ func TestServerInvalidUploadType(t *testing.T) {
 	defer server.Stop()
 	server.CreateBucketWithOpts(CreateBucketOpts{Name: "other-bucket"})
 	const data = "some nice content"
-	req, err := http.NewRequest("POST", server.URL()+"/storage/v1/b/other-bucket/o?uploadType=bananas&name=some-object.txt", strings.NewReader(data))
+	req, err := http.NewRequest("POST", server.URL()+"/upload/storage/v1/b/other-bucket/o?uploadType=bananas&name=some-object.txt", strings.NewReader(data))
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go
index 4fe7a4e..80f2ed8 100644
--- a/internal/backend/backend_test.go
+++ b/internal/backend/backend_test.go
@@ -294,6 +294,7 @@ func TestBucketAttrsStoreRetrieveUpdate(t *testing.T) {
 }
 
 func TestBucketCreateGetListDelete(t *testing.T) {
+	startTime := time.Now()
 	testForStorageBackends(t, func(t *testing.T, storage Storage) {
 		buckets, err := storage.ListBuckets()
 		if err != nil {
@@ -344,6 +345,9 @@ func TestBucketCreateGetListDelete(t *testing.T) {
 			if buckets[0].Name != bucket.Name {
 				t.Errorf("listed bucket has unexpected name. Expected %s, actual: %v", bucket.Name, buckets[0].Name)
 			}
+			if buckets[0].TimeCreated.Before(startTime.Truncate(time.Second)) || time.Now().Before(buckets[0].TimeCreated) {
+				t.Errorf("listed bucket has unexpected creation time. Expected between test start time %v and now %v, actual: %v", startTime, time.Now(), buckets[0].TimeCreated)
+			}
 			err = storage.DeleteBucket(bucket.Name)
 			if err != nil {
 				t.Fatal(err)