From f37c137d8e99868f2d317fdad54a63620382c470 Mon Sep 17 00:00:00 2001 From: Benoit Maziere Date: Wed, 31 Jul 2019 14:34:57 +0200 Subject: [PATCH] first commit --- .coveralls.yml | 3 + .github/ISSUE_TEMPLATE.md | 52 +++++ .github/PULL_REQUEST_TEMPLATE.md | 56 +++++ .gitignore | 5 + .php_cs | 67 ++++++ .travis.yml | 60 +++++ .travis/after_success_ci.sh | 4 + .travis/before_install_ci.sh | 10 + .travis/check_relevant_ci.sh | 6 + .travis/install_ci.sh | 16 ++ .yamllint | 7 + CHANGELOG.md | 6 + LICENSE | 19 ++ Makefile | 34 +++ README.md | 96 ++++++++ TODO.md | 14 ++ composer.json | 48 ++++ phpstan.neon.dist | 9 + phpunit.xml.dist | 25 +++ src/Client/Client.php | 54 +++++ src/Client/ClientInterface.php | 24 ++ src/Consumer/OptimizeImageConsumer.php | 115 ++++++++++ src/DependencyInjection/Configuration.php | 53 +++++ .../EkinoTinyPngSonataMediaExtension.php | 42 ++++ src/EkinoTinyPngSonataMediaBundle.php | 20 ++ src/Listener/MediaEventSubscriber.php | 205 ++++++++++++++++++ src/Resources/config/services.xml | 27 +++ tests/Client/ClientTest.php | 46 ++++ tests/Client/foo.png | 0 tests/Consumer/OptimizeImageConsumerTest.php | 85 ++++++++ .../EkinoTinyPngSonataMediaExtensionTest.php | 56 +++++ tests/Listener/MediaEventSubscriberTest.php | 63 ++++++ tests/autoload.php.dist | 29 +++ tests/bootstrap.php | 29 +++ 34 files changed, 1385 insertions(+) create mode 100644 .coveralls.yml create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .php_cs create mode 100644 .travis.yml create mode 100644 .travis/after_success_ci.sh create mode 100644 .travis/before_install_ci.sh create mode 100644 .travis/check_relevant_ci.sh create mode 100644 .travis/install_ci.sh create mode 100644 .yamllint create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 composer.json create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Client/Client.php create mode 100644 src/Client/ClientInterface.php create mode 100644 src/Consumer/OptimizeImageConsumer.php create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/EkinoTinyPngSonataMediaExtension.php create mode 100644 src/EkinoTinyPngSonataMediaBundle.php create mode 100644 src/Listener/MediaEventSubscriber.php create mode 100644 src/Resources/config/services.xml create mode 100644 tests/Client/ClientTest.php create mode 100644 tests/Client/foo.png create mode 100644 tests/Consumer/OptimizeImageConsumerTest.php create mode 100644 tests/DependencyInjection/EkinoTinyPngSonataMediaExtensionTest.php create mode 100644 tests/Listener/MediaEventSubscriberTest.php create mode 100644 tests/autoload.php.dist create mode 100644 tests/bootstrap.php diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..eb8d91f --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,3 @@ +coverage_clover: build/phpunit/clover.xml +json_path: build/phpunit/coveralls-upload.json +service_name: travis-ci diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..8361a7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,52 @@ + + + + +### Environment + +#### Sonata packages + +``` +$ composer show --latest 'sonata-project/*' +# Put the result here. +``` + +#### Symfony packages + +``` +$ composer show --latest 'symfony/*' +# Put the result here. +``` + +#### PHP version + +``` +$ php -v +# Put the result here. +``` + +## Subject + + + +## Steps to reproduce + +## Expected results + +## Actual results + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8a63910 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ + + + +I am targeting this branch, because {reason}. + + + +Closes #{put_issue_number_here} + +## Changelog + + + + +```markdown +### Added +- Added some `Class::newMethod` to do great stuff + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security +``` + +## To do + + + +- [ ] Update the tests +- [ ] Update the documentation +- [ ] Add an upgrade note + +## Subject + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b65f9d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/build/ +/vendor/ +/.php_cs.cache +/composer.lock +.phpunit.result.cache diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..8980f47 --- /dev/null +++ b/.php_cs @@ -0,0 +1,67 @@ +setRiskyAllowed(true) + ->setRules([ + 'array_indentation' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => true, + 'declare_strict_types' => true, + 'header_comment' => ['header' => $header], + 'indentation_type' => true, + 'linebreak_after_opening_tag' => true, + 'lowercase_constants' => true, + 'lowercase_keywords' => true, + 'method_separation' => true, + 'native_function_invocation' => ['include' => ['@compiler_optimized']], + 'no_alias_functions' => true, + 'no_closing_tag' => true, + 'no_extra_consecutive_blank_lines' => [ + 'tokens' => [ + 'break', + 'continue', + 'curly_brace_block', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'throw', + 'use', + ], + ], + 'no_short_echo_tag' => true, + 'no_useless_else' => true, + 'no_unused_imports' => true, + 'no_useless_return' => true, + 'ordered_imports' => true, + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_order' => true, + 'semicolon_after_instruction' => true, + 'visibility_required' => true, + ]) + ->setUsingCache(true) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__) + ) +; diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..746989d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,60 @@ +branches: + only: + - master + +language: php + +php: + - '7.1' + - '7.2' + - '7.3' + +dist: trusty +sudo: false + +cache: + directories: + - $HOME/.composer/cache/files + +env: + global: + - TARGET=ci + +matrix: + fast_finish: true + include: + - php: '7.3' + env: SYMFONY=3.4.* + - php: '7.3' + env: SYMFONY=^4.2 + - php: '7.3' + env: SYMFONY_DEPRECATIONS_HELPER=0 + - php: '7.2' + env: SYMFONY=3.4.* + - php: '7.2' + env: SYMFONY=^4.2 + - php: '7.2' + env: SYMFONY_DEPRECATIONS_HELPER=0 + - php: '7.1' + env: SYMFONY=3.4.* + - php: '7.1' + env: SYMFONY=^4.2 + - php: '7.1' + env: SYMFONY_DEPRECATIONS_HELPER=0 + allow_failures: + - php: nightly + - php: '7.3' + - env: SYMFONY_DEPRECATIONS_HELPER=0 + +before_install: + - composer self-update + - if [ -x .travis/before_install_${TARGET}.sh ]; then .travis/before_install_${TARGET}.sh; fi; + +install: + - if [ -x .travis/install_${TARGET}.sh ]; then .travis/install_${TARGET}.sh; fi; + +script: + - make $TARGET + +after_success: + - if [ -x .travis/after_success_${TARGET}.sh ]; then .travis/after_success_${TARGET}.sh; fi; diff --git a/.travis/after_success_ci.sh b/.travis/after_success_ci.sh new file mode 100644 index 0000000..8715851 --- /dev/null +++ b/.travis/after_success_ci.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -ev + +coveralls -v diff --git a/.travis/before_install_ci.sh b/.travis/before_install_ci.sh new file mode 100644 index 0000000..722d223 --- /dev/null +++ b/.travis/before_install_ci.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -ev + +PHP_INI_DIR="$HOME/.phpenv/versions/$(phpenv version-name)/etc/conf.d/" +TRAVIS_INI_FILE="$PHP_INI_DIR/travis.ini" +echo "memory_limit=3072M" >> "$TRAVIS_INI_FILE" + +sed --in-place "s/\"dev-master\":/\"dev-${TRAVIS_COMMIT}\":/" composer.json + +if [ "$SYMFONY" != "" ]; then composer require "symfony/symfony:$SYMFONY" --no-update; fi; diff --git a/.travis/check_relevant_ci.sh b/.travis/check_relevant_ci.sh new file mode 100644 index 0000000..a4ad290 --- /dev/null +++ b/.travis/check_relevant_ci.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -ev + +RELEVANT_FILES=$(git diff --name-only HEAD upstream/${TRAVIS_BRANCH} -- '*.php' '*.yml' '*.xml' '*.twig' '*.js' '*.css' '*.json') + +if [[ -z ${RELEVANT_FILES} ]]; then echo -n 'KO'; exit 0; fi; diff --git a/.travis/install_ci.sh b/.travis/install_ci.sh new file mode 100644 index 0000000..2274828 --- /dev/null +++ b/.travis/install_ci.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -ev + +mkdir --parents "${HOME}/bin" + +# Coveralls client install +wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar --output-document="${HOME}/bin/coveralls" +chmod u+x "${HOME}/bin/coveralls" + +# To be removed when these issues are resolved: +# https://github.com/composer/composer/issues/5355 +if [ "${COMPOSER_FLAGS}" = '--prefer-lowest' ]; then + composer update --prefer-dist --no-interaction --prefer-stable --quiet +fi + +composer update --prefer-dist --no-interaction --prefer-stable ${COMPOSER_FLAGS} diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..228b748 --- /dev/null +++ b/.yamllint @@ -0,0 +1,7 @@ +extends: default + +rules: + document-start: disable + line-length: + max: 120 + level: warning diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..01a4052 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +CHANGELOG +========= + +master +------ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f8eaeb4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) ekino + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3af520b --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: app-composer-validate app-cs-check app-cs-fix app-install app-security-check app-static-analysis app-test \ +app-test-with-code-coverage ci + +default: help + +help: + @grep -E '^[a-zA-Z_-]+:.*?##.*$$' $(MAKEFILE_LIST) | sort | awk '{split($$0, a, ":"); printf "\033[36m%-30s\033[0m %-30s %s\n", a[1], a[2], a[3]}' + +app-composer-validate: ## to validate composer config + composer validate + +app-cs-check: ## to show files that need to be fixed + vendor/bin/php-cs-fixer fix --dry-run --diff --verbose + +app-cs-fix: ## to fix files that need to be fixed + vendor/bin/php-cs-fixer fix --verbose + +app-install: ## to install app + composer install --prefer-dist + +app-security-check: ## to check if any security issues in the PHP dependencies + vendor/bin/security-checker security:check + +app-static-analysis: ## to run static analysis + php -dmemory_limit=-1 vendor/bin/phpstan analyze . -l 5 + +app-test: ## to run unit tests + vendor/bin/phpunit + +app-test-with-code-coverage: ## to run unit tests with code-coverage + vendor/bin/phpunit --coverage-text --colors=never + +ci: ## to run checks during ci + make app-composer-validate app-test-with-code-coverage app-static-analysis app-cs-check app-security-check diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bc7bf3 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +TinyPngSonataMediaBundle +======================== + +[![Latest Stable Version](https://poser.pugx.org/ekino/tiny-png-sonata-media-bundle/v/stable)](https://packagist.org/packages/ekino/tiny-png-sonata-media) +[![Build Status](https://travis-ci.org/ekino/EkinoTinyPngSonataMediaBundle.svg?branch=master)](https://travis-ci.org/ekino/EkinoTinyPngSonataMediaBundle) +[![Coverage Status](https://coveralls.io/repos/ekino/EkinoTinyPngSonataMediaBundle/badge.svg?branch=master&service=github)](https://coveralls.io/github/ekino/EkinoTinyPngSonataMediaBundle?branch=master) +[![Total Downloads](https://poser.pugx.org/ekino/tiny-png-sonata-media-bundle/downloads)](https://packagist.org/packages/ekino/tiny-png-sonata-media-bundle) + +This is a *work in progress*, so if you'd like something implemented please +feel free to ask for it or contribute to help us! + +# Purpose + +Automatize image optimization through tinyPNG service. You can only use the client or get the full process with +sonata media and sonata notification. + +# Installation + +## Step 1: add dependency + +```bash +$ composer require ekino/tiny-png-sonata-media-bundle +``` + +## Step 2: register the bundle + +### Symfony 2 or 3: + +```php + ['all' => true], + // ... +]; +``` + +## Step 3: configure the bundle + +```yaml +ekino_tiny_png_sonata_media: + tiny_png_api_key: '' # required + providers: [] # default +``` + +## Step 4: define the sonata notification queue for asynchronous behaviour + +# Usage + +## Use the tinyPng client + +Client can be used directly to optimize images through tinyPNG API. However, image optimization should not be done +synchronously as it takes time. + +If you know what you are doing, you can use the `ekino.tiny_png_sonata_media.tinfy.client` and its `optimize` method: + +```php +optimize($inputPath, $outputPath, $overwrite); +``` + +## Full process with sonata media & notification + +This bundle listen doctrine events (postPersist & postUpdate) on media entity. As soon as the media's provider is in +the whitelist (defined in configuration), it will publish a sonata notification message +(type: `ekino.tiny_png_sonata_media.optimize_image`) to be handled by a consumer +(`Ekino\TinyPngSonataMediaBundle\Consumer\OptimizeImageConsumer`). This consumer will contact tinyPNG API for +optimization, replace it on the server and update media size in database. + + +# Note + +- Only Sonata\MediaBundle\Filesystem\Local adapter is supported for now. +- Only png, jpg & jpeg files extensions are handled by this bundle as the tinyPNG only handle those ones. +- Regeneration of thumbnails after optimization is not yet supported. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1fa300e --- /dev/null +++ b/TODO.md @@ -0,0 +1,14 @@ +What's next? +============ + +- Add unit test on MediaEventSubscriber +- Add unit test on OptimizeImageConsumerTest +- Add admin action to launch an optimization from media admin edit +- Regenerate formats/thumbnails after optimization +- Handle others kind of adapters than local + +Nice to have +============ + +- liip monitor check on tinyPNG API consumption (free token is limited to 500 calls/month) +- sonata admin block to display the tinyPNG API consumption diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..184d2ce --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "ekino/tiny-png-sonata-media-bundle", + "description": "Tiny Png integration with SonataMedia by Ekino", + "type": "symfony-bundle", + "license": "MIT", + "keywords": ["sonata", "tiny png", "media"], + "homepage": "https://github.com/ekino/tiny-png-sonata-media-bundle", + "authors": [ + { + "name": "Ekino PHP Team", + "email": "php@ekino.com" + } + ], + "require": { + "php": "^7.1", + "doctrine/orm": "^2.6", + "sonata-project/media-bundle": "^3.0", + "sonata-project/notification-bundle": "^3.1", + "symfony/framework-bundle": "^3.3 || ^4.0", + "tinify/tinify": "^1.5" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^0.1", + "friendsofphp/php-cs-fixer": "^2.12", + "phpstan/phpstan-phpunit": "^0.11", + "phpunit/phpunit": "^7.2", + "sensiolabs/security-checker": "^6.0", + "symfony/phpunit-bridge": "^3.3 || ^4.0" + }, + "autoload": { + "psr-4": { + "Ekino\\TinyPngSonataMediaBundle\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Ekino\\TinyPngSonataMediaBundle\\Tests\\": "tests" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "0.x-dev" + } + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..7187f1b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - vendor/ekino/phpstan-banned-code/extension.neon + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + +parameters: + excludes_analyse: + - %rootDir%/../../../vendor/* + - %rootDir%/../../../src/DependencyInjection/Configuration.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..648659b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 0000000..2a81c5d --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,54 @@ + + */ +final class Client implements ClientInterface +{ + /** + * @param string $apiKey + */ + public function __construct(string $apiKey) + { + Tinify::setKey($apiKey); + } + + /** + * {@inheritdoc} + */ + public function optimize(string $inputPath, string $outputPath, bool $overwrite = true): void + { + if (!$overwrite && file_exists($outputPath)) { + throw new \RuntimeException(sprintf('The file %s already exists and the overwrite option is false', $outputPath)); + } + + $this->doOptimize($inputPath, $outputPath); + } + + /** + * @param string $inputPath + * @param string $outputPath + */ + protected function doOptimize(string $inputPath, string $outputPath): void + { + Source::fromFile($inputPath)->toFile($outputPath); + } +} diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php new file mode 100644 index 0000000..a819fbf --- /dev/null +++ b/src/Client/ClientInterface.php @@ -0,0 +1,24 @@ + + */ +final class OptimizeImageConsumer implements ConsumerInterface +{ + public const CONSUMER_TYPE = 'ekino.tiny_png_sonata_media.optimize_image'; + private const ERRONEOUS_RESTART_COUNT = 99; + + /** + * @var ManagerInterface + */ + private $mediaManager; + + /** + * @var Pool + */ + private $pool; + + /** + * @var ClientInterface + */ + private $client; + + /** + * @param ManagerInterface $mediaManager + * @param Pool $pool + * @param ClientInterface $client + */ + public function __construct(ManagerInterface $mediaManager, Pool $pool, ClientInterface $client) + { + $this->mediaManager = $mediaManager; + $this->pool = $pool; + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function process(ConsumerEvent $event): void + { + $message = $event->getMessage(); + /** @var MediaInterface $media */ + $media = $this->mediaManager->find($message->getValue('mediaId')); + + if (empty($media)) { + $message->setRestartCount(static::ERRONEOUS_RESTART_COUNT); + throw new HandlingException(sprintf('Media not found - id: %s', $message->getValue('mediaId'))); + } + + try { + $provider = $this->pool->getProvider($media->getProviderName()); + /** @var Local $adapter */ + $adapter = $provider->getFilesystem()->getAdapter(); + $directory = $adapter->getDirectory(); + + $path = sprintf('%s/%s', + $directory, $provider->generatePrivateUrl($media, MediaProviderInterface::FORMAT_REFERENCE) + ); + $this->client->optimize($path, $path); + + // fix media size in database after optimization + $binaryContent = new File($path); + $media->setSize($binaryContent->getSize()); + $this->mediaManager->save($media); + } catch(AccountException $e) { + throw new HandlingException(sprintf('Verify your API key and account limit - id: %s - message: %s', + $message->getValue('mediaId'), $e->getMessage())); + } catch(ClientException $e) { + throw new HandlingException(sprintf('Check your source image and request options - id: %s - message: %s', + $message->getValue('mediaId'), $e->getMessage())); + } catch(ServerException $e) { + throw new HandlingException(sprintf('Temporary issue with the Tinify API - id: %s - message: %s', + $message->getValue('mediaId'), $e->getMessage())); + } catch(ConnectionException $e) { + throw new HandlingException(sprintf('A network connection error occurred - id: %s - message: %s', + $message->getValue('mediaId'), $e->getMessage())); + } catch(\Throwable $e) { + $message->setRestartCount(static::ERRONEOUS_RESTART_COUNT); + throw new HandlingException(sprintf('Something else went wrong, unrelated to the Tinify API - id: %s - message: %s', + $message->getValue('mediaId'), $e->getMessage())); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..7e8b959 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,53 @@ +root('ekino_tiny_png_sonata_media'); + } else { + $rootNode = $treeBuilder->getRootNode(); + } + + $rootNode + ->children() + ->scalarNode('tiny_png_api_key')->isRequired()->cannotBeEmpty()->end() + ->arrayNode('providers') + ->info('Define providers for which optimization will be launched') + ->defaultValue([]) + ->prototype('scalar')->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/EkinoTinyPngSonataMediaExtension.php b/src/DependencyInjection/EkinoTinyPngSonataMediaExtension.php new file mode 100644 index 0000000..5b89293 --- /dev/null +++ b/src/DependencyInjection/EkinoTinyPngSonataMediaExtension.php @@ -0,0 +1,42 @@ +processConfiguration($configuration, $configs); + + $container->setParameter('ekino.tiny_png_sonata_media.api_key', $config['tiny_png_api_key']); + $container->setParameter('ekino.tiny_png_sonata_media.providers', $config['providers']); + + $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.xml'); + } +} diff --git a/src/EkinoTinyPngSonataMediaBundle.php b/src/EkinoTinyPngSonataMediaBundle.php new file mode 100644 index 0000000..025d47c --- /dev/null +++ b/src/EkinoTinyPngSonataMediaBundle.php @@ -0,0 +1,20 @@ + + */ +final class MediaEventSubscriber implements EventSubscriber +{ + private const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg']; + + /** + * @var array + */ + private $providers; + + /** + * @var Pool + */ + private $pool; + + /** + * @var BackendInterface + */ + private $backend; + + /** + * @param array $providers + * @param Pool $pool + * @param BackendInterface $backend + */ + public function __construct(array $providers, Pool $pool, BackendInterface $backend) + { + $this->providers = $providers; + $this->pool = $pool; + $this->backend = $backend; + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::postUpdate, + Events::postPersist, + ]; + } + + /** + * @param EventArgs $args + */ + public function postUpdate(EventArgs $args): void + { + if (!($provider = $this->getProvider($args))) { + return; + } + + if (!($media = $this->getMedia($args))) { + return; + } + + if (!$this->doesBinaryContentChanged($args, $media)) { + return; + } + + $this->publishOptimizeMessage($media); + } + + /** + * @param EventArgs $args + */ + public function postPersist(EventArgs $args): void + { + if (!($provider = $this->getProvider($args))) { + return; + } + + if (!($media = $this->getMedia($args))) { + return; + } + + $this->publishOptimizeMessage($media); + } + + /** + * @param EventArgs $args + * + * @return MediaProviderInterface|null + */ + private function getProvider(EventArgs $args): ?MediaProviderInterface + { + $media = $this->getMedia($args); + + if (!$media instanceof MediaInterface) { + return null; + } + + if (!\in_array($media->getProviderName(), $this->providers)) { + return null; + } + + if (!\in_array($media->getExtension(), static::ALLOWED_EXTENSIONS)) { + return null; + } + + $provider = $this->pool->getProvider($media->getProviderName()); + + if (!$provider->getFilesystem()->getAdapter() instanceof Local) { + return null; + } + + return $provider; + } + + /** + * @param EventArgs $args + * + * @return MediaInterface|null + */ + private function getMedia(EventArgs $args): ?MediaInterface + { + if (!$args instanceof LifecycleEventArgs) { + return null; + } + + $media = $args->getEntity(); + + if (!$media instanceof MediaInterface) { + return null; + } + + return $media; + } + + /** + * @param MediaInterface $media + */ + private function publishOptimizeMessage(MediaInterface $media): void + { + $this->backend->createAndPublish(OptimizeImageConsumer::CONSUMER_TYPE, [ + 'mediaId' => $media->getId(), + ]); + } + + /** + * This method intends to detect if binaryContent changed to prevent relaunched of optimization if binaryContent did not change. + * This can happen if the binary is not changed (only metadata) or after update size in OptimizeImageConsumer. + * It can be improved as for now it is only based on the fact that the size of the media changed. + * + * @param EventArgs $args + * @param MediaInterface $media + * + * @return bool + */ + private function doesBinaryContentChanged(EventArgs $args, MediaInterface $media): bool + { + if (!$args instanceof LifecycleEventArgs) { + return false; + } + + /** @var EntityManager $em */ + $em = $args->getEntityManager(); + + $provider = $this->pool->getProvider($media->getProviderName()); + /** @var Local $adapter */ + $adapter = $provider->getFilesystem()->getAdapter(); + $directory = $adapter->getDirectory(); + + $path = sprintf('%s/%s', + $directory, $provider->generatePrivateUrl($media, MediaProviderInterface::FORMAT_REFERENCE) + ); + $file = new File($path); + $size = $file->getSize(); + + return \array_key_exists('size', $em->getUnitOfWork()->getEntityChangeSet($media)) + && $size !== $em->getUnitOfWork()->getEntityChangeSet($media)['size'][1]; + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml new file mode 100644 index 0000000..a9360ab --- /dev/null +++ b/src/Resources/config/services.xml @@ -0,0 +1,27 @@ + + + + + + + %ekino.tiny_png_sonata_media.api_key% + + + + + + + + + + + + %ekino.tiny_png_sonata_media.providers% + + + + + + diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php new file mode 100644 index 0000000..9efbc18 --- /dev/null +++ b/tests/Client/ClientTest.php @@ -0,0 +1,46 @@ + + */ +class ClientTest extends TestCase +{ + /** + * @var Client + */ + private $client; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->client = new Client('foo'); + } + + public function testOptimizeWithFileExistWithoutOverwrite(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageRegExp('#The file \/(.*)\/src\/TinyPngSonataMediaBundle\/tests\/Client\/foo\.png already exists and the overwrite option is false#'); + + $this->client->optimize(__DIR__.'/foo.png', __DIR__.'/foo.png', false); + } +} diff --git a/tests/Client/foo.png b/tests/Client/foo.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/Consumer/OptimizeImageConsumerTest.php b/tests/Consumer/OptimizeImageConsumerTest.php new file mode 100644 index 0000000..ff47a64 --- /dev/null +++ b/tests/Consumer/OptimizeImageConsumerTest.php @@ -0,0 +1,85 @@ + + */ +class OptimizeImageConsumerTest extends TestCase +{ + /** + * @var ManagerInterface|MockObject + */ + private $mediaManager; + + /** + * @var Pool|MockObject + */ + private $pool; + + /** + * @var ClientInterface|MockObject + */ + private $client; + + /** + * @var OptimizeImageConsumer + */ + private $optimizeImageConsumer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->mediaManager = $this->createMock(ManagerInterface::class); + $this->pool = $this->createMock(Pool::class); + $this->client = $this->createMock(ClientInterface::class); + + $this->optimizeImageConsumer = new OptimizeImageConsumer($this->mediaManager, $this->pool, $this->client); + } + + public function testProcessWithoutMedia(): void + { + $this->expectException(HandlingException::class); + $this->expectExceptionMessage('Media not found - id: 1'); + + $this->mediaManager->expects($this->once()) + ->method('find') + ->with(1) + ->willReturn(null); + + $this->optimizeImageConsumer->process($this->configureEvent()); + } + + private function configureEvent(): ConsumerEvent + { + $message = $this->createMock(MessageInterface::class); + $message->expects($this->any())->method('getValue')->with('mediaId')->willReturn(1); + + return new ConsumerEvent($message); + } +} diff --git a/tests/DependencyInjection/EkinoTinyPngSonataMediaExtensionTest.php b/tests/DependencyInjection/EkinoTinyPngSonataMediaExtensionTest.php new file mode 100644 index 0000000..6ed7fcd --- /dev/null +++ b/tests/DependencyInjection/EkinoTinyPngSonataMediaExtensionTest.php @@ -0,0 +1,56 @@ + + */ +class EkinoTinyPngSonataMediaExtensionTest extends TestCase +{ + /** + * @var EkinoTinyPngSonataMediaExtension + */ + private $extension; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->extension = new EkinoTinyPngSonataMediaExtension(); + } + + public function testSonataMediaConfig(): void + { + $container = $this->createPartialMock(ContainerBuilder::class, + ['setParameter'] + ); + + $container->expects($this->at(0))->method('setParameter') + ->with('ekino.tiny_png_sonata_media.api_key', 'foo_api_key'); + $container->expects($this->at(1))->method('setParameter') + ->with('ekino.tiny_png_sonata_media.providers', ['foo_provider', 'bar_provider']); + + $this->extension->load([[ + 'tiny_png_api_key' => 'foo_api_key', + 'providers' => ['foo_provider', 'bar_provider'], + ]], $container); + } +} diff --git a/tests/Listener/MediaEventSubscriberTest.php b/tests/Listener/MediaEventSubscriberTest.php new file mode 100644 index 0000000..b741720 --- /dev/null +++ b/tests/Listener/MediaEventSubscriberTest.php @@ -0,0 +1,63 @@ + + */ +class MediaEventSubscriberTest extends TestCase +{ + /** + * @var Pool|MockObject + */ + private $pool; + + /** + * @var BackendInterface|MockObject + */ + private $backend; + + /** + * @var MediaEventSubscriber + */ + private $mediaEventSubscriber; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->pool = $this->createMock(Pool::class); + $this->backend = $this->createMock(BackendInterface::class); + + $this->mediaEventSubscriber = new MediaEventSubscriber([], $this->pool, $this->backend); + } + + public function testGetSubscribedEvents(): void + { + $this->assertSame([ + Events::postUpdate, + Events::postPersist, + ], $this->mediaEventSubscriber->getSubscribedEvents()); + } +} diff --git a/tests/autoload.php.dist b/tests/autoload.php.dist new file mode 100644 index 0000000..bd3d178 --- /dev/null +++ b/tests/autoload.php.dist @@ -0,0 +1,29 @@ +