diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 33e760b258aa..5fcd08696c72 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -614,6 +614,48 @@ public function object($name, array $options = []) ); } + /** + * Restores an object. + * + * Example: + * ``` + * $object = $bucket->restore('file.txt'); + * ``` + * + * @param string $name The name of the object to restore. + * @param string $generation Request a specific generation of the object. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $ifGenerationMatch Makes the operation conditional on whether + * the object's current generation matches the given value. + * @type string $ifGenerationNotMatch Makes the operation conditional on whether + * the object's current generation matches the given value. + * @type string $ifMetagenerationMatch If set, only restores + * if its metageneration matches this value. + * @type string $ifMetagenerationNotMatch If set, only restores + * if its metageneration does not match this value. + * } + * @return StorageObject + */ + public function restore($name, $generation, array $options = []) + { + $res = $this->connection->restoreObject([ + 'bucket' => $this->identity['bucket'], + 'generation' => $generation, + 'object' => $name, + ] + $options); + return new StorageObject( + $this->connection, + $name, + $this->identity['bucket'], + $res['generation'], // restored object will have a new generation + $res + array_filter([ + 'requesterProjectId' => $this->identity['userProject'] + ]) + ); + } + /** * Fetches all objects in the bucket. * diff --git a/Storage/src/Connection/ConnectionInterface.php b/Storage/src/Connection/ConnectionInterface.php index 71df234e52b9..ea429f5bc705 100644 --- a/Storage/src/Connection/ConnectionInterface.php +++ b/Storage/src/Connection/ConnectionInterface.php @@ -95,6 +95,11 @@ public function patchBucket(array $args = []); */ public function deleteObject(array $args = []); + /** + * @param array $args + */ + public function restoreObject(array $args = []); + /** * @param array $args */ diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index bdd2e419c345..2db6ee7546bb 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -228,6 +228,14 @@ public function deleteObject(array $args = []) return $this->send('objects', 'delete', $args); } + /** + * @param array $args + */ + public function restoreObject(array $args = []) + { + return $this->send('objects', 'restore', $args); + } + /** * @param array $args */ @@ -614,14 +622,22 @@ private function buildDownloadObjectParams(array $args) 'restDelayFunction' => null ]); + $queryOptions = [ + 'generation' => $args['generation'], + 'alt' => 'media', + 'userProject' => $args['userProject'], + ]; + if (isset($args['softDeleted'])) { + // alt param cannot be specified with softDeleted param. See: + // https://cloud.google.com/storage/docs/json_api/v1/objects/get + unset($args['alt']); + $queryOptions['softDeleted'] = $args['softDeleted']; + } + $uri = $this->expandUri($this->apiEndpoint . self::DOWNLOAD_PATH, [ 'bucket' => $args['bucket'], 'object' => $args['object'], - 'query' => [ - 'generation' => $args['generation'], - 'alt' => 'media', - 'userProject' => $args['userProject'] - ] + 'query' => $queryOptions, ]); return [ diff --git a/Storage/tests/Snippet/BucketTest.php b/Storage/tests/Snippet/BucketTest.php index 777fb54c1de6..3c4cb28758c4 100644 --- a/Storage/tests/Snippet/BucketTest.php +++ b/Storage/tests/Snippet/BucketTest.php @@ -519,6 +519,24 @@ public function testDelete() $snippet->invoke(); } + public function testRestore() + { + $snippet = $this->snippetFromMethod(Bucket::class, 'restore'); + $snippet->addLocal('bucket', $this->bucket); + + $this->connection->restoreObject(Argument::any()) + ->willReturn([ + 'name' => 'file.txt', + 'generation' => 'abc' + ]); + + $restoredObject = $this->bucket->restore('file.txt', 'abc'); + + $this->assertInstanceOf(StorageObject::class, $restoredObject); + $this->assertEquals('file.txt', $restoredObject->name()); + $this->assertEquals('abc', $restoredObject->info()['generation']); + } + public function testUpdate() { $snippet = $this->snippetFromMethod(Bucket::class, 'update'); diff --git a/Storage/tests/System/ManageBucketsTest.php b/Storage/tests/System/ManageBucketsTest.php index 17920b78585f..26592de518dd 100644 --- a/Storage/tests/System/ManageBucketsTest.php +++ b/Storage/tests/System/ManageBucketsTest.php @@ -117,6 +117,32 @@ public function testUpdateBucket() $this->assertEquals($options['website'], $info['website']); } + public function testSoftDeletePolicy() + { + $durationSecond = 8*24*60*60; + // set soft delete policy + self::$bucket->update([ + 'softDeletePolicy' => [ + 'retentionDurationSeconds' => $durationSecond + ] + ]); + $this->assertArrayHasKey('softDeletePolicy', self::$bucket->info()); + $this->assertEquals( + $durationSecond, + self::$bucket->info()['softDeletePolicy']['retentionDurationSeconds'] + ); + + // remove soft delete policy + self::$bucket->update([ + 'softDeletePolicy' => [] + ]); + $this->assertArrayHasKey('softDeletePolicy', self::$bucket->info()); + $this->assertEquals( + 0, + self::$bucket->info()['softDeletePolicy']['retentionDurationSeconds'] + ); + } + /** * @group storage-bucket-lifecycle * @dataProvider lifecycleRules diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 780deef6f5d4..406c9013c572 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -284,6 +284,36 @@ public function testComposeObjects($object) $this->assertEquals($name, $composedObject->name()); $this->assertEquals($expectedContent, $composedObject->downloadAsString()); + return $composedObject; + } + + /** + * @depends testComposeObjects + */ + public function testSoftDeleteObject($object) + { + // Set soft delete policy + self::$bucket->update([ + 'softDeletePolicy' => [ + 'retentionDurationSeconds' => 8*24*60*60 + ] + ]); + + $this->assertStorageObjectExists($object); + $generation = $object->info()['generation']; + + $object->delete(); + + $this->assertStorageObjectNotExists($object); + $this->assertStorageObjectExists($object, [ + 'softDeleted' => true, + 'generation' => $generation + ]); + + $restoredObject = self::$bucket->restore($object->name(), $generation); + $this->assertNotEquals($generation, $restoredObject->info()['generation']); + + $this->assertStorageObjectExists($restoredObject); } public function testRotatesCustomerSuppliedEncrpytion() @@ -414,4 +444,21 @@ public function testStringNormalization() $this->assertSame($expectedContent, $actualContent); } } + + private function assertStorageObjectExists($object, $options = [], $isPresent = true) + { + $this->assertEquals($isPresent, $object->exists($options)); + $object = self::$bucket->object($object->name(), $options); + $this->assertEquals($isPresent, $object->exists($options)); + $objects = self::$bucket->objects($options); + $objects = array_map(function ($o) { + return $o->name(); + }, iterator_to_array($objects)); + $this->assertEquals($isPresent, in_array($object->name(), $objects)); + } + + private function assertStorageObjectNotExists($object, $options = []) + { + $this->assertStorageObjectExists($object, $options, false); + } } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 4a5d210084dd..ed01a9ef6f54 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -275,6 +275,22 @@ public function testDelete() $this->assertNull($bucket->delete()); } + public function testRestore() + { + $this->connection->restoreObject(Argument::any()) + ->willReturn([ + 'name' => 'file.txt', + 'generation' => 'abc' + ]); + + $bucket = $this->getBucket(); + $restoredObject = $bucket->restore('file.txt', 'abc'); + + $this->assertInstanceOf(StorageObject::class, $restoredObject); + $this->assertEquals('file.txt', $restoredObject->name()); + $this->assertEquals('abc', $restoredObject->info()['generation']); + } + public function testComposeThrowsExceptionWithLessThanTwoSources() { $this->expectException(InvalidArgumentException::class);