diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 450c2aa47c3c..7b9cdbdfcc34 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -1177,6 +1177,9 @@ public function compose(array $sourceObjects, $name, array $options = []) * @param array $options [optional] { * Configuration options. * + * @type string $generation If present, selects a specific soft-deleted + * version of this bucket instead of the live version. + * This parameter is required if softDeleted is set to true. * @type string $ifMetagenerationMatch Makes the return of the bucket * metadata conditional on whether the bucket's current * metageneration matches the given value. @@ -1185,6 +1188,8 @@ public function compose(array $sourceObjects, $name, array $options = []) * metageneration does not match the given value. * @type string $projection Determines which properties to return. May * be either `"full"` or `"noAcl"`. + * @type bool $softDeleted If true, returns the soft-deleted bucket. + * This parameter is required if generation is specified. * } * @return array */ @@ -1208,6 +1213,9 @@ public function info(array $options = []) * @param array $options [optional] { * Configuration options. * + * @type string $generation If present, selects a specific soft-deleted + * version of this bucket instead of the live version. + * This parameter is required if softDeleted is set to true. * @type string $ifMetagenerationMatch Makes the return of the bucket * metadata conditional on whether the bucket's current * metageneration matches the given value. @@ -1216,6 +1224,8 @@ public function info(array $options = []) * metageneration does not match the given value. * @type string $projection Determines which properties to return. May * be either `"full"` or `"noAcl"`. + * @type bool $softDeleted If true, returns the soft-deleted bucket. + * This parameter is required if generation is specified. * } * @return array */ diff --git a/Storage/src/Connection/ConnectionInterface.php b/Storage/src/Connection/ConnectionInterface.php index ea429f5bc705..14fc51da506a 100644 --- a/Storage/src/Connection/ConnectionInterface.php +++ b/Storage/src/Connection/ConnectionInterface.php @@ -55,6 +55,11 @@ public function patchAcl(array $args = []); */ public function deleteBucket(array $args = []); + /** + * @param array $args + */ + public function restoreBucket(array $args = []); + /** * @param array $args */ diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index 2f89fe309a2c..578d955fa15a 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -186,6 +186,14 @@ public function deleteBucket(array $args = []) return $this->send('buckets', 'delete', $args); } + /** + * @param array $args + */ + public function restoreBucket(array $args = []) + { + return $this->send('buckets', 'restore', $args); + } + /** * @param array $args */ diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index 3a625cc96d2b..f54fa5869d4e 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -428,6 +428,11 @@ ] } }, + "generation": { + "type": "string", + "description": "The version of the bucket.", + "format": "int64" + }, "owner": { "type": "object", "description": "The owner of the bucket. This is always the project team's owner group.", @@ -501,6 +506,16 @@ } } }, + "softDeleteTime": { + "type": "string", + "description": "The time at which the bucket was soft-deleted.", + "format": "date-time" + }, + "hardDeleteTime": { + "type": "string", + "description": "The time when a soft-deleted bucket is permanently deleted and can no longer be restored.", + "format": "date-time" + }, "storageClass": { "type": "string", "description": "The bucket's default storage class, used whenever no storageClass is specified for a newly-created object. This defines how objects in the bucket are stored and determines the SLA and the cost of storage. Values include MULTI_REGIONAL, REGIONAL, STANDARD, NEARLINE, COLDLINE, ARCHIVE, and DURABLE_REDUCED_AVAILABILITY. If this value is not specified when the bucket is created, it will default to STANDARD. For more information, see storage classes." @@ -2277,6 +2292,12 @@ "required": true, "location": "path" }, + "generation": { + "type": "string", + "description": "If present, selects a specific soft-deleted version of this bucket instead of the live version. This parameter is required if softDeleted is set to true.", + "format": "int64", + "location": "query" + }, "ifMetagenerationMatch": { "type": "string", "description": "Makes the return of the bucket metadata conditional on whether the bucket's current metageneration matches the given value.", @@ -2306,6 +2327,11 @@ "type": "string", "description": "The project to be billed for this request. Required for Requester Pays buckets.", "location": "query" + }, + "softDeleted": { + "type": "boolean", + "description": "If true, returns the soft-deleted bucket. This parameter is required if generation is specified.", + "location": "query" } }, "parameterOrder": [ @@ -2497,6 +2523,11 @@ "type": "string", "description": "The project to be billed for this request.", "location": "query" + }, + "softDeleted": { + "type": "boolean", + "description": "If set to true, only soft-deleted bucket versions are listed as distinct results in order of bucket name and generation number. The default value is false.", + "location": "query" } }, "parameterOrder": [ @@ -2816,6 +2847,51 @@ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/devstorage.full_control" ] + }, + "restore": { + "id": "storage.buckets.restore", + "path": "b/{bucket}/restore", + "httpMethod": "POST", + "description": "Restores a soft-deleted bucket.", + "parameters": { + "bucket": { + "type": "string", + "description": "Name of the bucket to be restored.", + "required": true, + "location": "path" + }, + "generation": { + "type": "string", + "description": "The specific version of the bucket to be restored.", + "required": true, + "format": "int64", + "location": "query" + }, + "projection": { + "type": "string", + "description": "Set of properties to return. Defaults to full.", + "enum": [ + "full", + "noAcl" + ], + "enumDescriptions": [ + "Include all properties.", + "Omit the owner, acl property." + ], + "location": "query" + } + }, + "parameterOrder": [ + "bucket", + "generation" + ], + "response": { + "$ref": "Bucket" + }, + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/devstorage.full_control" + ] } } }, diff --git a/Storage/src/StorageClient.php b/Storage/src/StorageClient.php index b14e52c2841d..53ab0cd55246 100644 --- a/Storage/src/StorageClient.php +++ b/Storage/src/StorageClient.php @@ -148,9 +148,19 @@ public function __construct(array $config = []) * will be used. If a string, that string will be used as the * userProject argument, and that project will be billed for the * request. **Defaults to** `false`. + * @param array $options [optional] { + * Configuration Options. + * + * @type bool $softDeleted If set to true, only soft-deleted bucket versions + * are listed as distinct results in order of bucket name and generation + * number. The default value is false. + * @type string $generation If present, selects a specific soft-deleted version + * of this bucket instead of the live version. This parameter is required if + * softDeleted is set to true. + * } * @return Bucket */ - public function bucket($name, $userProject = false) + public function bucket($name, $userProject = false, array $options = []) { if (!$userProject) { $userProject = null; @@ -158,7 +168,7 @@ public function bucket($name, $userProject = false) $userProject = $this->projectId; } - return new Bucket($this->connection, $name, [ + return new Bucket($this->connection, $name, $options + [ 'requesterProjectId' => $userProject ]); } @@ -200,6 +210,9 @@ public function bucket($name, $userProject = false) * return the specified fields. * @type string $userProject If set, this is the ID of the project which * will be billed for the request. + * @type bool $softDeleted If set to true, only soft-deleted bucket versions + * are listed as distinct results in order of bucket name and generation + * number. The default value is false. * @type bool $bucketUserProject If true, each returned instance will * have `$userProject` set to the value of `$options.userProject`. * If false, `$options.userProject` will be used ONLY for the @@ -238,6 +251,38 @@ function (array $bucket) use ($userProject) { ); } + /** + * Restores a soft-deleted bucket. + * + * Example: + * ``` + * $bucket = $storage->bucket->restore('my-bucket'); + * ``` + * + * @param string $name The name of the bucket to restore. + * @param string $generation The specific version of the bucket to be restored. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $projection Determines which properties to return. May + * be either `"full"` or `"noAcl"`. **Defaults to** `"noAcl"`, + * unless the bucket resource specifies acl or defaultObjectAcl + * properties, when it defaults to `"full"`. + * } + * @return Bucket + */ + public function restore(string $name, string $generation, array $options = []) + { + $res = $this->connection->restoreBucket([ + 'bucket' => $name, + 'generation' => $generation, + ] + $options); + return new Bucket( + $this->connection, + $name + ); + } + /** * Create a bucket. Bucket names must be unique as Cloud Storage uses a flat * namespace. For more information please see diff --git a/Storage/tests/System/ManageBucketsTest.php b/Storage/tests/System/ManageBucketsTest.php index 59f97ac69fe7..c11486bb9d4f 100644 --- a/Storage/tests/System/ManageBucketsTest.php +++ b/Storage/tests/System/ManageBucketsTest.php @@ -491,4 +491,37 @@ public function hnsConfigs() ], true], ]; } + + public function testSoftDeleteBucket() + { + $name = "soft-delete-bucket-" . uniqid(); + $softDeleteBucket = self::createBucket( + self::$client, + $name, + [ + 'softDeletePolicy' => ['retentionDurationSeconds' => 8 * 24 * 60 * 60] + ] + ); + + // Assert that the bucket was created correctly. + $this->assertEquals($name, $softDeleteBucket->name()); + $generation = $softDeleteBucket->info()['generation']; + + // Delete the bucket. + $softDeleteBucket->delete(); + $this->assertFalse(self::$client->bucket($name)->exists()); + + // Retrieve the soft-deleted bucket by generation. + $softDeleteBucket->reload(['softDeleted' => true, 'generation' => $generation]); + + // Assert that the retrieved bucket is the soft-deleted version. + $this->assertEquals($name, $softDeleteBucket->name()); + $this->assertEquals($generation, $softDeleteBucket->info()['generation']); + $this->assertArrayHasKey('softDeleteTime', $softDeleteBucket->info()); + $this->assertArrayHasKey('hardDeleteTime', $softDeleteBucket->info()); + + // Restore the soft-deleted bucket. + self::$client->restore($name, $generation); + $this->assertTrue(self::$client->bucket($name)->exists()); + } } diff --git a/Storage/tests/Unit/StorageClientTest.php b/Storage/tests/Unit/StorageClientTest.php index 6d3aa00def85..531e049c019d 100644 --- a/Storage/tests/Unit/StorageClientTest.php +++ b/Storage/tests/Unit/StorageClientTest.php @@ -69,6 +69,45 @@ public function testGetBucketRequesterPaysDefaultProjectId() $bucket->reload(); } + public function testGetSoftDeletedBucket() + { + $this->connection->projectId()->willReturn(self::PROJECT); + $this->connection->getBucket(Argument::any())->shouldBeCalled() + ->willReturn([ + 'name' => 'bucket1', + 'generation' => 123456789, + 'softDeleteTime' => '2024-09-10T01:01:01.045123456Z', + 'hardDeleteTime' => '2024-09-17T01:01:01.045123456Z' + ]); + $this->client->___setProperty('connection', $this->connection->reveal()); + $bucket = $this->client->bucket('bucket1', true, ['softDeleted' => true, 'generation' => 123456789]); + + $bucket->reload(['softDeleted' => true, 'generation' => 123456789]); + + $this->assertEquals('bucket1', $bucket->name()); + $this->assertEquals(123456789, $bucket->info()['generation']); + $this->assertArrayHasKey('softDeleteTime', $bucket->info()); + $this->assertArrayHasKey('hardDeleteTime', $bucket->info()); + } + + public function testGetsSoftDeletedBuckets() + { + $this->connection->listBuckets( + Argument::withEntry('softDeleted', true) + )->willReturn([ + 'items' => [ + ['name' => 'bucket1'] + ] + ]); + $this->connection->projectId() + ->willReturn(self::PROJECT); + + $this->client->___setProperty('connection', $this->connection->reveal()); + $buckets = iterator_to_array($this->client->buckets(['softDeleted' => true])); + + $this->assertEquals('bucket1', $buckets[0]->name()); + } + public function testGetsBucketsWithoutToken() { $this->connection->listBuckets(Argument::any())->willReturn([ @@ -108,6 +147,23 @@ public function testGetsBucketsWithToken() $this->assertEquals('bucket2', $bucket[1]->name()); } + public function testRestore() + { + $this->connection->restoreBucket(Argument::any()) + ->willReturn([ + 'bucket' => 'bucket1', + 'info' => [ + 'generation' => 12345678 + ] + ]); + + $this->connection->projectId() + ->willReturn(self::PROJECT); + $this->client->___setProperty('connection', $this->connection->reveal()); + + $this->assertInstanceOf(Bucket::class, $this->client->restore('bucket1', 123456789)); + } + public function testCreatesBucket() { $this->connection->insertBucket(Argument::any())->willReturn(['name' => 'bucket']);