diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 03d854415..cca530320 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -6,7 +6,7 @@ before_commands: # - "composer install --prefer-source --dev" filter: - excluded_paths: [vendor/*, tests/*, bin/*] + excluded_paths: [vendor/*, Tests/*, bin/*] tools: php_sim: true @@ -24,5 +24,5 @@ tools: excluded_dirs: [vendor] php_pdepend: enabled: true - excluded_dirs: [vendor, tests, bin] + excluded_dirs: [vendor, Tests, bin] # php_hhvm: true diff --git a/DependencyInjection/Factory/Resolver/FlysystemResolverFactory.php b/DependencyInjection/Factory/Resolver/FlysystemResolverFactory.php new file mode 100644 index 000000000..54f542ccf --- /dev/null +++ b/DependencyInjection/Factory/Resolver/FlysystemResolverFactory.php @@ -0,0 +1,52 @@ +replaceArgument(0, new Reference($config['filesystem_service'])); + $resolverDefinition->replaceArgument(2, $config['root_url']); + $resolverDefinition->replaceArgument(3, $config['cache_prefix']); + $resolverDefinition->addTag('liip_imagine.cache.resolver', array( + 'resolver' => $resolverName, + )); + $resolverId = 'liip_imagine.cache.resolver.'.$resolverName; + + $container->setDefinition($resolverId, $resolverDefinition); + + return $resolverId; + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'flysystem'; + } + + /** + * {@inheritdoc} + */ + public function addConfiguration(ArrayNodeDefinition $builder) + { + $builder + ->children() + ->scalarNode('filesystem_service')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('cache_prefix')->defaultValue(null)->end() + ->scalarNode('root_url')->isRequired()->cannotBeEmpty()->end() + ->end() + ; + } +} diff --git a/Imagine/Cache/Resolver/FlysystemResolver.php b/Imagine/Cache/Resolver/FlysystemResolver.php new file mode 100644 index 000000000..91f21a25c --- /dev/null +++ b/Imagine/Cache/Resolver/FlysystemResolver.php @@ -0,0 +1,152 @@ +flysystem = $flysystem; + $this->requestContext = $requestContext; + + $this->webRoot = rtrim($rootUrl, '/'); + $this->cachePrefix = ltrim(str_replace('//', '/', $cachePrefix), '/'); + $this->cacheRoot = $this->cachePrefix; + } + + /** + * Checks whether the given path is stored within this Resolver. + * + * @param string $path + * @param string $filter + * + * @return bool + */ + public function isStored($path, $filter) + { + return $this->flysystem->has($this->getFilePath($path, $filter)); + } + + /** + * {@inheritdoc} + */ + protected function getFilePath($path, $filter) + { + return $this->getFileUrl($path, $filter); + } + + /** + * {@inheritdoc} + */ + protected function getFileUrl($path, $filter) + { + // crude way of sanitizing URL scheme ("protocol") part + $path = str_replace('://', '---', $path); + + return $this->cachePrefix.'/'.$filter.'/'.ltrim($path, '/'); + } + + /** + * Resolves filtered path for rendering in the browser. + * + * @param string $path The path where the original file is expected to be. + * @param string $filter The name of the imagine filter in effect. + * + * @return string The absolute URL of the cached image. + * + * @throws NotResolvableException + */ + public function resolve($path, $filter) + { + return sprintf( + '%s/%s', + $this->webRoot, + $this->getFileUrl($path, $filter) + ); + } + + /** + * Stores the content of the given binary. + * + * @param BinaryInterface $binary The image binary to store. + * @param string $path The path where the original file is expected to be. + * @param string $filter The name of the imagine filter in effect. + */ + public function store(BinaryInterface $binary, $path, $filter) + { + $this->flysystem->put( + $this->getFilePath($path, $filter), + $binary->getContent() + ); + } + + /** + * @param string[] $paths The paths where the original files are expected to be. + * @param string[] $filters The imagine filters in effect. + */ + public function remove(array $paths, array $filters) + { + if (empty($paths) && empty($filters)) { + return; + } + + if (empty($paths)) { + foreach ($filters as $filter) { + $filterCacheDir = $this->cacheRoot.'/'.$filter; + $this->flysystem->deleteDir($filterCacheDir); + } + + return; + } + + foreach ($paths as $path) { + foreach ($filters as $filter) { + if ($this->flysystem->has($this->getFilePath($path, $filter))) { + $this->flysystem->delete($this->getFilePath($path, $filter)); + } + } + } + } +} diff --git a/LiipImagineBundle.php b/LiipImagineBundle.php index 0e080cdf1..a0dbd2a6c 100644 --- a/LiipImagineBundle.php +++ b/LiipImagineBundle.php @@ -10,6 +10,7 @@ use Liip\ImagineBundle\DependencyInjection\Factory\Loader\StreamLoaderFactory; use Liip\ImagineBundle\DependencyInjection\Factory\Loader\FlysystemLoaderFactory; use Liip\ImagineBundle\DependencyInjection\Factory\Resolver\AwsS3ResolverFactory; +use Liip\ImagineBundle\DependencyInjection\Factory\Resolver\FlysystemResolverFactory; use Liip\ImagineBundle\DependencyInjection\Factory\Resolver\WebPathResolverFactory; use Liip\ImagineBundle\DependencyInjection\LiipImagineExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -34,6 +35,7 @@ public function build(ContainerBuilder $container) $extension->addResolverFactory(new WebPathResolverFactory()); $extension->addResolverFactory(new AwsS3ResolverFactory()); + $extension->addResolverFactory(new FlysystemResolverFactory()); $extension->addLoaderFactory(new StreamLoaderFactory()); $extension->addLoaderFactory(new FileSystemLoaderFactory()); diff --git a/Resources/config/imagine.xml b/Resources/config/imagine.xml index 1f678d67c..19116386e 100644 --- a/Resources/config/imagine.xml +++ b/Resources/config/imagine.xml @@ -57,6 +57,7 @@ Liip\ImagineBundle\Imagine\Cache\Resolver\NoCacheWebPathResolver Liip\ImagineBundle\Imagine\Cache\Resolver\AwsS3Resolver Liip\ImagineBundle\Imagine\Cache\Resolver\CacheResolver + Liip\ImagineBundle\Imagine\Cache\Resolver\FlysystemResolver Liip\ImagineBundle\Imagine\Cache\Resolver\ProxyResolver @@ -208,7 +209,7 @@ - + @@ -236,6 +237,13 @@ + + + + + + + diff --git a/Resources/doc/cache-resolver/flysystem.rst b/Resources/doc/cache-resolver/flysystem.rst new file mode 100644 index 000000000..688e332ae --- /dev/null +++ b/Resources/doc/cache-resolver/flysystem.rst @@ -0,0 +1,63 @@ +FlysystemResolver +================= + +This resolver lets you load images onto `Flysystem`_ filesystem abstraction layer, +which can be used in Symfony projects by installing, for example, `OneupFlysystemBundle`_. + +Value of ``filesystem_service`` property must be a service, +which returns an instance of League\\Flysystem\\Filesystem. + +For implementation using `OneupFlysystemBundle`_ look below. + +Create resolver +--------------- + +.. code-block:: yaml + + liip_imagine: + resolvers: + profile_photos: + flysystem: + filesystem_service: oneup_flysystem.profile_photos_filesystem + root_url: http://images.example.com + cache_prefix: media/cache + oneup_flysystem: + adapters: + profile_photos: + local: + directory: "path/to/profile/photos" + + filesystems: + profile_photos: + adapter: profile_photos + +There are several configuration options available: + +* ``root_url`` - must be a valid url to the target system the flysystem adapter + points to. This is used to determine how the url should be generated upon request. + Default value: ``null`` +* ``cache_prefix`` - this is used for the image path generation. This will be the + prefix inside the given Flysystem. + Default value: ``media/cache`` + +Usage +----- + +.. code-block:: yaml + + liip_imagine: + cache: profile_photos + +Usage on a specific filter +-------------------------- + +.. code-block:: yaml + + liip_imagine: + filter_sets: + cache: ~ + my_thumb: + cache: profile_photos + quality: 75 + filters: + thumbnail: { size: [120, 90], mode: outbound } diff --git a/Tests/DependencyInjection/Factory/Resolver/FlysystemResolverFactoryTest.php b/Tests/DependencyInjection/Factory/Resolver/FlysystemResolverFactoryTest.php new file mode 100644 index 000000000..e79569b7d --- /dev/null +++ b/Tests/DependencyInjection/Factory/Resolver/FlysystemResolverFactoryTest.php @@ -0,0 +1,125 @@ + + */ +class FlysystemResolverFactoryTest extends \Phpunit_Framework_TestCase +{ + public function setUp() + { + parent::setUp(); + + if (!class_exists('\League\Flysystem\Filesystem')) { + $this->markTestSkipped( + 'The league/flysystem PHP library is not available.' + ); + } + } + + public function testImplementsResolverFactoryInterface() + { + $rc = new \ReflectionClass('Liip\ImagineBundle\DependencyInjection\Factory\Resolver\FlysystemResolverFactory'); + + $this->assertTrue($rc->implementsInterface('Liip\ImagineBundle\DependencyInjection\Factory\Resolver\ResolverFactoryInterface')); + } + + public function testCouldBeConstructedWithoutAnyArguments() + { + new FlysystemResolverFactory(); + } + + public function testReturnExpectedName() + { + $resolver = new FlysystemResolverFactory(); + + $this->assertEquals('flysystem', $resolver->getName()); + } + + public function testCreateResolverDefinitionOnCreate() + { + $container = new ContainerBuilder(); + + $resolver = new FlysystemResolverFactory(); + + $resolver->create($container, 'theResolverName', array( + 'filesystem_service' => 'flyfilesystemservice', + 'root_url' => 'http://images.example.com', + 'cache_prefix' => 'theCachePrefix', + )); + + $this->assertTrue($container->hasDefinition('liip_imagine.cache.resolver.theresolvername')); + + $resolverDefinition = $container->getDefinition('liip_imagine.cache.resolver.theresolvername'); + $this->assertInstanceOf('Symfony\Component\DependencyInjection\DefinitionDecorator', $resolverDefinition); + $this->assertEquals('liip_imagine.cache.resolver.prototype.flysystem', $resolverDefinition->getParent()); + + $this->assertEquals('http://images.example.com', $resolverDefinition->getArgument(2)); + $this->assertEquals('theCachePrefix', $resolverDefinition->getArgument(3)); + } + + public function testProcessCorrectlyOptionsOnAddConfiguration() + { + $expectedRootUrl = 'http://images.example.com'; + $expectedCachePrefix = 'theCachePrefix'; + $expectedFlysystemService = 'flyfilesystemservice'; + + $treeBuilder = new TreeBuilder(); + $rootNode = $treeBuilder->root('flysystem', 'array'); + + $resolver = new FlysystemResolverFactory(); + $resolver->addConfiguration($rootNode); + + $config = $this->processConfigTree($treeBuilder, array( + 'flysystem' => array( + 'root_url' => $expectedRootUrl, + 'cache_prefix' => $expectedCachePrefix, + 'filesystem_service' => $expectedFlysystemService, + ), + )); + + $this->assertArrayHasKey('filesystem_service', $config); + $this->assertEquals($expectedFlysystemService, $config['filesystem_service']); + + $this->assertArrayHasKey('root_url', $config); + $this->assertEquals($expectedRootUrl, $config['root_url']); + + $this->assertArrayHasKey('cache_prefix', $config); + $this->assertEquals($expectedCachePrefix, $config['cache_prefix']); + } + + /** + * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException + */ + public function testAddDefaultOptionsIfNotSetOnAddConfiguration() + { + $treeBuilder = new TreeBuilder(); + $rootNode = $treeBuilder->root('flysystem', 'array'); + + $resolver = new FlysystemResolverFactory(); + $resolver->addConfiguration($rootNode); + + $config = $this->processConfigTree($treeBuilder, array( + 'flysystem' => array(), + )); + } + + /** + * @param TreeBuilder $treeBuilder + * @param array $configs + * + * @return array + */ + protected function processConfigTree(TreeBuilder $treeBuilder, array $configs) + { + $processor = new Processor(); + + return $processor->process($treeBuilder->buildTree(), $configs); + } +} diff --git a/Tests/Imagine/Cache/Resolver/FlysystemResolverTest.php b/Tests/Imagine/Cache/Resolver/FlysystemResolverTest.php new file mode 100644 index 000000000..c3746adb6 --- /dev/null +++ b/Tests/Imagine/Cache/Resolver/FlysystemResolverTest.php @@ -0,0 +1,287 @@ +markTestSkipped( + 'The league/flysystem PHP library is not available.' + ); + } + } + + public function testImplementsResolverInterface() + { + $rc = new \ReflectionClass('Liip\ImagineBundle\Imagine\Cache\Resolver\FlysystemResolver'); + + $this->assertTrue($rc->implementsInterface('Liip\ImagineBundle\Imagine\Cache\Resolver\ResolverInterface')); + } + + public function testResolveUriForFilter() + { + $fs = $this->getFlysystemMock(); + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + $uri = $resolver->resolve('/some-folder/path.jpg', 'thumb'); + $this->assertEquals('http://images.example.com/media/cache/thumb/some-folder/path.jpg', $uri); + } + + public function testRemoveObjectsForFilter() + { + $expectedFilter = 'theFilter'; + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->once()) + ->method('deleteDir') + ->with('media/cache/theFilter') + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + $resolver->remove(array(), array($expectedFilter)); + } + + public function testCreateObjectInAdapter() + { + $binary = new Binary('aContent', 'image/jpeg', 'jpeg'); + + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->once()) + ->method('put') + ->will($this->returnValue(true)) + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $this->assertNull($resolver->store($binary, 'thumb/foobar.jpg', 'thumb')); + } + + public function testIsStoredChecksObjectExistence() + { + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->once()) + ->method('has') + ->will($this->returnValue(false)) + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $this->assertFalse($resolver->isStored('/some-folder/path.jpg', 'thumb')); + } + + public function testReturnResolvedImageUrlOnResolve() + { + $fs = $this->getFlysystemMock(); + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $this->assertEquals( + 'http://images.example.com/media/cache/thumb/some-folder/path.jpg', + $resolver->resolve('/some-folder/path.jpg', 'thumb') + ); + } + + public function testRemoveCacheForPathAndFilterOnRemove() + { + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->once()) + ->method('has') + ->with('media/cache/thumb/some-folder/path.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->once()) + ->method('delete') + ->with('media/cache/thumb/some-folder/path.jpg') + ->will($this->returnValue(true)) + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $resolver->remove(array('some-folder/path.jpg'), array('thumb')); + } + + public function testRemoveCacheForSomePathsAndFilterOnRemove() + { + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->at(0)) + ->method('has') + ->with('media/cache/thumb/pathOne.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(1)) + ->method('delete') + ->with('media/cache/thumb/pathOne.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(2)) + ->method('has') + ->with('media/cache/thumb/pathTwo.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(3)) + ->method('delete') + ->with('media/cache/thumb/pathTwo.jpg') + ->will($this->returnValue(true)) + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $resolver->remove( + array('pathOne.jpg', 'pathTwo.jpg'), + array('thumb') + ); + } + + public function testRemoveCacheForSomePathsAndSomeFiltersOnRemove() + { + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->at(0)) + ->method('has') + ->with('media/cache/filterOne/pathOne.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(1)) + ->method('delete') + ->with('media/cache/filterOne/pathOne.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(2)) + ->method('has') + ->with('media/cache/filterTwo/pathOne.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(3)) + ->method('delete') + ->with('media/cache/filterTwo/pathOne.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(4)) + ->method('has') + ->with('media/cache/filterOne/pathTwo.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(5)) + ->method('delete') + ->with('media/cache/filterOne/pathTwo.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(6)) + ->method('has') + ->with('media/cache/filterTwo/pathTwo.jpg') + ->will($this->returnValue(true)) + ; + $fs + ->expects($this->at(7)) + ->method('delete') + ->with('media/cache/filterTwo/pathTwo.jpg') + ->will($this->returnValue(true)) + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $resolver->remove( + array('pathOne.jpg', 'pathTwo.jpg'), + array('filterOne', 'filterTwo') + ); + } + + public function testDoNothingWhenObjectNotExistForPathAndFilterOnRemove() + { + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->once()) + ->method('has') + ->with('media/cache/thumb/some-folder/path.jpg') + ->will($this->returnValue(false)) + ; + $fs + ->expects($this->never()) + ->method('delete') + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + $resolver->remove(array('some-folder/path.jpg'), array('thumb')); + } + + public function testRemoveCacheForFilterOnRemove() + { + $expectedFilter = 'theFilter'; + + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->once()) + ->method('deleteDir') + ->with('media/cache/theFilter') + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $resolver->remove(array(), array($expectedFilter)); + } + + public function testRemoveCacheForSomeFiltersOnRemove() + { + $expectedFilterOne = 'theFilterOne'; + $expectedFilterTwo = 'theFilterTwo'; + + $fs = $this->getFlysystemMock(); + $fs + ->expects($this->at(0)) + ->method('deleteDir') + ->with('media/cache/theFilterOne') + ; + $fs + ->expects($this->at(1)) + ->method('deleteDir') + ->with('media/cache/theFilterTwo') + ; + + $resolver = new FlysystemResolver($fs, new RequestContext(), 'http://images.example.com'); + + $resolver->remove(array(), array($expectedFilterOne, $expectedFilterTwo)); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|Filesystem + */ + protected function getFlysystemMock() + { + $mockedMethods = array( + 'delete', + 'deleteDir', + 'has', + 'put', + 'remove', + ); + + return $this->getMock('League\Flysystem\Filesystem', $mockedMethods, array(), '', false); + } +} diff --git a/Tests/LiipImagineBundleTest.php b/Tests/LiipImagineBundleTest.php index 3acd257a5..64a1e253a 100644 --- a/Tests/LiipImagineBundleTest.php +++ b/Tests/LiipImagineBundleTest.php @@ -141,11 +141,33 @@ public function testAddAwsS3ResolverFactoryOnBuild() $bundle->build($containerMock); } - public function testAddStreamLoaderFactoryOnBuild() + public function testAddFlysystemResolverFactoryOnBuild() { $extensionMock = $this->createExtensionMock(); $extensionMock ->expects($this->at(2)) + ->method('addResolverFactory') + ->with($this->isInstanceOf('Liip\ImagineBundle\DependencyInjection\Factory\Resolver\FlysystemResolverFactory')) + ; + + $containerMock = $this->createContainerBuilderMock(); + $containerMock + ->expects($this->atLeastOnce()) + ->method('getExtension') + ->with('liip_imagine') + ->will($this->returnValue($extensionMock)) + ; + + $bundle = new LiipImagineBundle(); + + $bundle->build($containerMock); + } + + public function testAddStreamLoaderFactoryOnBuild() + { + $extensionMock = $this->createExtensionMock(); + $extensionMock + ->expects($this->at(3)) ->method('addLoaderFactory') ->with($this->isInstanceOf('Liip\ImagineBundle\DependencyInjection\Factory\Loader\StreamLoaderFactory')) ; @@ -167,7 +189,7 @@ public function testAddFilesystemLoaderFactoryOnBuild() { $extensionMock = $this->createExtensionMock(); $extensionMock - ->expects($this->at(3)) + ->expects($this->at(4)) ->method('addLoaderFactory') ->with($this->isInstanceOf('Liip\ImagineBundle\DependencyInjection\Factory\Loader\FilesystemLoaderFactory')) ; @@ -189,7 +211,7 @@ public function testAddFlysystemLoaderFactoryOnBuild() { $extensionMock = $this->createExtensionMock(); $extensionMock - ->expects($this->at(4)) + ->expects($this->at(5)) ->method('addLoaderFactory') ->with($this->isInstanceOf('Liip\ImagineBundle\DependencyInjection\Factory\Loader\FlysystemLoaderFactory')) ; diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php index 79e186b78..c1bc09a44 100644 --- a/Tests/bootstrap.php +++ b/Tests/bootstrap.php @@ -20,5 +20,5 @@ interface ExpressionFunctionProviderInterface { - }; + } }