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/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..be963f8 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,45 @@ +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", "8.1"] + + steps: + - 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 }} + + - 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: Send feedback on Github + run: vendor/bin/psalm --output-format=github diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 6d53b96..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.3', '7.4', '8.0'] - - 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..4d46cd5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,48 @@ +# 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] + php-versions: ["7.4", "8.0", "8.1"] + + steps: + - 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 PHPUnit + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 3d7e132..5e0f179 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -vendor/ -composer.lock +/composer.lock +/vendor .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 48ca4e6..674bbac 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,20 @@ -MIT License +The MIT License (MIT) -Copyright (c) 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 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/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. diff --git a/composer.json b/composer.json index 2c87e85..d97f565 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,19 +19,20 @@ "email": "chris@chrisnharvey.com" } ], + "require": { + "php": ">= 7.4", + "league/flysystem": "^2.0", + "php-opencloud/openstack": "^3.2", + "guzzlehttp/psr7": "^2.0" + }, + "require-dev": { + "mockery/mockery": ">= 1.3.1", + "phpunit/phpunit": ">= 5.5", + "vimeo/psalm": "^4.20" + }, "autoload": { "psr-4": { "Nimbusoft\\Flysystem\\OpenStack\\": "src/" } - }, - "require": { - "php": ">=7.3", - "php-opencloud/openstack": "^3.0 | ^3.2", - "league/flysystem": "^1.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.0", - "mockery/mockery": "^1.3.1", - "mikey179/vfsstream": "^1.6.4" } } diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..7c0333d --- /dev/null +++ b/psalm.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/SwiftAdapter.php b/src/SwiftAdapter.php index fce62a7..4065dc6 100644 --- a/src/SwiftAdapter.php +++ b/src/SwiftAdapter.php @@ -1,147 +1,146 @@ setPathPrefix($prefix); + public function __construct( + Container $container, + string $prefix = '' + ) { $this->container = $container; + $this->prefixer = new PathPrefixer($prefix); } /** * {@inheritdoc} */ - public function write($path, $contents, Config $config, $size = 0) + public function write(string $path, string $contents, Config $config): void { - $path = $this->applyPathPrefix($path); - + $path = $this->prefixer->prefixPath($path); $data = $this->getWriteData($path, $config); - $type = 'content'; + $data['content'] = $contents; - if (is_a($contents, 'GuzzleHttp\Psr7\Stream')) { - $type = 'stream'; - } - - $data[$type] = $contents; - - // 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); - - $response = $this->container->createLargeObject($data); - } else { - $response = $this->container->createObject($data); + try { + $this->container->createObject($data); + } catch (BadResponseError $e) { + throw UnableToWriteFile::atLocation($path); } - - return $this->normalizeObject($response); } /** * {@inheritdoc} */ - public function writeStream($path, $resource, Config $config) + public function writeStream(string $path, $contents, Config $config): void { - return $this->write($path, new Stream($resource), $config, Util::getStreamSize($resource)); - } + if (!is_resource($contents)) { + throw new InvalidArgumentException('The $contents parameter must be a resource.'); + } - /** - * {@inheritdoc} - */ - public function update($path, $contents, Config $config) - { - return $this->write($path, $contents, $config); - } + $stream = $this->getStreamFromResource($contents); + $path = $this->prefixer->prefixPath($path); + $data = $this->getWriteData($path, $config); + $data['stream'] = $stream; - /** - * {@inheritdoc} - */ - public function updateStream($path, $resource, Config $config) - { - return $this->writeStream($path, $resource, $config); + 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); + } } /** * {@inheritdoc} */ - public function rename($path, $newpath) + public function move(string $source, string $destination, Config $config): void { - $object = $this->getObject($path); - $newLocation = $this->applyPathPrefix($newpath); - $destination = '/'.$this->container->name.'/'.ltrim($newLocation, '/'); - try { - $response = $object->copy(compact('destination')); + $this->copy($source, $destination, $config); + $this->delete($source); } catch (BadResponseError $e) { - return false; + throw UnableToMoveFile::fromLocationTo($source, $destination, $e); } - - $object->delete(); - - return true; } /** * {@inheritdoc} */ - public function delete($path) + public function delete(string $path): void { - $object = $this->getObjectInstance($path); - try { + $object = $this->getObjectInstance($path); $object->delete(); } catch (BadResponseError $e) { - return false; + throw UnableToDeleteFile::atLocation($path, $e->getMessage(), $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 === '/') { + throw UnableToDeleteDirectory::atLocation($path, 'Will not delete root.'); } $objects = $this->container->listObjects([ - 'prefix' => $this->applyPathPrefix($dirname) + 'prefix' => $this->prefixer->prefixPath($path), ]); try { @@ -150,155 +149,149 @@ public function deleteDir($dirname) $object->delete(); } } catch (BadResponseError $e) { - return false; + throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e); } - - return true; } /** * {@inheritdoc} */ - public function createDir($dirname, Config $config) + public function createDirectory(string $path, Config $config): void { - return ['path' => $dirname]; + throw UnableToCreateDirectory::atLocation($path, 'Not supported.'); } /** * {@inheritdoc} */ - public function has($path) + public function fileExists(string $path): bool { try { - $object = $this->getObject($path); + return $this->container->objectExists($this->prefixer->prefixPath($path)); } catch (BadResponseError $e) { - $code = $e->getResponse()->getStatusCode(); - - if ($code == 404) return false; - - throw $e; + throw UnableToCheckFileExistence::forLocation($path, $e); } - - return $this->normalizeObject($object); } /** * {@inheritdoc} */ - public function read($path) + public function read(string $path): string { - $object = $this->getObject($path); - $data = $this->normalizeObject($object); - - $stream = $object->download(); - $stream->rewind(); - $data['contents'] = $stream->getContents(); + try { + $object = $this->getObject($path); + $stream = $object->download(); + $stream->rewind(); + $contents = $stream->getContents(); + } catch (BadResponseError $e) { + throw UnableToReadFile::fromLocation($path, $e->getMessage()); + } - return $data; + return $contents; } /** - * {@inheritdoc} - */ - public function readStream($path) + * {@inheritdoc} + */ + public function readStream(string $path) { - $object = $this->getObject($path); - $data = $this->normalizeObject($object); + try { + $resource = $this->getObject($path)->download()->detach(); + } catch (BadResponseError $e) { + throw UnableToReadFile::fromLocation($path, $e->getMessage()); + } - $stream = $object->download(); - $stream->rewind(); - $data['stream'] = StreamWrapper::getResource($stream); + if (is_null($resource)) { + throw UnableToReadFile::fromLocation($path); + } - return $data; + return $resource; } /** * {@inheritdoc} */ - public function listContents($directory = '', $recursive = false) + public function listContents(string $path, bool $deep): iterable { - $location = $this->applyPathPrefix($directory); + $location = $this->prefixer->prefixPath($path); $objectList = $this->container->listObjects([ - 'prefix' => $directory + 'prefix' => $location, ]); - $response = iterator_to_array($objectList); - - return Util::emulateDirectories(array_map([$this, 'normalizeObject'], $response)); + foreach ($objectList as $object) { + yield $this->normalizeObject($object); + } } /** * {@inheritdoc} */ - public function getMetadata($path) + public function fileSize(string $path): FileAttributes { - $object = $this->getObject($path); - - return $this->normalizeObject($object); + return $this->getMetadata($path, 'fileSize'); } /** * {@inheritdoc} */ - public function getSize($path) + public function mimeType(string $path): FileAttributes { - return $this->getMetadata($path); + return $this->getMetadata($path, 'mimeType'); } /** * {@inheritdoc} */ - public function getMimetype($path) + public function lastModified(string $path): FileAttributes { - return $this->getMetadata($path); + return $this->getMetadata($path, 'lastModified'); } /** * {@inheritdoc} */ - public function getTimestamp($path) + public function visibility(string $path): FileAttributes { - return $this->getMetadata($path); + throw UnableToRetrieveMetadata::visibility($path, 'Not supported.'); } /** - * Get the data properties to write or update an object. - * - * @param string $path - * @param Config $config - * - * @return array + * {@inheritdoc} */ - protected function getWriteData($path, $config) + public function copy(string $source, string $destination, Config $config): void { - return ['name' => $path]; + $newLocation = $this->prefixer->prefixPath($destination); + $destination = '/' . $this->container->name . '/' . ltrim($newLocation, '/'); + + try { + $this->getObjectInstance($source)->copy(compact('destination')); + } catch (BadResponseError $e) { + throw UnableToCopyFile::fromLocationTo($source, $destination, $e); + } } /** - * Get an object instance. - * - * @param string $path - * - * @return StorageObject + * {@inheritdoc} */ - protected function getObjectInstance($path) + public function setVisibility(string $path, string $visibility): void { - $location = $this->applyPathPrefix($path); + throw UnableToSetVisibility::atLocation($path, 'Not supported.'); + } - $object = $this->container->getObject($location); + protected function getWriteData(string $path, Config $config): array + { + return ['name' => $path]; + } - return $object; + protected function getObjectInstance(string $path): StorageObject + { + $location = $this->prefixer->prefixPath($path); + + return $this->container->getObject($location); } - /** - * Get an object instance and retrieve its metadata from storage. - * - * @param string $path - * - * @return StorageObject - */ - protected function getObject($path) + protected function getObject(string $path): StorageObject { $object = $this->getObjectInstance($path); $object->retrieve(); @@ -306,30 +299,42 @@ protected function getObject($path) return $object; } - /** - * Normalize Openstack "StorageObject" object into an array - * - * @param StorageObject $object - * @return array - */ - protected function normalizeObject(StorageObject $object) + protected 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); } - 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, + $timestamp, + $object->contentType, + [ + 'type' => 'file', + ] + ); + } + + 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 7037f5e..7dc8a02 100644 --- a/tests/SwiftAdapterTest.php +++ b/tests/SwiftAdapterTest.php @@ -1,26 +1,47 @@ 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->object = Mockery::mock(StorageObject::class); + // Object properties. + $this->object->name = 'name'; + $this->object->contentType = 'text/html; charset=UTF-8'; + $this->object->lastModified = new DateTimeImmutable('@1628624822'); + $this->adapter = new SwiftAdapter($this->container); - // for testing the large object support - $this->root = vfsStream::setUp('home'); } protected function tearDown(): void @@ -28,149 +49,77 @@ protected function tearDown(): void Mockery::close(); } - public function testWriteAndUpdate() - { - foreach (['write', 'update'] as $method) { - $this->container->shouldReceive('createObject')->once()->with([ - 'name' => 'hello', - 'content' => 'world' - ])->andReturn($this->object); - - $response = $this->adapter->$method('hello', 'world', $this->config); - - $this->assertEquals($response, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]); - } - } - - public function testWriteAndUpdateStream() + public function testDelete() { - foreach (['writeStream', 'updateStream'] as $method) { - $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); - $psrStream = new Stream($stream); + $this->object->shouldNotReceive('retrieve'); + $this->object->shouldReceive('delete')->once(); - $this->container->shouldReceive('createObject')->once()->with([ - 'name' => 'hello', - 'stream' => $psrStream - ])->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, - ]); - } + $this->assertNull($response); } - public function testWriteAndUpdateLargeStream() + public function testCreateDirectory() { - 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->container->shouldReceive('createLargeObject')->once()->with([ - 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 104857600, - 'segmentContainer' => $this->container->name, - ])->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, - ]); - } + $this->expectException(UnableToCreateDirectory::class); + $this->adapter->createDirectory('hello', $this->config); } - 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'); + $times = mt_rand(1, 10); - foreach (['writeStream', 'updateStream'] as $method) { - // create a large file - $file = vfsStream::newFile('large.txt') - ->withContent(LargeFileContent::withMegabytes(200)) - ->at($this->root); + $generator = function () use ($times) { + for ($i = 1; $i <= $times; ++$i) { + yield $this->object; + } + }; - $stream = fopen(vfsStream::url('home/large.txt'), 'r'); + $objects = $generator(); - $psrStream = new Stream($stream); + $this->container->shouldReceive('listObjects') + ->once() + ->with([ + 'prefix' => 'hello/', + ]) + ->andReturn($objects); - $this->container->shouldReceive('createLargeObject')->once()->with([ - 'name' => 'hello', - 'stream' => $psrStream, - 'segmentSize' => 52428800, // 50 MiB - 'segmentContainer' => 'segmentContainer', - ])->andReturn($this->object); + $this->object->shouldReceive('delete')->times($times); - $response = $this->adapter->$method('hello', $stream, $this->config); - } + $response = $this->adapter->deleteDirectory('hello'); + + $this->assertNull($response); } - public function testRename() + public function testDeleteDirectoryRoot() { - $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() - ->with('hello') - ->andReturn($this->object); - - $response = $this->adapter->rename('hello', 'world'); - - $this->assertTrue($response); + $this->expectException(UnableToDeleteDirectory::class); + $this->adapter->deleteDirectory(''); } - 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); + $this->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; } }; @@ -180,51 +129,53 @@ 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'); + $expect = [ + 'path' => 'name', + 'type' => 'file', + 'last_modified' => 1628624822, + 'mime_type' => 'text/html; charset=UTF-8', + 'visibility' => null, + 'file_size' => 0, + 'extra_metadata' => [ + 'type' => 'file', + ], + ]; - $this->assertTrue($response); - } + $contents = $this->adapter->listContents('hello', false); + $count = 0; - public function testCreateDir() - { - $dir = $this->adapter->createDir('hello', $this->config); + foreach ($contents as $file) { + $this->assertEquals($expect, $file->jsonSerialize()); + $count += 1; + } - $this->assertEquals($dir, [ - 'path' => 'hello' - ]); + $this->assertEquals($times, $count); } - 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') - ->once() + $this->container->shouldReceive('getObject') + ->twice() ->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, - ]); + $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'); @@ -234,130 +185,185 @@ 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'); - $this->assertEquals($data, [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - 'contents' => 'hello world' - ]); + $this->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'); + $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'); - $this->assertEquals('world', stream_get_contents($data['stream'])); + $this->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(); + $this->assertNull($response); + } - $this->container->shouldReceive('listObjects') + public function testWriteStream() + { + $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); + + $this->adapter = new SwiftAdapterStub($this->container); + $this->adapter->streamMock = Mockery::mock(Stream::class); + + $this->adapter->streamMock + ->shouldReceive('getSize') + ->once() + ->andReturn(104857600); // 100 MB + + $this->container->shouldReceive('createObject')->once()->with([ + 'name' => 'hello', + 'stream' => $this->adapter->streamMock, + ])->andReturn($this->object); + + $response = $this->adapter->writeStream('hello', $stream, $this->config); + + $this->assertNull($response); + } + + public function testWriteLargeStream() + { + $stream = fopen('data://text/plain;base64,'.base64_encode('world'), 'r'); + + $this->adapter = new SwiftAdapterStub($this->container); + $this->adapter->streamMock = Mockery::mock(Stream::class); + + $this->adapter->streamMock + ->shouldReceive('getSize') + ->once() + ->andReturn(419430400); // 400 MB + + $this->container->shouldReceive('createLargeObject') ->once() ->with([ - 'prefix' => 'hello' + 'name' => 'hello', + 'stream' => $this->adapter->streamMock, + 'segmentSize' => 104857600, // 100 MiB + 'segmentContainer' => 'container-name', ]) - ->andReturn($objects); + ->andReturn($this->object); - $contents = $this->adapter->listContents('hello'); + $response = $this->adapter->writeStream('hello', $stream, $this->config); - for ($i = 1; $i <= $times; $i++) { - $data[] = [ - 'type' => 'file', - 'dirname' => null, - 'path' => null, - 'timestamp' => null, - 'mimetype' => null, - 'size' => null, - ]; - } + $this->assertNull($response); + } + + public function testWriteLargeStreamConfig() + { + $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']); + + $this->adapter = new SwiftAdapterStub($this->container); + $this->adapter->streamMock = Mockery::mock(Stream::class); + + $this->adapter->streamMock + ->shouldReceive('getSize') + ->once() + ->andReturn(209715200); // 200 MB + + $this->container->shouldReceive('createLargeObject') + ->once() + ->with([ + 'name' => 'hello', + 'stream' => $this->adapter->streamMock, + 'segmentSize' => 52428800, // 50 MiB + 'segmentContainer' => 'segment-container', + ]) + ->andReturn($this->object); - $this->assertEquals($data, $contents); + $response = $this->adapter->writeStream('hello', $stream, $config); + + $this->assertNull($response); } public function testMetadataMethods() { $methods = [ - 'getMetadata', - 'getSize', - 'getMimetype', - 'getTimestamp' + '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' => [ + 'type' => 'file', + ], ]; 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') + $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, - ]); + $this->assertEquals($expect, $metadata->jsonSerialize()); } } - public function testGetTimestampDateTimeImmutable() + public function testSetVisibility() { - $time = new \DateTimeImmutable(date('Y-m-d')); - $this->object->shouldReceive('retrieve')->once(); - $this->object->lastModified = $time; + $this->expectException(UnableToSetVisibility::class); + $this->adapter->setVisibility('hello', 'public'); + } - $this->container - ->shouldReceive('getObject') - ->once() - ->with('hello') - ->andReturn($this->object); + public function testVisibility() + { + $this->expectException(UnableToRetrieveMetadata::class); + $this->adapter->visibility('hello'); + } +} - $metadata = $this->adapter->getTimestamp('hello'); +class SwiftAdapterStub extends SwiftAdapter +{ + public $streamMock; - $this->assertEquals($time->getTimestamp(), $metadata['timestamp']); + protected function getStreamFromResource($resource): Stream + { + return $this->streamMock; } }