From 68c7900340137261ee3bf9e1d2aaa86044e12709 Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 21:07:55 +0200 Subject: [PATCH 01/12] chore: Update composer.json. --- composer.json | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index b3c9c7e..d48af6f 100644 --- a/composer.json +++ b/composer.json @@ -2,8 +2,15 @@ "name": "nimbusoft/flysystem-openstack-swift", "description": "Flysystem adapter for OpenStack Swift", "keywords": [ - "filesystem", "filesystems", "files", "chrisnharvey", - "storage", "flysystem", "openstack", "opencloud", "swift" + "filesystem", + "filesystems", + "files", + "chrisnharvey", + "storage", + "flysystem", + "openstack", + "opencloud", + "swift" ], "license": "MIT", "authors": [ @@ -12,18 +19,26 @@ "email": "chris@chrisnharvey.com" } ], + "require": { + "php": ">= 7.4", + "league/flysystem": "^2.0", + "php-opencloud/openstack": "^3.2", + "psr/http-factory": "^1.0", + "guzzlehttp/psr7": "^2.0" + }, + "require-dev": { + "drupol/php-conventions": "^5", + "mikey179/vfsstream": ">= 1.6.4", + "mockery/mockery": ">= 1.3.1", + "nyholm/psr7": "^1.4", + "phpunit/phpunit": ">= 5.5" + }, + "suggest": { + "nyholm/psr7": "A fast PHP7 implementation of PSR-7." + }, "autoload": { "psr-4": { "Nimbusoft\\Flysystem\\OpenStack\\": "src/" } - }, - "require": { - "php-opencloud/openstack": "^3.0", - "league/flysystem": "^1.0" - }, - "require-dev": { - "phpunit/phpunit": "^5.5", - "mockery/mockery": "^1.3.1", - "mikey179/vfsstream": "^1.6.4" } } From 295a5b454e05ea9e50549a74f534e7dfadd2e279 Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 21:09:31 +0200 Subject: [PATCH 02/12] chore: Update/add static configuration files. --- .editorconfig | 18 ++++++++++++++++++ .gitattributes | 22 ++++++++++++++++++++++ .gitignore | 15 +++++++++++++-- LICENSE | 27 +++++++++++++-------------- grumphp.yml | 9 +++++++++ 5 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 grumphp.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..654c18b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# PHP PSR-2 Coding Standards +# http://www.php-fig.org/psr/psr-2/ + +root = true + +[*.{php,inc,module}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,json.dist,yml,yml.dist}] +indent_size = 4 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f70d5c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +/.github export-ignore +/build export-ignore +/docker export-ignore +/docs export-ignore +/spec export-ignore +/tests export-ignore +.auto-changelog export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.php-cs-fixer.dist.php export-ignore +.scrutinizer.yml export-ignore +infection.json export-ignore +grumphp.yml export-ignore +phpspec.yml export-ignore +docker-compose.yaml export-ignore +psalm-baseline.xml export-ignore +psalm.xml export-ignore +phpstan-baseline.neon export-ignore +phpstan-docs-baseline.neon export-ignore +phpstan-unsupported-baseline.neon export-ignore +phpstan.neon.dist export-ignore diff --git a/.gitignore b/.gitignore index d1502b0..34abe17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ -vendor/ -composer.lock +/composer.lock +/vendor +/build +/docs/_build +/.php_cs.cache +/.php-cs-fixer.cache +/examples/ +/.idea/ +/test.php +/node_modules/ +/benchmarks/ +/.vscode/ +/.phpunit.result.cache diff --git a/LICENSE b/LICENSE index 48ca4e6..e052d98 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,20 @@ -MIT License +The MIT License (MIT) -Copyright (c) Chris Harvey +Copyright (c) 2016-2021 Chris Harvey -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/grumphp.yml b/grumphp.yml new file mode 100644 index 0000000..02d4266 --- /dev/null +++ b/grumphp.yml @@ -0,0 +1,9 @@ +imports: + - { resource: vendor/drupol/php-conventions/config/php73/grumphp.yml } + +parameters: + # GrumPHP License + tasks.license.holder: Chris Harvey + tasks.license.date_from: 2016 + extra_tasks: + phpunit: ~ From 17e338d2be9091aa9f7e57df87a7d9891a0ead55 Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 21:10:16 +0200 Subject: [PATCH 03/12] chore: Add static analysis tools configuration files. --- phpstan-baseline.neon | 37 +++++++++++++++++++++++++++++++++++++ phpstan.neon.dist | 2 ++ psalm-baseline.xml | 2 ++ psalm.xml | 16 ++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 psalm-baseline.xml create mode 100644 psalm.xml diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..c424e38 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,37 @@ +parameters: + ignoreErrors: + - + message: "#^Access to an undefined property Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:\\$mimeTypeDetector\\.$#" + count: 1 + path: src/SwiftAdapter.php + + - + message: "#^Access to an undefined property Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:\\$visibility\\.$#" + count: 1 + path: src/SwiftAdapter.php + + - + message: "#^Else branch is unreachable because previous condition is always true\\.$#" + count: 1 + path: src/SwiftAdapter.php + + - + message: "#^Method Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:getWriteData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SwiftAdapter.php + + - + message: "#^Method Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:writeStream\\(\\) has parameter \\$path with no typehint specified\\.$#" + count: 1 + path: src/SwiftAdapter.php + + - + message: "#^Parameter \\#1 \\$resource of method Psr\\\\Http\\\\Message\\\\StreamFactoryInterface\\:\\:createStreamFromResource\\(\\) expects resource, Psr\\\\Http\\\\Message\\\\StreamInterface given\\.$#" + count: 1 + path: src/SwiftAdapter.php + + - + message: "#^Strict comparison using \\=\\=\\= between false and true will always evaluate to false\\.$#" + count: 1 + path: src/SwiftAdapter.php + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..2ee6a55 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,2 @@ +includes: + - phpstan-baseline.neon diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..a5205c5 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,2 @@ + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..86fd1f4 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,16 @@ + + + + + + + + + From ed21dd16daf8c96f4916adbe8dff7fa74869c99a Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 21:09:57 +0200 Subject: [PATCH 04/12] tests: Update PHPUnit configuration file. --- phpunit.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index ce7d747..e863719 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,7 +8,6 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - syntaxCheck="true" verbose="true" > From 615141a43452d3dd780ac09a4e205b07aeb69936 Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 21:11:00 +0200 Subject: [PATCH 05/12] tests: Refactor the tests. --- tests/SwiftAdapterTest.php | 433 ++++++++++++++++++------------------- 1 file changed, 205 insertions(+), 228 deletions(-) diff --git a/tests/SwiftAdapterTest.php b/tests/SwiftAdapterTest.php index e2e9f2d..413bc78 100644 --- a/tests/SwiftAdapterTest.php +++ b/tests/SwiftAdapterTest.php @@ -1,173 +1,122 @@ config = new Config([]); $this->container = Mockery::mock('OpenStack\ObjectStore\v1\Models\Container'); $this->container->name = 'container-name'; $this->object = Mockery::mock('OpenStack\ObjectStore\v1\Models\StorageObject'); - $this->adapter = new SwiftAdapter($this->container); - // for testing the large object support - $this->root = vfsStream::setUp('home'); - } - - protected function tearDown() - { - Mockery::close(); - } + $this->streamFactory = Mockery::mock(StreamFactoryInterface::class); - public function testWriteAndUpdate() - { - foreach (['write', 'update'] as $method) { - $this->container->shouldReceive('createObject')->once()->with([ - 'name' => 'hello', - 'content' => 'world' - ])->andReturn($this->object); + // Object properties. + $this->object->name = 'name'; + $this->object->contentType = 'text/html; charset=UTF-8'; + $this->object->lastModified = 1628624822; - $response = $this->adapter->$method('hello', 'world', $this->config); + $this->adapter = new SwiftAdapter($this->container, $this->streamFactory); - $this->assertEquals($response, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]); - } + // for testing the large object support + $this->root = vfsStream::setUp('home'); } - public function testWriteAndUpdateStream() + protected function tearDown(): void { - foreach (['writeStream', 'updateStream'] as $method) { - $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); - $psrStream = new Stream($stream); - - $this->container->shouldReceive('createObject')->once()->with([ - 'name' => 'hello', - 'stream' => $psrStream - ])->andReturn($this->object); - - $response = $this->adapter->$method('hello', $stream, $this->config); - - $this->assertEquals($response, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]); - } + Mockery::close(); } - public function testWriteAndUpdateLargeStream() + public function testDelete() { - foreach (['writeStream', 'updateStream'] as $method) { - // create a large file - $file = vfsStream::newFile('large.txt') - ->withContent(LargeFileContent::withMegabytes(400)) - ->at($this->root); - - $stream = fopen(vfsStream::url('home/large.txt'), 'r'); - - $psrStream = new Stream($stream); + $this->object->shouldNotReceive('retrieve'); + $this->object->shouldReceive('delete')->once(); - $this->container->shouldReceive('createLargeObject')->once()->with([ - 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 104857600, - 'segmentContainer' => $this->container->name, - ])->andReturn($this->object); + $this->container->shouldReceive('getObject') + ->once() + ->with('hello') + ->andReturn($this->object); - $response = $this->adapter->$method('hello', $stream, $this->config); + $response = $this->adapter->delete('hello'); - $this->assertEquals($response, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]); - } + self::assertNull($response); } - public function testWriteAndUpdateLargeStreamConfig() + public function testDeleteDirectory() { - $this->config->set('swiftLargeObjectThreshold', 104857600); // 100 MiB - $this->config->set('swiftSegmentSize', 52428800); // 50 MiB - $this->config->set('swiftSegmentContainer', 'segmentContainer'); - - foreach (['writeStream', 'updateStream'] as $method) { - // create a large file - $file = vfsStream::newFile('large.txt') - ->withContent(LargeFileContent::withMegabytes(200)) - ->at($this->root); - - $stream = fopen(vfsStream::url('home/large.txt'), 'r'); - - $psrStream = new Stream($stream); + $times = mt_rand(1, 10); - $this->container->shouldReceive('createLargeObject')->once()->with([ - 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 52428800, // 50 MiB - 'segmentContainer' => 'segmentContainer', - ])->andReturn($this->object); - - $response = $this->adapter->$method('hello', $stream, $this->config); - } - } + $generator = function () use ($times) { + for ($i = 1; $i <= $times; ++$i) { + yield $this->object; + } + }; - public function testRename() - { - $this->object->shouldReceive('retrieve')->once(); - $this->object->shouldReceive('copy')->once()->with([ - 'destination' => '/container-name/world' - ]); - $this->object->shouldReceive('delete')->once(); + $objects = $generator(); - $this->container->shouldReceive('getObject') + $this->container->shouldReceive('listObjects') ->once() - ->with('hello') - ->andReturn($this->object); + ->with([ + 'prefix' => 'hello/', + ]) + ->andReturn($objects); + + $this->object->shouldReceive('delete')->times($times); - $response = $this->adapter->rename('hello', 'world'); + $response = $this->adapter->deleteDirectory('hello'); - $this->assertTrue($response); + self::assertNull($response); } - public function testDelete() + public function testFileExists() { - $this->object->shouldNotReceive('retrieve'); - $this->object->shouldReceive('delete')->once(); - - $this->container->shouldReceive('getObject') + $this->container + ->shouldReceive('objectExists') ->once() ->with('hello') - ->andReturn($this->object); + ->andReturn(true); - $response = $this->adapter->delete('hello'); + $fileExists = $this->adapter->fileExists('hello'); - $this->assertTrue($response); + self::assertTrue($fileExists); } - public function testDeleteDir() + public function testListContents() { - $times = rand(1, 10); + $times = mt_rand(1, 10); - $generator = function() use ($times) { - for ($i = 1; $i <= $times; $i++) { + $generator = function () use ($times) { + for ($i = 1; $i <= $times; ++$i) { yield $this->object; } }; @@ -177,46 +126,51 @@ public function testDeleteDir() $this->container->shouldReceive('listObjects') ->once() ->with([ - 'prefix' => 'hello/' + 'prefix' => 'hello', ]) ->andReturn($objects); - $this->object->shouldReceive('delete')->times($times); - - $response = $this->adapter->deleteDir('hello'); + $contents = array_map( + static function (FileAttributes $fileAttributes): array { + return $fileAttributes->jsonSerialize(); + }, + iterator_to_array($this->adapter->listContents('hello', false)) + ); - $this->assertTrue($response); - } - - public function testCreateDir() - { - $dir = $this->adapter->createDir('hello', $this->config); + for ($i = 1; $i <= $times; ++$i) { + $data[] = [ + 'path' => 'name', + 'type' => 'file', + 'last_modified' => 1628624822, + 'mime_type' => 'text/html; charset=UTF-8', + 'visibility' => null, + 'file_size' => 0, + 'extra_metadata' => [ + 'dirname' => 'name', + 'type' => 'file', + ], + ]; + } - $this->assertEquals($dir, [ - 'path' => 'hello' - ]); + self::assertEquals($data, $contents); } - public function testHas() + public function testMove() { $this->object->shouldReceive('retrieve')->once(); + $this->object->shouldReceive('copy')->once()->with([ + 'destination' => '/container-name/world', + ]); + $this->object->shouldReceive('delete')->once(); - $this->container - ->shouldReceive('getObject') + $this->container->shouldReceive('getObject') ->once() ->with('hello') ->andReturn($this->object); - $has = $this->adapter->has('hello'); + $response = $this->adapter->move('hello', 'world', $this->config); - $this->assertEquals($has, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]); + self::assertNull($response); } public function testRead() @@ -239,21 +193,13 @@ public function testRead() $data = $this->adapter->read('hello'); - $this->assertEquals($data, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - 'contents' => 'hello world' - ]); + self::assertEquals($data, 'hello world'); } public function testReadStream() { - $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); - $psrStream = new Stream($stream); + $resource = fopen('data://text/plain;base64,' . base64_encode('world'), 'rb'); + $psrStream = (new Psr17Factory())->createStreamFromResource($resource); $this->object->shouldReceive('retrieve')->once(); $this->object->shouldReceive('download') @@ -268,93 +214,124 @@ public function testReadStream() $data = $this->adapter->readStream('hello'); - $this->assertEquals('world', stream_get_contents($data['stream'])); + self::assertEquals('world', stream_get_contents($data)); } - public function testListContents() + public function testWrite() { - $times = rand(1, 10); + $this + ->container + ->shouldReceive('createObject') + ->once() + ->with([ + 'name' => 'hello', + 'content' => 'world', + ]) + ->andReturn($this->object); - $generator = function() use ($times) { - for ($i = 1; $i <= $times; $i++) { - yield $this->object; - } - }; + $response = $this->adapter->write('hello', 'world', $this->config); - $objects = $generator(); + self::assertNull($response); + } - $this->container->shouldReceive('listObjects') + public function testWriteAndUpdateLargeStreamConfig() + { + $config = $this + ->config + ->extend(['swiftLargeObjectThreshold' => 104857600]) // 100 MiB + ->extend(['swiftSegmentSize' => 52428800]) // 50 MiB + ->extend(['swiftSegmentContainer' => 'segmentContainer']); + + vfsStream::newFile('large.txt') + ->withContent(LargeFileContent::withMegabytes(200)) + ->at($this->root); + + $stream = fopen(vfsStream::url('home/large.txt'), 'rb'); + $psrStream = (new Psr17Factory())->createStreamFromResource($stream); + + $this + ->streamFactory + ->shouldReceive('createStreamFromResource') + ->once() + ->with($stream) + ->andReturn($psrStream); + + $this + ->container + ->shouldReceive('createLargeObject') ->once() ->with([ - 'prefix' => 'hello' + 'name' => 'hello', + 'stream' => $psrStream, + 'segmentSize' => 52428800, // 50 MiB + 'segmentContainer' => 'segmentContainer', ]) - ->andReturn($objects); - - $contents = $this->adapter->listContents('hello'); + ->andReturn($this->object); - for ($i = 1; $i <= $times; $i++) { - $data[] = [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]; - } + $response = $this->adapter->writeStream('hello', $stream, $config); - $this->assertEquals($data, $contents); + self::assertNull($response); } - public function testMetadataMethods() + public function testWriteResource() { - $methods = [ - 'getMetadata', - 'getSize', - 'getMimetype', - 'getTimestamp' - ]; - - foreach ($methods as $method) { - $this->object->shouldReceive('retrieve')->once(); - $this->object->name = 'hello/world'; - $this->object->lastModified = date('Y-m-d'); - $this->object->contentType = 'mimetype'; - $this->object->contentLength = 1234; - - $this->container - ->shouldReceive('getObject') - ->once() - ->with('hello') - ->andReturn($this->object); - - $metadata = $this->adapter->$method('hello'); - - $this->assertEquals($metadata, [ - 'type' => 'file', - 'dirname' => 'hello', - 'path' => 'hello/world', - 'timestamp' => strtotime(date('Y-m-d')), - 'mimetype' => 'mimetype', - 'size' => 1234, - ]); - } + $stream = fopen('data://text/plain;base64,' . base64_encode('world'), 'rb'); + $psrStream = (new Psr17Factory())->createStreamFromResource($stream); + + $this + ->streamFactory + ->shouldReceive('createStreamFromResource') + ->once() + ->with($stream) + ->andReturn($psrStream); + + $this + ->container + ->shouldReceive('createLargeObject') + ->once() + ->with([ + 'name' => 'hello', + 'stream' => $psrStream, + 'segmentSize' => 104857600, + 'segmentContainer' => 'container-name', + ]) + ->andReturn($this->object); + + $response = $this->adapter->writeStream('hello', $stream, $this->config); + + self::assertNull($response); } - public function testGetTimestampDateTimeImmutable() + public function testWriteStream() { - $time = new \DateTimeImmutable(date('Y-m-d')); - $this->object->shouldReceive('retrieve')->once(); - $this->object->lastModified = $time; + vfsStream::newFile('large.txt') + ->withContent(LargeFileContent::withMegabytes(400)) + ->at($this->root); - $this->container - ->shouldReceive('getObject') + $stream = fopen(vfsStream::url('home/large.txt'), 'rb'); + $psrStream = (new Psr17Factory())->createStreamFromResource($stream); + + $this + ->streamFactory + ->shouldReceive('createStreamFromResource') ->once() - ->with('hello') + ->with($stream) + ->andReturn($psrStream); + + $this + ->container + ->shouldReceive('createLargeObject') + ->once() + ->with([ + 'name' => 'hello', + 'stream' => $psrStream, + 'segmentSize' => 104857600, // 100 MiB + 'segmentContainer' => 'container-name', + ]) ->andReturn($this->object); - $metadata = $this->adapter->getTimestamp('hello'); + $response = $this->adapter->writeStream('hello', $stream, $this->config); - $this->assertEquals($time->getTimestamp(), $metadata['timestamp']); + self::assertNull($response); } } From d7a6001bdd35d54f2626011a88d5fbc9b0592b8c Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 18:00:55 +0200 Subject: [PATCH 06/12] refactor: Rewrite the adapter so it works with Flysystem 2. --- src/SwiftAdapter.php | 385 ++++++++++++++++++------------------------- 1 file changed, 160 insertions(+), 225 deletions(-) diff --git a/src/SwiftAdapter.php b/src/SwiftAdapter.php index fc6bc24..4353208 100644 --- a/src/SwiftAdapter.php +++ b/src/SwiftAdapter.php @@ -1,146 +1,103 @@ setPathPrefix($prefix); - $this->container = $container; - } +use function is_resource; - /** - * {@inheritdoc} - */ - public function write($path, $contents, Config $config, $size = 0) - { - $path = $this->applyPathPrefix($path); +final class SwiftAdapter implements FilesystemAdapter +{ + private Container $container; - $data = $this->getWriteData($path, $config); - $type = 'content'; + private PathPrefixer $prefixer; - if (is_a($contents, 'GuzzleHttp\Psr7\Stream')) { - $type = 'stream'; - } + private StreamFactoryInterface $streamFactory; - $data[$type] = $contents; + public function __construct( + Container $container, + StreamFactoryInterface $streamFactory, + string $prefix = '', + ?VisibilityConverter $visibility = null, + ?MimeTypeDetector $mimeTypeDetector = null + ) { + $this->container = $container; + $this->streamFactory = $streamFactory; + $this->prefixer = new PathPrefixer($prefix); + $this->visibility = $visibility ?: new PortableVisibilityConverter(); + $this->mimeTypeDetector = $mimeTypeDetector ?: new FinfoMimeTypeDetector(); + } - // Create large object if the stream is larger than 300 MiB (default). - if ($type === 'stream' && $size > $config->get('swiftLargeObjectThreshold', 314572800)) { - // Set the segment size to 100 MiB by default as suggested in OVH docs. - $data['segmentSize'] = $config->get('swiftSegmentSize', 104857600); - // Set segment container to the same container by default. - $data['segmentContainer'] = $config->get('swiftSegmentContainer', $this->container->name); + public function copy(string $source, string $destination, Config $config): void + { + $stream = $this->readStream($source); - $response = $this->container->createLargeObject($data); - } else { - $response = $this->container->createObject($data); + if (false === is_resource($stream)) { + throw UnableToCopyFile::fromLocationTo($source, $destination); } - return $this->normalizeObject($response); - } - - /** - * {@inheritdoc} - */ - public function writeStream($path, $resource, Config $config) - { - return $this->write($path, new Stream($resource), $config, Util::getStreamSize($resource)); - } + $this->writeStream($destination, $stream, $config); - /** - * {@inheritdoc} - */ - public function update($path, $contents, Config $config) - { - return $this->write($path, $contents, $config); + fclose($stream); } - /** - * {@inheritdoc} - */ - public function updateStream($path, $resource, Config $config) + public function createDirectory(string $path, Config $config): void { - return $this->write($path, new Stream($resource), $config, Util::getStreamSize($resource)); + // TODO } - /** - * {@inheritdoc} - */ - public function rename($path, $newpath) - { - $object = $this->getObject($path); - $newLocation = $this->applyPathPrefix($newpath); - $destination = '/'.$this->container->name.'/'.ltrim($newLocation, '/'); - - try { - $response = $object->copy(compact('destination')); - } catch (BadResponseError $e) { - return false; - } - - $object->delete(); - - return true; - } - - /** - * {@inheritdoc} - */ - public function delete($path) + public function delete(string $path): void { $object = $this->getObjectInstance($path); try { $object->delete(); } catch (BadResponseError $e) { - return false; + throw UnableToDeleteFile::atLocation($path, '', $e); } - - return true; } - /** - * {@inheritdoc} - */ - public function deleteDir($dirname) + public function deleteDirectory(string $path): void { // Make sure a slash is added to the end. - $dirname = rtrim(trim($dirname), '/') . '/'; + $path = rtrim(trim($path), '/') . '/'; // To be safe, don't delete everything. - if($dirname === '/') { - return false; + if ('/' === $path) { + return; } $objects = $this->container->listObjects([ - 'prefix' => $this->applyPathPrefix($dirname) + 'prefix' => $this->prefixer->prefixPath($path), ]); try { @@ -149,155 +106,125 @@ public function deleteDir($dirname) $object->delete(); } } catch (BadResponseError $e) { - return false; + throw UnableToDeleteDirectory::atLocation($path, '', $e); } + } - return true; + public function fileExists(string $path): bool + { + try { + return $this->container->objectExists($this->prefixer->prefixPath($path)); + } catch (Throwable $exception) { + throw UnableToCheckFileExistence::forLocation($path, $exception); + } } - /** - * {@inheritdoc} - */ - public function createDir($dirname, Config $config) + public function fileSize(string $path): FileAttributes { - return ['path' => $dirname]; + return $this->getMetadata($path); } - /** - * {@inheritdoc} - */ - public function has($path) + public function lastModified(string $path): FileAttributes { - try { - $object = $this->getObject($path); - } catch (BadResponseError $e) { - $code = $e->getResponse()->getStatusCode(); + return $this->getMetadata($path); + } - if ($code == 404) return false; + public function listContents(string $path, bool $deep): iterable + { + $location = $this->prefixer->prefixPath($path); - throw $e; - } + $objectList = $this->container->listObjects([ + 'prefix' => $location, + ]); - return $this->normalizeObject($object); + foreach ($objectList as $object) { + yield $this->normalizeObject($object); + } } - /** - * {@inheritdoc} - */ - public function read($path) + public function mimeType(string $path): FileAttributes { - $object = $this->getObject($path); - $data = $this->normalizeObject($object); - - $stream = $object->download(); - $stream->rewind(); - $data['contents'] = $stream->getContents(); - - return $data; + return $this->getMetadata($path); } - /** - * {@inheritdoc} - */ - public function readStream($path) + public function move(string $source, string $destination, Config $config): void { - $object = $this->getObject($path); - $data = $this->normalizeObject($object); + $object = $this->getObject($source); + $newLocation = $this->prefixer->prefixPath($destination); + $destination = '/' . $this->container->name . '/' . ltrim($newLocation, '/'); - $stream = $object->download(); - $stream->rewind(); - $data['stream'] = StreamWrapper::getResource($stream); + try { + $object->copy(compact('destination')); + } catch (BadResponseError $e) { + throw UnableToMoveFile::fromLocationTo($source, $destination, $e); + } - return $data; + $object->delete(); } - /** - * {@inheritdoc} - */ - public function listContents($directory = '', $recursive = false) + public function read(string $path): string { - $location = $this->applyPathPrefix($directory); - - $objectList = $this->container->listObjects([ - 'prefix' => $directory - ]); + $object = $this->getObject($path); - $response = iterator_to_array($objectList); + $stream = $object->download(); + $stream->rewind(); - return Util::emulateDirectories(array_map([$this, 'normalizeObject'], $response)); + return $stream->getContents(); } - /** - * {@inheritdoc} - */ - public function getMetadata($path) + public function readStream(string $path) { $object = $this->getObject($path); - return $this->normalizeObject($object); - } + $stream = $object->download(); + $stream->rewind(); - /** - * {@inheritdoc} - */ - public function getSize($path) - { - return $this->getMetadata($path); + return StreamWrapper::getResource($stream); } - /** - * {@inheritdoc} - */ - public function getMimetype($path) + public function setVisibility(string $path, string $visibility): void { - return $this->getMetadata($path); + throw UnableToSetVisibility::atLocation($path); } - /** - * {@inheritdoc} - */ - public function getTimestamp($path) + public function visibility(string $path): FileAttributes { - return $this->getMetadata($path); + throw UnableToRetrieveMetadata::visibility($path); } - /** - * Get the data properties to write or update an object. - * - * @param string $path - * @param Config $config - * - * @return array - */ - protected function getWriteData($path, $config) + public function write(string $path, string $contents, Config $config): void { - return ['name' => $path]; + $this + ->container + ->createObject( + $this->getWriteData($this->prefixer->prefixPath($path), $config) + ['content' => $contents] + ); } - /** - * Get an object instance. - * - * @param string $path - * - * @return StorageObject - */ - protected function getObjectInstance($path) + public function writeStream($path, $contents, Config $config): void { - $location = $this->applyPathPrefix($path); + if (!is_resource($contents)) { + throw new InvalidArgumentException('The $contents parameter must be a resource.'); + } - $object = $this->container->getObject($location); + $data = $this->getWriteData($this->prefixer->prefixPath($path), $config) + + ['stream' => $this->streamFactory->createStreamFromResource($contents)]; + $data['segmentSize'] = $config->get('swiftSegmentSize', 104857600); + $data['segmentContainer'] = $config->get('swiftSegmentContainer', $this->container->name); - return $object; + $this + ->container + ->createLargeObject( + $data + ); + } + + private function getMetadata(string $path): FileAttributes + { + return $this->normalizeObject($this->getObject($path)); } - /** - * Get an object instance and retrieve its metadata from storage. - * - * @param string $path - * - * @return StorageObject - */ - protected function getObject($path) + private function getObject(string $path): StorageObject { $object = $this->getObjectInstance($path); $object->retrieve(); @@ -305,30 +232,38 @@ protected function getObject($path) return $object; } - /** - * Normalize Openstack "StorageObject" object into an array - * - * @param StorageObject $object - * @return array - */ - protected function normalizeObject(StorageObject $object) + private function getObjectInstance(string $path): StorageObject + { + $location = $this->prefixer->prefixPath($path); + + return $this->container->getObject($location); + } + + private function getWriteData(string $path, Config $config): array + { + return ['name' => $path]; + } + + private function normalizeObject(StorageObject $object): FileAttributes { - $name = $this->removePathPrefix($object->name); - $mimetype = explode('; ', $object->contentType); + $name = $this->prefixer->stripPrefix($object->name); - if ($object->lastModified instanceof \DateTimeInterface) { + if ($object->lastModified instanceof DateTimeInterface) { $timestamp = $object->lastModified->getTimestamp(); } else { - $timestamp = strtotime($object->lastModified); + $timestamp = $object->lastModified; } - return [ - 'type' => 'file', - 'dirname' => Util::dirname($name), - 'path' => $name, - 'timestamp' => $timestamp, - 'mimetype' => reset($mimetype), - 'size' => $object->contentLength, - ]; + return new FileAttributes( + $name, + (int) $object->contentLength, + null, + (int) $timestamp, + $object->contentType, + [ + 'type' => 'file', + 'dirname' => $this->prefixer->prefixPath($object->name), + ] + ); } } From 79ff4a0c20a846fb7efd139438725f38be670640 Mon Sep 17 00:00:00 2001 From: Pol Dellaiera Date: Tue, 10 Aug 2021 22:16:54 +0200 Subject: [PATCH 07/12] ci: Update Github workflow. --- .github/workflows/code-style.yml | 59 ++++++++++++++++++++++++ .github/workflows/static-analysis.yml | 64 +++++++++++++++++++++++++++ .github/workflows/test.yml | 34 -------------- .github/workflows/tests.yml | 59 ++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/code-style.yml create mode 100644 .github/workflows/static-analysis.yml delete mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..abc61ea --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,59 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +on: + push: + branches: + - master + pull_request: + +name: "Code style" + +jobs: + run: + name: "Code Style" + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-latest] + php-versions: ["7.4", "8.0"] + + steps: + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - name: Checkout + uses: actions/checkout@v2.3.4 + with: + fetch-depth: 1 + + - name: Install PHP + uses: shivammathur/setup-php@2.12.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: gd,mbstring,pcov + tools: cs2pr + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2.1.6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Run Grumphp + run: vendor/bin/grumphp run --testsuite=cs -n + + - name: Send feedback on Github + if: ${{ failure() }} + run: | + vendor/bin/php-cs-fixer --allow-risky=yes --config=.php-cs-fixer.dist.php fix --dry-run --format=checkstyle | cs2pr diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..910178f --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,64 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +on: + push: + branches: + - master + pull_request: + +name: "Static analysis" + +jobs: + run: + name: "Static Analysis" + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-latest] + php-versions: ["7.4", "8.0"] + + steps: + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - name: Checkout + uses: actions/checkout@v2.3.4 + with: + fetch-depth: 1 + + - name: Install PHP + uses: shivammathur/setup-php@2.12.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: gd,mbstring,pcov,xdebug + tools: cs2pr + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2.1.6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Run Grumphp + run: vendor/bin/grumphp run --tasks=psalm,phpstan -n + + - name: Send feedback on Github + if: ${{ failure() }} + run: | + vendor/bin/phpstan analyse -l max --error-format=checkstyle src/ | cs2pr + vendor/bin/psalm --output-format=github | cs2pr + + - name: Send PSALM data + run: vendor/bin/psalm --shepherd --stats src/ + continue-on-error: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 7ab8266..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Test - -on: - push: - - pull_request: - -jobs: - run: - runs-on: ubuntu-latest - strategy: - matrix: - php-versions: ['7.0', '7.1', '7.2', '7.3', '7.4'] - - name: PHP ${{ matrix.php-versions }} test - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: mbstring, intl - ini-values: post_max_size=256M, short_open_tag=On - coverage: xdebug - tools: composer - - - name: Composer install - run: composer install - - - name: Run tests - run: vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7637e2f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,59 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +on: + push: + branches: + - master + pull_request: + +name: "Unit tests" + +jobs: + run: + name: "Unit Tests" + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-latest, windows-latest] + php-versions: ["7.4", "8.0"] + + steps: + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - name: Checkout + uses: actions/checkout@v2.3.4 + with: + fetch-depth: 1 + + - name: Install PHP + uses: shivammathur/setup-php@2.12.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: gd,mbstring,pcov,xdebug + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2.1.6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Run Grumphp + run: vendor/bin/grumphp run --tasks=phpunit + + - name: Send Scrutinizer data + run: | + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover build/logs/clover.xml + continue-on-error: true From 891d1acbe1e1f0835218d57c71355958e7bd3bca Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 17 Feb 2022 11:27:33 +0100 Subject: [PATCH 08/12] Refactor support for Flysystem 2 --- .gitattributes | 22 -- .github/workflows/code-style.yml | 59 ------ .github/workflows/static-analysis.yml | 22 +- .github/workflows/tests.yml | 19 +- .gitignore | 12 +- .scrutinizer.yml | 33 --- LICENSE | 2 +- composer.json | 7 +- grumphp.yml | 9 - phpstan-baseline.neon | 37 ---- phpstan.neon.dist | 2 - psalm-baseline.xml | 2 - psalm.xml | 1 - src/SwiftAdapter.php | 290 ++++++++++++++++---------- tests/SwiftAdapterTest.php | 282 ++++++++++++++----------- 15 files changed, 349 insertions(+), 450 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .github/workflows/code-style.yml delete mode 100644 .scrutinizer.yml delete mode 100644 grumphp.yml delete mode 100644 phpstan-baseline.neon delete mode 100644 phpstan.neon.dist delete mode 100644 psalm-baseline.xml diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index f70d5c2..0000000 --- a/.gitattributes +++ /dev/null @@ -1,22 +0,0 @@ -/.github export-ignore -/build export-ignore -/docker export-ignore -/docs export-ignore -/spec export-ignore -/tests export-ignore -.auto-changelog export-ignore -.editorconfig export-ignore -.gitattributes export-ignore -.gitignore export-ignore -.php-cs-fixer.dist.php export-ignore -.scrutinizer.yml export-ignore -infection.json export-ignore -grumphp.yml export-ignore -phpspec.yml export-ignore -docker-compose.yaml export-ignore -psalm-baseline.xml export-ignore -psalm.xml export-ignore -phpstan-baseline.neon export-ignore -phpstan-docs-baseline.neon export-ignore -phpstan-unsupported-baseline.neon export-ignore -phpstan.neon.dist export-ignore diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml deleted file mode 100644 index abc61ea..0000000 --- a/.github/workflows/code-style.yml +++ /dev/null @@ -1,59 +0,0 @@ -# https://help.github.com/en/categories/automating-your-workflow-with-github-actions - -on: - push: - branches: - - master - pull_request: - -name: "Code style" - -jobs: - run: - name: "Code Style" - runs-on: ${{ matrix.operating-system }} - strategy: - fail-fast: false - matrix: - operating-system: [ubuntu-latest] - php-versions: ["7.4", "8.0"] - - steps: - - name: Set git to use LF - run: | - git config --global core.autocrlf false - git config --global core.eol lf - - - name: Checkout - uses: actions/checkout@v2.3.4 - with: - fetch-depth: 1 - - - name: Install PHP - uses: shivammathur/setup-php@2.12.0 - with: - php-version: ${{ matrix.php-versions }} - extensions: gd,mbstring,pcov - tools: cs2pr - - - name: Get Composer Cache Directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v2.1.6 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: composer install --no-progress --prefer-dist --optimize-autoloader - - - name: Run Grumphp - run: vendor/bin/grumphp run --testsuite=cs -n - - - name: Send feedback on Github - if: ${{ failure() }} - run: | - vendor/bin/php-cs-fixer --allow-risky=yes --config=.php-cs-fixer.dist.php fix --dry-run --format=checkstyle | cs2pr diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 910178f..cfe3bc9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,5 +1,3 @@ -# https://help.github.com/en/categories/automating-your-workflow-with-github-actions - on: push: branches: @@ -16,14 +14,9 @@ jobs: fail-fast: false matrix: operating-system: [ubuntu-latest] - php-versions: ["7.4", "8.0"] + php-versions: ["7.4", "8.0", "8.1"] steps: - - name: Set git to use LF - run: | - git config --global core.autocrlf false - git config --global core.eol lf - - name: Checkout uses: actions/checkout@v2.3.4 with: @@ -33,7 +26,6 @@ jobs: uses: shivammathur/setup-php@2.12.0 with: php-version: ${{ matrix.php-versions }} - extensions: gd,mbstring,pcov,xdebug tools: cs2pr - name: Get Composer Cache Directory @@ -50,15 +42,5 @@ jobs: - name: Install dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader - - name: Run Grumphp - run: vendor/bin/grumphp run --tasks=psalm,phpstan -n - - name: Send feedback on Github - if: ${{ failure() }} - run: | - vendor/bin/phpstan analyse -l max --error-format=checkstyle src/ | cs2pr - vendor/bin/psalm --output-format=github | cs2pr - - - name: Send PSALM data - run: vendor/bin/psalm --shepherd --stats src/ - continue-on-error: true + run: vendor/bin/psalm --output-format=github | cs2pr diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7637e2f..4d46cd5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,15 +15,10 @@ jobs: strategy: fail-fast: false matrix: - operating-system: [ubuntu-latest, windows-latest] - php-versions: ["7.4", "8.0"] + operating-system: [ubuntu-latest] + php-versions: ["7.4", "8.0", "8.1"] steps: - - name: Set git to use LF - run: | - git config --global core.autocrlf false - git config --global core.eol lf - - name: Checkout uses: actions/checkout@v2.3.4 with: @@ -49,11 +44,5 @@ jobs: - name: Install dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader - - name: Run Grumphp - run: vendor/bin/grumphp run --tasks=phpunit - - - name: Send Scrutinizer data - run: | - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover build/logs/clover.xml - continue-on-error: true + - name: Run PHPUnit + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 34abe17..5e0f179 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,3 @@ /composer.lock /vendor -/build -/docs/_build -/.php_cs.cache -/.php-cs-fixer.cache -/examples/ -/.idea/ -/test.php -/node_modules/ -/benchmarks/ -/.vscode/ -/.phpunit.result.cache +.phpunit.result.cache diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 04bc29e..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,33 +0,0 @@ -filter: - paths: [src/*] - excluded_paths: [tests/*] -checks: - php: - code_rating: true - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true -tools: - external_code_coverage: - timeout: 1200 - runs: 3 - php_code_coverage: false - php_code_sniffer: - config: - standard: PSR2 - filter: - paths: ['src'] - php_loc: - enabled: true - excluded_dirs: [vendor] - php_sim: false diff --git a/LICENSE b/LICENSE index e052d98..674bbac 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2021 Chris Harvey +Copyright (c) 2016 Chris Harvey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/composer.json b/composer.json index d48af6f..e6d2d2d 100644 --- a/composer.json +++ b/composer.json @@ -23,15 +23,12 @@ "php": ">= 7.4", "league/flysystem": "^2.0", "php-opencloud/openstack": "^3.2", - "psr/http-factory": "^1.0", "guzzlehttp/psr7": "^2.0" }, "require-dev": { - "drupol/php-conventions": "^5", - "mikey179/vfsstream": ">= 1.6.4", "mockery/mockery": ">= 1.3.1", - "nyholm/psr7": "^1.4", - "phpunit/phpunit": ">= 5.5" + "phpunit/phpunit": ">= 5.5", + "vimeo/psalm": "^4.20" }, "suggest": { "nyholm/psr7": "A fast PHP7 implementation of PSR-7." diff --git a/grumphp.yml b/grumphp.yml deleted file mode 100644 index 02d4266..0000000 --- a/grumphp.yml +++ /dev/null @@ -1,9 +0,0 @@ -imports: - - { resource: vendor/drupol/php-conventions/config/php73/grumphp.yml } - -parameters: - # GrumPHP License - tasks.license.holder: Chris Harvey - tasks.license.date_from: 2016 - extra_tasks: - phpunit: ~ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index c424e38..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,37 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Access to an undefined property Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:\\$mimeTypeDetector\\.$#" - count: 1 - path: src/SwiftAdapter.php - - - - message: "#^Access to an undefined property Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:\\$visibility\\.$#" - count: 1 - path: src/SwiftAdapter.php - - - - message: "#^Else branch is unreachable because previous condition is always true\\.$#" - count: 1 - path: src/SwiftAdapter.php - - - - message: "#^Method Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:getWriteData\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/SwiftAdapter.php - - - - message: "#^Method Nimbusoft\\\\Flysystem\\\\OpenStack\\\\SwiftAdapter\\:\\:writeStream\\(\\) has parameter \\$path with no typehint specified\\.$#" - count: 1 - path: src/SwiftAdapter.php - - - - message: "#^Parameter \\#1 \\$resource of method Psr\\\\Http\\\\Message\\\\StreamFactoryInterface\\:\\:createStreamFromResource\\(\\) expects resource, Psr\\\\Http\\\\Message\\\\StreamInterface given\\.$#" - count: 1 - path: src/SwiftAdapter.php - - - - message: "#^Strict comparison using \\=\\=\\= between false and true will always evaluate to false\\.$#" - count: 1 - path: src/SwiftAdapter.php - diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 2ee6a55..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,2 +0,0 @@ -includes: - - phpstan-baseline.neon diff --git a/psalm-baseline.xml b/psalm-baseline.xml deleted file mode 100644 index a5205c5..0000000 --- a/psalm-baseline.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/psalm.xml b/psalm.xml index 86fd1f4..7c0333d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,7 +5,6 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorBaseline="psalm-baseline.xml" > diff --git a/src/SwiftAdapter.php b/src/SwiftAdapter.php index 4353208..d1912eb 100644 --- a/src/SwiftAdapter.php +++ b/src/SwiftAdapter.php @@ -10,7 +10,7 @@ namespace Nimbusoft\Flysystem\OpenStack; use DateTimeInterface; -use GuzzleHttp\Psr7\StreamWrapper; +use GuzzleHttp\Psr7\Stream; use InvalidArgumentException; use League\Flysystem\Config; use League\Flysystem\FileAttributes; @@ -18,11 +18,14 @@ use League\Flysystem\PathPrefixer; use League\Flysystem\UnableToCheckFileExistence; use League\Flysystem\UnableToCopyFile; +use League\Flysystem\UnableToCreateDirectory; use League\Flysystem\UnableToDeleteDirectory; use League\Flysystem\UnableToDeleteFile; use League\Flysystem\UnableToMoveFile; +use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; use League\Flysystem\UnableToSetVisibility; +use League\Flysystem\UnableToWriteFile; use League\Flysystem\UnixVisibility\PortableVisibilityConverter; use League\Flysystem\UnixVisibility\VisibilityConverter; use League\MimeTypeDetection\FinfoMimeTypeDetector; @@ -30,70 +33,110 @@ use OpenStack\Common\Error\BadResponseError; use OpenStack\ObjectStore\v1\Models\Container; use OpenStack\ObjectStore\v1\Models\StorageObject; -use Psr\Http\Message\StreamFactoryInterface; -use Throwable; -use function is_resource; - -final class SwiftAdapter implements FilesystemAdapter +class SwiftAdapter implements FilesystemAdapter { - private Container $container; - - private PathPrefixer $prefixer; + protected Container $container; - private StreamFactoryInterface $streamFactory; + protected PathPrefixer $prefixer; + /** + * Create a new instance. + * + * @param Container $container The OpenStack container. + * @param string $prefix Optional path prefix to apply to all operations. + */ public function __construct( Container $container, - StreamFactoryInterface $streamFactory, - string $prefix = '', - ?VisibilityConverter $visibility = null, - ?MimeTypeDetector $mimeTypeDetector = null + string $prefix = '' ) { $this->container = $container; - $this->streamFactory = $streamFactory; $this->prefixer = new PathPrefixer($prefix); - $this->visibility = $visibility ?: new PortableVisibilityConverter(); - $this->mimeTypeDetector = $mimeTypeDetector ?: new FinfoMimeTypeDetector(); } - public function copy(string $source, string $destination, Config $config): void + /** + * {@inheritdoc} + */ + public function write(string $path, string $contents, Config $config): void { - $stream = $this->readStream($source); + $path = $this->prefixer->prefixPath($path); + $data = $this->getWriteData($path, $config); + $data['content'] = $contents; - if (false === is_resource($stream)) { - throw UnableToCopyFile::fromLocationTo($source, $destination); + try { + $this->container->createObject($data); + } catch (BadResponseError $e) { + throw UnableToWriteFile::atLocation($path); } + } - $this->writeStream($destination, $stream, $config); + /** + * {@inheritdoc} + */ + public function writeStream(string $path, $contents, Config $config): void + { + if (!is_resource($contents)) { + throw new InvalidArgumentException('The $contents parameter must be a resource.'); + } + + $stream = $this->getStreamFromResource($contents); + $path = $this->prefixer->prefixPath($path); + $data = $this->getWriteData($path, $config); + $data['stream'] = $stream; - fclose($stream); + try { + // Create large object if the stream is larger than 300 MiB (default). + if ($stream->getSize() > $config->get('swiftLargeObjectThreshold', 314572800)) { + // Set the segment size to 100 MiB by default as suggested in OVH docs. + $data['segmentSize'] = $config->get('swiftSegmentSize', 104857600); + $data['segmentContainer'] = $config->get('swiftSegmentContainer', $this->container->name); + + $this->container->createLargeObject($data); + } else { + $this->container->createObject($data); + } + } catch (BadResponseError $e) { + throw UnableToWriteFile::atLocation($path); + } } - public function createDirectory(string $path, Config $config): void + /** + * {@inheritdoc} + */ + public function move(string $source, string $destination, Config $config): void { - // TODO + try { + $this->copy($source, $destination, $config); + $this->delete($source); + } catch (BadResponseError $e) { + throw UnableToMoveFile::fromLocationTo($source, $destination, $e); + } } + /** + * {@inheritdoc} + */ public function delete(string $path): void { - $object = $this->getObjectInstance($path); - try { + $object = $this->getObjectInstance($path); $object->delete(); } catch (BadResponseError $e) { - throw UnableToDeleteFile::atLocation($path, '', $e); + throw UnableToDeleteFile::atLocation($path, $e->getMessage(), $e); } } + /** + * {@inheritdoc} + */ public function deleteDirectory(string $path): void { // Make sure a slash is added to the end. $path = rtrim(trim($path), '/') . '/'; // To be safe, don't delete everything. - if ('/' === $path) { - return; + if ($path === '/') { + throw UnableToDeleteDirectory::atLocation($path, 'Will not delete root.'); } $objects = $this->container->listObjects([ @@ -106,29 +149,68 @@ public function deleteDirectory(string $path): void $object->delete(); } } catch (BadResponseError $e) { - throw UnableToDeleteDirectory::atLocation($path, '', $e); + throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e); } } + /** + * {@inheritdoc} + */ + public function createDirectory(string $path, Config $config): void + { + throw UnableToCreateDirectory::atLocation($path, 'Not supported.'); + } + + /** + * {@inheritdoc} + */ public function fileExists(string $path): bool { try { return $this->container->objectExists($this->prefixer->prefixPath($path)); - } catch (Throwable $exception) { - throw UnableToCheckFileExistence::forLocation($path, $exception); + } catch (BadResponseError $e) { + throw UnableToCheckFileExistence::forLocation($path, $e); } } - public function fileSize(string $path): FileAttributes + /** + * {@inheritdoc} + */ + public function read(string $path): string { - return $this->getMetadata($path); + try { + $object = $this->getObject($path); + $stream = $object->download(); + $stream->rewind(); + $contents = $stream->getContents(); + } catch (BadResponseError $e) { + throw UnableToReadFile::fromLocation($path, $e->getMessage()); + } + + return $contents; } - public function lastModified(string $path): FileAttributes + /** + * {@inheritdoc} + */ + public function readStream(string $path) { - return $this->getMetadata($path); + try { + $resource = $this->getObject($path)->download()->detach(); + } catch (BadResponseError $e) { + throw UnableToReadFile::fromLocation($path, $e->getMessage()); + } + + if (is_null($resource)) { + throw UnableToReadFile::fromLocation($path); + } + + return $resource; } + /** + * {@inheritdoc} + */ public function listContents(string $path, bool $deep): iterable { $location = $this->prefixer->prefixPath($path); @@ -142,123 +224,96 @@ public function listContents(string $path, bool $deep): iterable } } - public function mimeType(string $path): FileAttributes - { - return $this->getMetadata($path); - } - - public function move(string $source, string $destination, Config $config): void - { - $object = $this->getObject($source); - $newLocation = $this->prefixer->prefixPath($destination); - $destination = '/' . $this->container->name . '/' . ltrim($newLocation, '/'); - - try { - $object->copy(compact('destination')); - } catch (BadResponseError $e) { - throw UnableToMoveFile::fromLocationTo($source, $destination, $e); - } - - $object->delete(); - } - - public function read(string $path): string + /** + * {@inheritdoc} + */ + public function fileSize(string $path): FileAttributes { - $object = $this->getObject($path); - - $stream = $object->download(); - $stream->rewind(); - - return $stream->getContents(); + return $this->getMetadata($path, 'fileSize'); } - public function readStream(string $path) + /** + * {@inheritdoc} + */ + public function mimeType(string $path): FileAttributes { - $object = $this->getObject($path); - - $stream = $object->download(); - $stream->rewind(); - - return StreamWrapper::getResource($stream); + return $this->getMetadata($path, 'mimeType'); } - public function setVisibility(string $path, string $visibility): void + /** + * {@inheritdoc} + */ + public function lastModified(string $path): FileAttributes { - throw UnableToSetVisibility::atLocation($path); + return $this->getMetadata($path, 'lastModified'); } + /** + * {@inheritdoc} + */ public function visibility(string $path): FileAttributes { - throw UnableToRetrieveMetadata::visibility($path); + throw UnableToRetrieveMetadata::visibility($path, 'Not supported.'); } - public function write(string $path, string $contents, Config $config): void + /** + * {@inheritdoc} + */ + public function copy(string $source, string $destination, Config $config): void { - $this - ->container - ->createObject( - $this->getWriteData($this->prefixer->prefixPath($path), $config) + ['content' => $contents] - ); - } + $newLocation = $this->prefixer->prefixPath($destination); + $destination = '/' . $this->container->name . '/' . ltrim($newLocation, '/'); - public function writeStream($path, $contents, Config $config): void - { - if (!is_resource($contents)) { - throw new InvalidArgumentException('The $contents parameter must be a resource.'); + try { + $this->getObjectInstance($source)->copy(compact('destination')); + } catch (BadResponseError $e) { + throw UnableToCopyFile::fromLocationTo($source, $destination, $e); } - - $data = $this->getWriteData($this->prefixer->prefixPath($path), $config) + - ['stream' => $this->streamFactory->createStreamFromResource($contents)]; - $data['segmentSize'] = $config->get('swiftSegmentSize', 104857600); - $data['segmentContainer'] = $config->get('swiftSegmentContainer', $this->container->name); - - $this - ->container - ->createLargeObject( - $data - ); } - private function getMetadata(string $path): FileAttributes + /** + * {@inheritdoc} + */ + public function setVisibility(string $path, string $visibility): void { - return $this->normalizeObject($this->getObject($path)); + throw UnableToSetVisibility::atLocation($path, 'Not supported.'); } - private function getObject(string $path): StorageObject + protected function getWriteData(string $path, Config $config): array { - $object = $this->getObjectInstance($path); - $object->retrieve(); - - return $object; + return ['name' => $path]; } - private function getObjectInstance(string $path): StorageObject + protected function getObjectInstance(string $path): StorageObject { $location = $this->prefixer->prefixPath($path); return $this->container->getObject($location); } - private function getWriteData(string $path, Config $config): array + protected function getObject(string $path): StorageObject { - return ['name' => $path]; + $object = $this->getObjectInstance($path); + $object->retrieve(); + + return $object; } - private function normalizeObject(StorageObject $object): FileAttributes + protected function normalizeObject(StorageObject $object): FileAttributes { $name = $this->prefixer->stripPrefix($object->name); if ($object->lastModified instanceof DateTimeInterface) { $timestamp = $object->lastModified->getTimestamp(); } else { - $timestamp = $object->lastModified; + $timestamp = strtotime($object->lastModified); } return new FileAttributes( $name, (int) $object->contentLength, null, - (int) $timestamp, + $timestamp, $object->contentType, [ 'type' => 'file', @@ -266,4 +321,21 @@ private function normalizeObject(StorageObject $object): FileAttributes ] ); } + + protected function getMetadata(string $path, string $type): FileAttributes + { + try { + return $this->normalizeObject($this->getObject($path)); + } catch (BadResponseError $e) { + throw UnableToRetrieveMetadata::$type($path, $e->getMessage(), $e); + } + } + + /** + * @param resource $resource + */ + protected function getStreamFromResource($resource): Stream + { + return new Stream($resource); + } } diff --git a/tests/SwiftAdapterTest.php b/tests/SwiftAdapterTest.php index 413bc78..b588954 100644 --- a/tests/SwiftAdapterTest.php +++ b/tests/SwiftAdapterTest.php @@ -7,21 +7,20 @@ declare(strict_types=1); +use GuzzleHttp\Psr7\Stream; use League\Flysystem\Config; use League\Flysystem\FileAttributes; +use League\Flysystem\UnableToCreateDirectory; +use League\Flysystem\UnableToDeleteDirectory; +use League\Flysystem\UnableToRetrieveMetadata; +use League\Flysystem\UnableToSetVisibility; use Mockery\LegacyMockInterface; use Nimbusoft\Flysystem\OpenStack\SwiftAdapter; -use Nyholm\Psr7\Factory\Psr17Factory; -use org\bovigo\vfs\content\LargeFileContent; -use org\bovigo\vfs\vfsStream; +use OpenStack\ObjectStore\v1\Models\Container; +use OpenStack\ObjectStore\v1\Models\StorageObject; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\StreamFactoryInterface; -/** - * @internal - * @coversNothing - */ -final class SwiftAdapterTest extends TestCase +class SwiftAdapterTest extends TestCase { private SwiftAdapter $adapter; @@ -31,25 +30,18 @@ final class SwiftAdapterTest extends TestCase private LegacyMockInterface $object; - private LegacyMockInterface $streamFactory; - protected function setUp(): void { $this->config = new Config([]); - $this->container = Mockery::mock('OpenStack\ObjectStore\v1\Models\Container'); + $this->container = Mockery::mock(Container::class); $this->container->name = 'container-name'; - $this->object = Mockery::mock('OpenStack\ObjectStore\v1\Models\StorageObject'); - $this->streamFactory = Mockery::mock(StreamFactoryInterface::class); - + $this->object = Mockery::mock(StorageObject::class); // Object properties. $this->object->name = 'name'; $this->object->contentType = 'text/html; charset=UTF-8'; - $this->object->lastModified = 1628624822; + $this->object->lastModified = new DateTimeImmutable('@1628624822'); - $this->adapter = new SwiftAdapter($this->container, $this->streamFactory); - - // for testing the large object support - $this->root = vfsStream::setUp('home'); + $this->adapter = new SwiftAdapter($this->container); } protected function tearDown(): void @@ -69,7 +61,13 @@ public function testDelete() $response = $this->adapter->delete('hello'); - self::assertNull($response); + $this->assertNull($response); + } + + public function testCreateDirectory() + { + $this->expectException(UnableToCreateDirectory::class); + $this->adapter->createDirectory('hello', $this->config); } public function testDeleteDirectory() @@ -95,20 +93,25 @@ public function testDeleteDirectory() $response = $this->adapter->deleteDirectory('hello'); - self::assertNull($response); + $this->assertNull($response); + } + + public function testDeleteDirectoryRoot() + { + $this->expectException(UnableToDeleteDirectory::class); + $this->adapter->deleteDirectory(''); } public function testFileExists() { - $this->container - ->shouldReceive('objectExists') + $this->container->shouldReceive('objectExists') ->once() ->with('hello') ->andReturn(true); $fileExists = $this->adapter->fileExists('hello'); - self::assertTrue($fileExists); + $this->assertTrue($fileExists); } public function testListContents() @@ -130,52 +133,50 @@ public function testListContents() ]) ->andReturn($objects); - $contents = array_map( - static function (FileAttributes $fileAttributes): array { - return $fileAttributes->jsonSerialize(); - }, - iterator_to_array($this->adapter->listContents('hello', false)) - ); - - for ($i = 1; $i <= $times; ++$i) { - $data[] = [ - 'path' => 'name', + $expect = [ + 'path' => 'name', + 'type' => 'file', + 'last_modified' => 1628624822, + 'mime_type' => 'text/html; charset=UTF-8', + 'visibility' => null, + 'file_size' => 0, + 'extra_metadata' => [ + 'dirname' => 'name', 'type' => 'file', - 'last_modified' => 1628624822, - 'mime_type' => 'text/html; charset=UTF-8', - 'visibility' => null, - 'file_size' => 0, - 'extra_metadata' => [ - 'dirname' => 'name', - 'type' => 'file', - ], - ]; + ], + ]; + + $contents = $this->adapter->listContents('hello', false); + $count = 0; + + foreach ($contents as $file) { + $this->assertEquals($expect, $file->jsonSerialize()); + $count += 1; } - self::assertEquals($data, $contents); + $this->assertEquals($times, $count); } public function testMove() { - $this->object->shouldReceive('retrieve')->once(); $this->object->shouldReceive('copy')->once()->with([ 'destination' => '/container-name/world', ]); $this->object->shouldReceive('delete')->once(); $this->container->shouldReceive('getObject') - ->once() + ->twice() ->with('hello') ->andReturn($this->object); $response = $this->adapter->move('hello', 'world', $this->config); - self::assertNull($response); + $this->assertNull($response); } public function testRead() { - $stream = Mockery::mock('GuzzleHttp\Psr7\Stream'); + $stream = Mockery::mock(Stream::class); $stream->shouldReceive('close'); $stream->shouldReceive('rewind'); $stream->shouldReceive('getContents')->once()->andReturn('hello world'); @@ -185,43 +186,39 @@ public function testRead() ->once() ->andReturn($stream); - $this->container - ->shouldReceive('getObject') + $this->container->shouldReceive('getObject') ->once() ->with('hello') ->andReturn($this->object); $data = $this->adapter->read('hello'); - self::assertEquals($data, 'hello world'); + $this->assertEquals($data, 'hello world'); } public function testReadStream() { $resource = fopen('data://text/plain;base64,' . base64_encode('world'), 'rb'); - $psrStream = (new Psr17Factory())->createStreamFromResource($resource); + $stream = new Stream($resource); $this->object->shouldReceive('retrieve')->once(); $this->object->shouldReceive('download') ->once() - ->andReturn($psrStream); + ->andReturn($stream); - $this->container - ->shouldReceive('getObject') + $this->container->shouldReceive('getObject') ->once() ->with('hello') ->andReturn($this->object); $data = $this->adapter->readStream('hello'); - self::assertEquals('world', stream_get_contents($data)); + $this->assertEquals('world', stream_get_contents($data)); } public function testWrite() { - $this - ->container - ->shouldReceive('createObject') + $this->container->shouldReceive('createObject') ->once() ->with([ 'name' => 'hello', @@ -231,107 +228,144 @@ public function testWrite() $response = $this->adapter->write('hello', 'world', $this->config); - self::assertNull($response); + $this->assertNull($response); } - public function testWriteAndUpdateLargeStreamConfig() + public function testWriteStream() { - $config = $this - ->config - ->extend(['swiftLargeObjectThreshold' => 104857600]) // 100 MiB - ->extend(['swiftSegmentSize' => 52428800]) // 50 MiB - ->extend(['swiftSegmentContainer' => 'segmentContainer']); + $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); - vfsStream::newFile('large.txt') - ->withContent(LargeFileContent::withMegabytes(200)) - ->at($this->root); + $this->adapter = new SwiftAdapterStub($this->container); + $this->adapter->streamMock = Mockery::mock(Stream::class); - $stream = fopen(vfsStream::url('home/large.txt'), 'rb'); - $psrStream = (new Psr17Factory())->createStreamFromResource($stream); - - $this - ->streamFactory - ->shouldReceive('createStreamFromResource') + $this->adapter->streamMock + ->shouldReceive('getSize') ->once() - ->with($stream) - ->andReturn($psrStream); + ->andReturn(104857600); // 100 MB - $this - ->container - ->shouldReceive('createLargeObject') - ->once() - ->with([ - 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 52428800, // 50 MiB - 'segmentContainer' => 'segmentContainer', - ]) - ->andReturn($this->object); + $this->container->shouldReceive('createObject')->once()->with([ + 'name' => 'hello', + 'stream' => $this->adapter->streamMock, + ])->andReturn($this->object); - $response = $this->adapter->writeStream('hello', $stream, $config); + $response = $this->adapter->writeStream('hello', $stream, $this->config); - self::assertNull($response); + $this->assertNull($response); } - public function testWriteResource() + public function testWriteLargeStream() { - $stream = fopen('data://text/plain;base64,' . base64_encode('world'), 'rb'); - $psrStream = (new Psr17Factory())->createStreamFromResource($stream); + $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); + + $this->adapter = new SwiftAdapterStub($this->container); + $this->adapter->streamMock = Mockery::mock(Stream::class); - $this - ->streamFactory - ->shouldReceive('createStreamFromResource') + $this->adapter->streamMock + ->shouldReceive('getSize') ->once() - ->with($stream) - ->andReturn($psrStream); + ->andReturn(419430400); // 400 MB - $this - ->container - ->shouldReceive('createLargeObject') + $this->container->shouldReceive('createLargeObject') ->once() ->with([ 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 104857600, + 'stream' => $this->adapter->streamMock, + 'segmentSize' => 104857600, // 100 MiB 'segmentContainer' => 'container-name', ]) ->andReturn($this->object); $response = $this->adapter->writeStream('hello', $stream, $this->config); - self::assertNull($response); + $this->assertNull($response); } - public function testWriteStream() + public function testWriteLargeStreamConfig() { - vfsStream::newFile('large.txt') - ->withContent(LargeFileContent::withMegabytes(400)) - ->at($this->root); + $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); + + $config = $this->config + ->extend(['swiftLargeObjectThreshold' => 104857600]) // 100 MiB + ->extend(['swiftSegmentSize' => 52428800]) // 50 MiB + ->extend(['swiftSegmentContainer' => 'segment-container']); - $stream = fopen(vfsStream::url('home/large.txt'), 'rb'); - $psrStream = (new Psr17Factory())->createStreamFromResource($stream); + $this->adapter = new SwiftAdapterStub($this->container); + $this->adapter->streamMock = Mockery::mock(Stream::class); - $this - ->streamFactory - ->shouldReceive('createStreamFromResource') + $this->adapter->streamMock + ->shouldReceive('getSize') ->once() - ->with($stream) - ->andReturn($psrStream); + ->andReturn(209715200); // 200 MB - $this - ->container - ->shouldReceive('createLargeObject') + $this->container->shouldReceive('createLargeObject') ->once() ->with([ 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 104857600, // 100 MiB - 'segmentContainer' => 'container-name', + 'stream' => $this->adapter->streamMock, + 'segmentSize' => 52428800, // 50 MiB + 'segmentContainer' => 'segment-container', ]) ->andReturn($this->object); - $response = $this->adapter->writeStream('hello', $stream, $this->config); + $response = $this->adapter->writeStream('hello', $stream, $config); + + $this->assertNull($response); + } + + public function testMetadataMethods() + { + $methods = [ + 'fileSize', + 'mimeType', + 'lastModified' + ]; + + $expect = [ + 'path' => 'name', + 'type' => 'file', + 'last_modified' => 1628624822, + 'mime_type' => 'text/html; charset=UTF-8', + 'visibility' => null, + 'file_size' => 0, + 'extra_metadata' => [ + 'dirname' => 'name', + 'type' => 'file', + ], + ]; + + foreach ($methods as $method) { + $this->object->shouldReceive('retrieve')->once(); - self::assertNull($response); + $this->container->shouldReceive('getObject') + ->once() + ->with('hello') + ->andReturn($this->object); + + $metadata = $this->adapter->$method('hello'); + + $this->assertEquals($expect, $metadata->jsonSerialize()); + } + } + + public function testSetVisibility() + { + $this->expectException(UnableToSetVisibility::class); + $this->adapter->setVisibility('hello', 'public'); + } + + public function testVisibility() + { + $this->expectException(UnableToRetrieveMetadata::class); + $this->adapter->visibility('hello'); + } +} + +class SwiftAdapterStub extends SwiftAdapter +{ + public $streamMock; + + protected function getStreamFromResource($resource): Stream + { + return $this->streamMock; } } From ddf65ad1872fead3633029dbbbc269a5d76949d4 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 17 Feb 2022 11:39:54 +0100 Subject: [PATCH 09/12] Fix static analysis workflow --- .github/workflows/static-analysis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index cfe3bc9..be963f8 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -26,7 +26,6 @@ jobs: uses: shivammathur/setup-php@2.12.0 with: php-version: ${{ matrix.php-versions }} - tools: cs2pr - name: Get Composer Cache Directory id: composer-cache @@ -43,4 +42,4 @@ jobs: run: composer install --no-progress --prefer-dist --optimize-autoloader - name: Send feedback on Github - run: vendor/bin/psalm --output-format=github | cs2pr + run: vendor/bin/psalm --output-format=github From 97b4f3432d791d628a716f58273ef461f8de55df Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 17 Feb 2022 13:53:02 +0100 Subject: [PATCH 10/12] Remove dirname from extra metadata --- src/SwiftAdapter.php | 1 - tests/SwiftAdapterTest.php | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/SwiftAdapter.php b/src/SwiftAdapter.php index d1912eb..4065dc6 100644 --- a/src/SwiftAdapter.php +++ b/src/SwiftAdapter.php @@ -317,7 +317,6 @@ protected function normalizeObject(StorageObject $object): FileAttributes $object->contentType, [ 'type' => 'file', - 'dirname' => $this->prefixer->prefixPath($object->name), ] ); } diff --git a/tests/SwiftAdapterTest.php b/tests/SwiftAdapterTest.php index b588954..7dc8a02 100644 --- a/tests/SwiftAdapterTest.php +++ b/tests/SwiftAdapterTest.php @@ -141,7 +141,6 @@ public function testListContents() 'visibility' => null, 'file_size' => 0, 'extra_metadata' => [ - 'dirname' => 'name', 'type' => 'file', ], ]; @@ -328,7 +327,6 @@ public function testMetadataMethods() 'visibility' => null, 'file_size' => 0, 'extra_metadata' => [ - 'dirname' => 'name', 'type' => 'file', ], ]; From 649b2706b505f1e371e45899a30624b7fec35a34 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 17 Feb 2022 13:57:17 +0100 Subject: [PATCH 11/12] Remove suggest from composer.json --- composer.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/composer.json b/composer.json index e6d2d2d..d97f565 100644 --- a/composer.json +++ b/composer.json @@ -30,9 +30,6 @@ "phpunit/phpunit": ">= 5.5", "vimeo/psalm": "^4.20" }, - "suggest": { - "nyholm/psr7": "A fast PHP7 implementation of PSR-7." - }, "autoload": { "psr-4": { "Nimbusoft\\Flysystem\\OpenStack\\": "src/" From 85ab8b3e40dc37d156ce26e921680adb75d33e91 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Thu, 17 Feb 2022 14:01:06 +0100 Subject: [PATCH 12/12] Update readme for new large object behavior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f52416..630e1d9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ $flysystem = new League\Flysystem\Filesystem($adapter); ## Configuration -The Swift adapter allows you to configure the behavior of uploading [large objects](https://php-opencloudopenstack.readthedocs.io/en/latest/services/object-store/v1/objects.html#create-a-large-object-over-5gb). You can set the following configuration options: +The Swift adapter allows you to configure the behavior of uploading [large objects](https://php-opencloudopenstack.readthedocs.io/en/latest/services/object-store/v1/objects.html#create-a-large-object-over-5gb) with `writeStream()`. You can set the following configuration options: - `swiftLargeObjectThreshold`: Size of the file in bytes when to switch over to the large object upload procedure. Default is 300 MiB. The maximum allowed size of regular objects is 5 GiB. - `swiftSegmentSize`: Size of individual segments or chunks that the large file is split up into. Default is 100 MiB. Should be below 5 GiB.