diff --git a/.travis.yml b/.travis.yml index abfab0a..3344ca8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,11 @@ cache: directories: - $HOME/.composer/cache/files +env: + global: + - AWS_KEY: + - AWS_SECRET: + php: - 5.6 - 7.0 diff --git a/README.md b/README.md index c907b96..6c4e037 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,26 @@ Provides extras functionality around Gaufrette like Resolvable filesystem. + +### Resolvable filesystem + +`ResolvableFilesystem` is a decorator permitting to resolve objects paths into URLs. + +In order to use it, you have to pass the decorated Filesystem and a Resolver: + + $client = // AwsS3 client instantiation + $decorated = new Filesystem(new AwsS3($client, 'my_bucket', ['directory' => 'root/dir'])); + $filesystem = new ResolvableFilesystem( + $decorated, + new AwsS3PresignedUrlResolver($client, 'my_bucket', 'root/dir') + ); + +Then you can call `resolve($key)`: + + $filesystem->resolve('/foo.png'); // = 'https://... + +Currently these resolvers are supported: + +* AwsS3PublicUrlResolver +* AwsS3PresignedUrlResolver +* StaticUrlResolver diff --git a/composer.json b/composer.json index 0f35cd2..f880ee5 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,8 @@ }, "require-dev": { "phpspec/phpspec": "^3.4", - "phpunit/phpunit": "^5" + "phpunit/phpunit": "^5", + "aws/aws-sdk-php": "~3" }, "license": "MIT", "authors": [ @@ -32,5 +33,10 @@ "psr-4": { "Gaufrette\\Extras\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Gaufrette\\Extras\\Tests\\": "tests/" + } } } diff --git a/phpspec.yml b/phpspec.yml new file mode 100644 index 0000000..da33e94 --- /dev/null +++ b/phpspec.yml @@ -0,0 +1,4 @@ +suites: + default: + namespace: Gaufrette\Extras + psr4_prefix: Gaufrette\Extras diff --git a/spec/Resolvable/ResolvableFilesystemSpec.php b/spec/Resolvable/ResolvableFilesystemSpec.php new file mode 100644 index 0000000..71f5312 --- /dev/null +++ b/spec/Resolvable/ResolvableFilesystemSpec.php @@ -0,0 +1,142 @@ +beConstructedWith($decorated, $resolver); + } + + function it_is_initializable() + { + $this->shouldHaveType(ResolvableFilesystem::class); + } + + function it_is_a_filesystem() + { + $this->shouldImplement(FilesystemInterface::class); + } + + function it_resolves_object_path_into_uri($resolver) + { + $resolver->resolve('foo')->willReturn('https://yolo.com/my_image.png'); + $this->resolve('foo')->shouldReturn('https://yolo.com/my_image.png'); + } + + function it_throws_unresolvable_object_exception_if_any_error_happen_during_resolution($resolver) + { + $resolver->resolve('foo')->willThrow(\Exception::class); + $this->shouldThrow(UnresolvableObjectException::class)->duringResolve('foo'); + } + + function it_delegates_has_to_decorated_filesystem($decorated) + { + $decorated->has('foo')->willReturn(true); + $this->has('foo')->shouldReturn(true); + } + + function it_delegates_rename_to_decorated_filesystem($decorated) + { + $decorated->rename('foo', 'bar')->willReturn(true); + $this->rename('foo', 'bar')->shouldReturn(true); + } + + function it_delegates_get_to_decorated_filesystem($decorated, File $file) + { + $decorated->get('foo', true)->willReturn($file); + $this->get('foo', true)->shouldReturn($file); + } + + function it_delegates_write_to_decorated_filesystem($decorated) + { + $decorated->write('foo', 'content', true)->willReturn(7); + $this->write('foo', 'content', true)->shouldReturn(7); + } + + function it_delegates_read_to_decorated_filesystem($decorated) + { + $decorated->read('foo')->willReturn('content'); + $this->read('foo')->shouldReturn('content'); + } + + function it_delegates_delete_to_decorated_filesystem($decorated) + { + $decorated->delete('foo')->willReturn(true); + $this->delete('foo')->shouldReturn(true); + } + + function it_delegates_keys_to_decorated_filesystem($decorated) + { + $decorated->keys()->willReturn(['foo', 'bar']); + $this->keys()->shouldReturn(['foo', 'bar']); + } + + function it_delegates_list_keys_to_decorated_filesystem($decorated) + { + $decorated->listKeys('aze/*')->willReturn(['keys' => ['foo', 'bar']]); + $this->listKeys('aze/*')->shouldReturn(['keys' => ['foo', 'bar']]); + } + + function it_delegates_mime_type_retrieval_to_decorated_filesystem($decorated) + { + $decorated->mtime('foo')->willReturn(123); + $this->mtime('foo')->shouldReturn(123); + } + + function it_delegates_checksum_computation_to_decorated_filesystem($decorated) + { + $decorated->checksum('foo')->willReturn('abcdef'); + $this->checksum('foo')->shouldReturn('abcdef'); + } + + function it_delegates_size_computation_to_decorated_filesystem($decorated) + { + $decorated->size('foo')->willReturn(123); + $this->size('foo')->shouldReturn(123); + } + + function it_delegates_stream_creation_to_decorated_filesystem($decorated, Stream $stream) + { + $decorated->createStream('foo')->willReturn($stream); + $this->createStream('foo')->shouldReturn($stream); + } + + function it_delegates_file_creation_to_decorated_filesystem($decorated, File $file) + { + $decorated->createFile('foo')->willReturn($file); + $this->createFile('foo')->shouldReturn($file); + } + + function it_delegates_mime_type_guessing_to_decorated_filesystem($decorated) + { + $decorated->mimeType('foo')->willReturn('application/json'); + $this->mimeType('foo')->shouldReturn('application/json'); + } + + function it_delegates_is_directory_to_delegated_filesystem($decorated) + { + $decorated->isDirectory('foo')->willReturn(false); + $this->isDirectory('foo')->shouldReturn(false); + } + + function it_delegates_any_other_method_call_to_decorated_filesystem($decorated) + { + $decorated->otherMethod()->shouldBeCalled(); + + $this->otherMethod(); + } +} + +interface MockedFilesystem extends FilesystemInterface { + public function otherMethod(); +} diff --git a/spec/Resolvable/Resolver/AwsS3PresignedUrlResolverSpec.php b/spec/Resolvable/Resolver/AwsS3PresignedUrlResolverSpec.php new file mode 100644 index 0000000..dc61080 --- /dev/null +++ b/spec/Resolvable/Resolver/AwsS3PresignedUrlResolverSpec.php @@ -0,0 +1,41 @@ +beConstructedWith($client, 'bucket', '/base/dir/', new \DateTime('+12 hour')); + } + + function it_is_initializable() + { + $this->shouldHaveType(AwsS3PresignedUrlResolver::class); + } + + function it_is_a_resolver() + { + $this->shouldImplement(ResolverInterface::class); + } + + function it_resolves_object_path_into_presigned_url( + $client, + CommandInterface $command, + RequestInterface $presignedRequest + ) { + $client->getCommand('GetObject', ['Bucket' => 'bucket', 'Key' => 'base/dir/foo.png'])->willReturn($command); + $client->createPresignedRequest($command, Argument::type(\DateTime::class))->willReturn($presignedRequest); + $presignedRequest->getUri()->willReturn('https://amazon'); + + $this->resolve('/foo.png')->shouldReturn('https://amazon'); + } +} diff --git a/spec/Resolvable/Resolver/AwsS3PublicUrlResolverSpec.php b/spec/Resolvable/Resolver/AwsS3PublicUrlResolverSpec.php new file mode 100644 index 0000000..788bc81 --- /dev/null +++ b/spec/Resolvable/Resolver/AwsS3PublicUrlResolverSpec.php @@ -0,0 +1,33 @@ +beConstructedWith($service, 'bucket', 'base/dir/'); + } + + function it_is_initializable() + { + $this->shouldHaveType(AwsS3PublicUrlResolver::class); + } + + function it_is_a_resolver() + { + $this->shouldImplement(ResolverInterface::class); + } + + function it_resolves_object_path_to_public_url($service) + { + $service->getObjectUrl('bucket', 'base/dir/foo.png')->willReturn('https://amazon'); + + $this->resolve('/foo.png')->shouldReturn('https://amazon'); + } +} diff --git a/spec/Resolvable/Resolver/StaticUrlResolverSpec.php b/spec/Resolvable/Resolver/StaticUrlResolverSpec.php new file mode 100644 index 0000000..a5b44c0 --- /dev/null +++ b/spec/Resolvable/Resolver/StaticUrlResolverSpec.php @@ -0,0 +1,30 @@ +beConstructedWith('https://google.com/'); + } + + function it_is_initializable() + { + $this->shouldHaveType(StaticUrlResolver::class); + } + + function it_is_a_resolver() + { + $this->shouldImplement(ResolverInterface::class); + } + + function it_resolves_url_by_prepending_static_prefix_to_path() + { + $this->resolve('/foo.png')->shouldReturn('https://google.com/foo.png'); + } +} diff --git a/src/Resolvable/ResolvableFilesystem.php b/src/Resolvable/ResolvableFilesystem.php new file mode 100644 index 0000000..e15b1a2 --- /dev/null +++ b/src/Resolvable/ResolvableFilesystem.php @@ -0,0 +1,165 @@ +decorated = $decorated; + $this->resolver = $resolver; + } + + /** + * @param string $key Object path. + * + * @throws UnresolvableObjectException When not able to resolve the object path. Any exception thrown by underlying + * resolver will be converted to this exception. + * + * @return string + */ + public function resolve($key) + { + try { + return $this->resolver->resolve($key); + } catch (\Exception $e) { + throw new UnresolvableObjectException($key, $e); + } + } + + /** + * {@inheritdoc} + */ + public function has($key) + { + return $this->decorated->has($key); + } + + /** + * {@inheritdoc} + */ + public function rename($sourceKey, $targetKey) + { + return $this->decorated->rename($sourceKey, $targetKey); + } + + /** + * {@inheritdoc} + */ + public function get($key, $create = false) + { + return $this->decorated->get($key, $create); + } + + /** + * {@inheritdoc} + */ + public function write($key, $content, $overwrite = false) + { + return $this->decorated->write($key, $content, $overwrite); + } + + /** + * {@inheritdoc} + */ + public function read($key) + { + return $this->decorated->read($key); + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + return $this->decorated->delete($key); + } + + /** + * {@inheritdoc} + */ + public function keys() + { + return $this->decorated->keys(); + } + + /** + * {@inheritdoc} + */ + public function listKeys($prefix = '') + { + return $this->decorated->listKeys($prefix); + } + + /** + * {@inheritdoc} + */ + public function mtime($key) + { + return $this->decorated->mtime($key); + } + + /** + * {@inheritdoc} + */ + public function checksum($key) + { + return $this->decorated->checksum($key); + } + + /** + * {@inheritdoc} + */ + public function size($key) + { + return $this->decorated->size($key); + } + + /** + * {@inheritdoc} + */ + public function createStream($key) + { + return $this->decorated->createStream($key); + } + + /** + * {@inheritdoc} + */ + public function createFile($key) + { + return $this->decorated->createFile($key); + } + + /** + * {@inheritdoc} + */ + public function mimeType($key) + { + return $this->decorated->mimeType($key); + } + + /** + * {@inheritdoc} + */ + public function isDirectory($key) + { + return $this->decorated->isDirectory($key); + } + + /** + * {@inheritdoc} + */ + public function __call($name, $arguments) + { + return call_user_func_array([$this->decorated, $name], $arguments); + } +} diff --git a/src/Resolvable/Resolver/AwsS3PresignedUrlResolver.php b/src/Resolvable/Resolver/AwsS3PresignedUrlResolver.php new file mode 100644 index 0000000..f78e74c --- /dev/null +++ b/src/Resolvable/Resolver/AwsS3PresignedUrlResolver.php @@ -0,0 +1,69 @@ +service = $service; + $this->bucket = $bucket; + $this->baseDir = trim($baseDir, '/'); + $this->expiresAt = $expiresAt; + } + + /** + * Resolves given object path into presigned request URI. + * + * @param string $path + * + * @return (string) \Psr\Http\Message\UriInterface + */ + public function resolve($path) + { + $command = $this->service->getCommand('GetObject', [ + 'Bucket' => $this->bucket, + 'Key' => $this->computePath($path), + ]); + + return (string) $this->service->createPresignedRequest($command, $this->expiresAt)->getUri(); + } + + /** + * Appends baseDir to $key. + * + * @param string $key + * + * @return string + */ + private function computePath($key) + { + return ltrim($this->baseDir . '/' . ltrim($key, '/'), '/'); + } +} diff --git a/src/Resolvable/Resolver/AwsS3PublicUrlResolver.php b/src/Resolvable/Resolver/AwsS3PublicUrlResolver.php new file mode 100644 index 0000000..2f14642 --- /dev/null +++ b/src/Resolvable/Resolver/AwsS3PublicUrlResolver.php @@ -0,0 +1,48 @@ +service = $service; + $this->bucket = $bucket; + $this->baseDir = trim($baseDir, '/'); + } + + /** + * Resolves given object $path into public URL. + * + * @param string $path + * + * @return string + */ + public function resolve($path) + { + return $this->service->getObjectUrl( + $this->bucket, + ltrim($this->baseDir . '/' . ltrim($path, '/'), '/') + ); + } +} diff --git a/src/Resolvable/Resolver/StaticUrlResolver.php b/src/Resolvable/Resolver/StaticUrlResolver.php new file mode 100644 index 0000000..e696265 --- /dev/null +++ b/src/Resolvable/Resolver/StaticUrlResolver.php @@ -0,0 +1,30 @@ +prefix = rtrim($prefix, '/'); + } + + /** + * {@inheritdoc} + */ + public function resolve($path) + { + return $this->prefix . '/' . ltrim($path, '/'); + } +} diff --git a/src/Resolvable/ResolverException.php b/src/Resolvable/ResolverException.php new file mode 100644 index 0000000..62643d6 --- /dev/null +++ b/src/Resolvable/ResolverException.php @@ -0,0 +1,7 @@ +client, $this->bucket, ['create' => true])), + new AwsS3PresignedUrlResolver($this->client, $this->bucket, '', $expiresAt) + ); + } +} diff --git a/tests/Resolvable/Resolver/AwsS3PublicUrlResolverTest.php b/tests/Resolvable/Resolver/AwsS3PublicUrlResolverTest.php new file mode 100644 index 0000000..c68360e --- /dev/null +++ b/tests/Resolvable/Resolver/AwsS3PublicUrlResolverTest.php @@ -0,0 +1,21 @@ +client, $this->bucket, ['create' => true])), + new AwsS3PublicUrlResolver($this->client, $this->bucket, '') + ); + } +} diff --git a/tests/Resolvable/Resolver/AwsS3SetUpTearDownTrait.php b/tests/Resolvable/Resolver/AwsS3SetUpTearDownTrait.php new file mode 100644 index 0000000..3fd8807 --- /dev/null +++ b/tests/Resolvable/Resolver/AwsS3SetUpTearDownTrait.php @@ -0,0 +1,55 @@ +markTestSkipped('Missing AWS_KEY and/or AWS_SECRET env vars.'); + } + + $this->bucket = uniqid(getenv('AWS_BUCKET')); + $this->client = new S3Client([ + 'region' => $region ? $region : 'eu-west-1', + 'version' => 'latest', + 'credentials' => [ + 'key' => $key, + 'secret' => $secret, + ], + ]); + } + + public function tearDown() + { + if ($this->client === null || !$this->client->doesBucketExist($this->bucket)) { + return; + } + + $result = $this->client->listObjects(['Bucket' => $this->bucket]); + $staleObjects = $result->get('Contents'); + + foreach ($staleObjects as $staleObject) { + $this->client->deleteObject(['Bucket' => $this->bucket, 'Key' => $staleObject['Key']]); + } + + $this->client->deleteBucket(['Bucket' => $this->bucket]); + } +} diff --git a/tests/Resolvable/Resolver/StaticUrlResolverTest.php b/tests/Resolvable/Resolver/StaticUrlResolverTest.php new file mode 100644 index 0000000..d189eb1 --- /dev/null +++ b/tests/Resolvable/Resolver/StaticUrlResolverTest.php @@ -0,0 +1,19 @@ +getFilesystem(); + + $filesystem->write('foo/bar', ''); + $this->assertNotEmpty($filesystem->resolve('foo/bar')); + } + + public function testWithNonExistentFile() + { + $filesystem = $this->getFilesystem(); + + $this->assertNotEmpty($filesystem->resolve('baz')); + } +}