diff --git a/.gitattributes b/.gitattributes index c255f66722075..22512cef105ae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ /src/Symfony/Contracts export-ignore /src/Symfony/Bridge/PhpUnit export-ignore +/src/Symfony/Component/Mailer/Bridge export-ignore +/src/Symfony/Component/Messenger/Bridge export-ignore +/src/Symfony/Component/Notifier/Bridge export-ignore diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md new file mode 100644 index 0000000000000..aef16611e0f77 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.md @@ -0,0 +1,22 @@ +--- +name: 🐛 Bug Report +about: ⚠️ See below for security reports +labels: Bug + +--- + +**Symfony version(s) affected**: x.y.z + +**Description** + + +**How to reproduce** + + +**Possible Solution** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/2_Feature_request.md b/.github/ISSUE_TEMPLATE/2_Feature_request.md new file mode 100644 index 0000000000000..908c5ee52664d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_Feature_request.md @@ -0,0 +1,12 @@ +--- +name: 🚀 Feature Request +about: RFC and ideas for new features and improvements + +--- + +**Description** + + +**Example** + diff --git a/.github/ISSUE_TEMPLATE/3_Support_question.md b/.github/ISSUE_TEMPLATE/3_Support_question.md new file mode 100644 index 0000000000000..9480710c15655 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_Support_question.md @@ -0,0 +1,11 @@ +--- +name: ⛔ Support Question +about: See https://symfony.com/support for questions about using Symfony and its components + +--- + +We use GitHub issues only to discuss about Symfony bugs and new features. For +this kind of questions about using Symfony or third-party bundles, please use +any of the support alternatives shown in https://symfony.com/support + +Thanks! diff --git a/.github/ISSUE_TEMPLATE/4_Documentation_issue.md b/.github/ISSUE_TEMPLATE/4_Documentation_issue.md new file mode 100644 index 0000000000000..0855c3c5f1e12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4_Documentation_issue.md @@ -0,0 +1,10 @@ +--- +name: ⛔ Documentation Issue +about: See https://github.com/symfony/symfony-docs/issues for documentation issues + +--- + +Symfony Documentation has its own dedicated repository. Please open your +documentation-related issue at https://github.com/symfony/symfony-docs/issues + +Thanks! diff --git a/.github/composer-config.json b/.github/composer-config.json index 01c998e5ed672..1b82f7c5db002 100644 --- a/.github/composer-config.json +++ b/.github/composer-config.json @@ -4,6 +4,7 @@ "preferred-install": { "symfony/form": "source", "symfony/http-kernel": "source", + "symfony/messenger": "source", "symfony/notifier": "source", "symfony/validator": "source", "*": "dist" diff --git a/.github/workflows/phpunit-bridge.yml b/.github/workflows/phpunit-bridge.yml deleted file mode 100644 index b503ce48d8a17..0000000000000 --- a/.github/workflows/phpunit-bridge.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: PhpUnitBridge - -on: - push: - paths: - - 'src/Symfony/Bridge/PhpUnit/**' - pull_request: - paths: - - 'src/Symfony/Bridge/PhpUnit/**' - -defaults: - run: - shell: bash - -jobs: - lint: - name: Lint - runs-on: Ubuntu-20.04 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - coverage: "none" - php-version: "5.5" - - - name: Lint - run: find ./src/Symfony/Bridge/PhpUnit -name '*.php' | grep -v -e /Tests/ -e ForV6 -e ForV7 -e ForV8 -e ForV9 -e ConstraintLogicTrait | parallel -j 4 php -l {} diff --git a/.travis.yml b/.travis.yml index 009143743f598..dbfd75c7ef41b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ addons: env: global: - - SYMFONY_VERSION=5.2 + - SYMFONY_VERSION=5.x - MIN_PHP=7.2.5 - SYMFONY_PROCESS_PHP_TEST_BINARY=~/.phpenv/shims/php - SYMFONY_PHPUNIT_DISABLE_RESULT_CACHE=1 diff --git a/UPGRADE-5.3.md b/UPGRADE-5.3.md new file mode 100644 index 0000000000000..4c5576adb9a38 --- /dev/null +++ b/UPGRADE-5.3.md @@ -0,0 +1,111 @@ +UPGRADE FROM 5.2 to 5.3 +======================= + +Asset +----- + + * Deprecated `RemoteJsonManifestVersionStrategy`, use `JsonManifestVersionStrategy` instead + +DoctrineBridge +-------------- + + * Remove `UuidV*Generator` classes + +DomCrawler +---------- + + * Deprecated the `parents()` method, use `ancestors()` instead + +Form +---- + + * Changed `$forms` parameter type of the `DataMapperInterface::mapDataToForms()` method from `iterable` to `\Traversable` + * Changed `$forms` parameter type of the `DataMapperInterface::mapFormsToData()` method from `iterable` to `\Traversable` + * Deprecated passing an array as the second argument of the `DataMapper::mapDataToForms()` method, pass `\Traversable` instead + * Deprecated passing an array as the first argument of the `DataMapper::mapFormsToData()` method, pass `\Traversable` instead + * Deprecated passing an array as the second argument of the `CheckboxListMapper::mapDataToForms()` method, pass `\Traversable` instead + * Deprecated passing an array as the first argument of the `CheckboxListMapper::mapFormsToData()` method, pass `\Traversable` instead + * Deprecated passing an array as the second argument of the `RadioListMapper::mapDataToForms()` method, pass `\Traversable` instead + * Deprecated passing an array as the first argument of the `RadioListMapper::mapFormsToData()` method, pass `\Traversable` instead + * Dependency on `symfony/intl` was removed. Install `symfony/intl` if you are using `LocaleType`, `CountryType`, `CurrencyType`, `LanguageType` or `TimezoneType` + +FrameworkBundle +--------------- + + * Deprecate the `session.storage` alias and `session.storage.*` services, use the `session.storage.factory` alias and `session.storage.factory.*` services instead + * Deprecate the `framework.session.storage_id` configuration option, use the `framework.session.storage_factory_id` configuration option instead + * Deprecate the `session` service and the `SessionInterface` alias, use the `\Symfony\Component\HttpFoundation\Request::getSession()` or the new `\Symfony\Component\HttpFoundation\RequestStack::getSession()` methods instead + +HttpFoundation +-------------- + + * Deprecate the `NamespacedAttributeBag` class + +HttpKernel +---------- + + * Deprecate `ArgumentInterface` + * Deprecate `ArgumentMetadata::getAttribute()`, use `getAttributes()` instead + * Marked the class `Symfony\Component\HttpKernel\EventListener\DebugHandlersListener` as internal + +Messenger +--------- + + * Deprecated the `prefetch_count` parameter in the AMQP bridge, it has no effect and will be removed in Symfony 6.0 + * Deprecated the use of TLS option for Redis Bridge, use `rediss://127.0.0.1` instead of `redis://127.0.0.1?tls=1` + +Notifier +-------- + + * Changed the return type of `AbstractTransportFactory::getEndpoint()` from `?string` to `string` + * Changed the signature of `Dsn::__construct()` to accept a single `string $dsn` argument + * Removed the `Dsn::fromString()` method + + +PhpunitBridge +------------- + + * Deprecated the `SetUpTearDownTrait` trait, use original methods with "void" return typehint + +PropertyInfo +------------ + + * Deprecated the `Type::getCollectionKeyType()` and `Type::getCollectionValueType()` methods, use `Type::getCollectionKeyTypes()` and `Type::getCollectionValueTypes()` instead + +Routing +------- + + * Deprecated creating instances of the `Route` annotation class by passing an array of parameters, use named arguments instead + +Security +-------- + + * Deprecate all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead + * Deprecated voters that do not return a valid decision when calling the `vote` method + +SecurityBundle +-------------- + + * [BC break] Add `login_throttling.lock_factory` setting defaulting to `null`. Set this option + to `lock.factory` if you need precise login rate limiting with synchronous requests. + * Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, + use `UserPasswordHashCommand` and `user:hash-password` instead + * Deprecate the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, + use `security.password_hasher_factory` and `Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface` instead + * Deprecate the `security.user_password_encoder.generic` service, the `security.password_encoder` and the `Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface` aliases, + use `security.user_password_hasher`, `security.password_hasher` and `Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface` instead + +Serializer +---------- + + * Deprecated `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead + +Uid +--- + + * Replaced `UuidV1::getTime()`, `UuidV6::getTime()` and `Ulid::getTime()` by `UuidV1::getDateTime()`, `UuidV6::getDateTime()` and `Ulid::getDateTime()` + +Workflow +-------- + + * Deprecate `InvalidTokenConfigurationException` diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index b0e52953dd672..a254493d4e0f6 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -1,6 +1,11 @@ UPGRADE FROM 5.x to 6.0 ======================= +Asset +----- + + * Removed `RemoteJsonManifestVersionStrategy`, use `JsonManifestVersionStrategy` instead. + Config ------ @@ -28,6 +33,11 @@ DependencyInjection * The `ref()` function from the PHP-DSL has been removed, use `service()` instead. * Removed `Definition::setPrivate()` and `Alias::setPrivate()`, use `setPublic()` instead +DomCrawler +---------- + + * Removed the `parents()` method, use `ancestors()` instead. + Dotenv ------ @@ -49,10 +59,19 @@ Form * The `Symfony\Component\Form\Extension\Validator\Util\ServerParams` class has been removed, use its parent `Symfony\Component\Form\Util\ServerParams` instead. * The `NumberToLocalizedStringTransformer::ROUND_*` constants have been removed, use `\NumberFormatter::ROUND_*` instead. * Removed `PropertyPathMapper` in favor of `DataMapper` and `PropertyPathAccessor`. + * Changed `$forms` parameter type of the `DataMapper::mapDataToForms()` method from `iterable` to `\Traversable`. + * Changed `$forms` parameter type of the `DataMapper::mapFormsToData()` method from `iterable` to `\Traversable`. + * Changed `$checkboxes` parameter type of the `CheckboxListMapper::mapDataToForms()` method from `iterable` to `\Traversable`. + * Changed `$checkboxes` parameter type of the `CheckboxListMapper::mapFormsToData()` method from `iterable` to `\Traversable`. + * Changed `$radios` parameter type of the `RadioListMapper::mapDataToForms()` method from `iterable` to `\Traversable`. + * Changed `$radios` parameter type of the `RadioListMapper::mapFormsToData()` method from `iterable` to `\Traversable`. FrameworkBundle --------------- + * Remove the `session.storage` alias and `session.storage.*` services, use the `session.storage.factory` alias and `session.storage.factory.*` services instead + * Remove `framework.session.storage_id` configuration option, use the `framework.session.storage_factory_id` configuration option instead + * Remove the `session` service and the `SessionInterface` alias, use the `\Symfony\Component\HttpFoundation\Request::getSession()` or the new `\Symfony\Component\HttpFoundation\RequestStack::getSession()` methods instead * `MicroKernelTrait::configureRoutes()` is now always called with a `RoutingConfigurator` * The "framework.router.utf8" configuration option defaults to `true` * Removed `session.attribute_bag` service and `session.flash_bag` service. @@ -63,6 +82,7 @@ FrameworkBundle HttpFoundation -------------- + * Remove the `NamespacedAttributeBag` class * Removed `Response::create()`, `JsonResponse::create()`, `RedirectResponse::create()`, `StreamedResponse::create()` and `BinaryFileResponse::create()` methods (use `__construct()` instead) @@ -72,6 +92,8 @@ HttpFoundation HttpKernel ---------- + * Remove `ArgumentInterface` + * Remove `ArgumentMetadata::getAttribute()`, use `getAttributes()` instead * Made `WarmableInterface::warmUp()` return a list of classes or files to preload on PHP 7.4+ * Removed support for `service:action` syntax to reference controllers. Use `serviceOrFqcn::method` instead. @@ -101,6 +123,8 @@ Messenger * Use of invalid options in Redis and AMQP connections now throws an error. * The signature of method `RetryStrategyInterface::isRetryable()` has been updated to `RetryStrategyInterface::isRetryable(Envelope $message, \Throwable $throwable = null)`. * The signature of method `RetryStrategyInterface::getWaitingTime()` has been updated to `RetryStrategyInterface::getWaitingTime(Envelope $message, \Throwable $throwable = null)`. + * Removed the `prefetch_count` parameter in the AMQP bridge. + * Removed the use of TLS option for Redis Bridge, use `rediss://127.0.0.1` instead of `redis://127.0.0.1?tls=1` Mime ---- @@ -123,6 +147,7 @@ PhpUnitBridge ------------- * Removed support for `@expectedDeprecation` annotations, use the `ExpectDeprecationTrait::expectDeprecation()` method instead. + * Removed the `SetUpTearDownTrait` trait, use original methods with "void" return typehint. PropertyAccess -------------- @@ -133,6 +158,7 @@ PropertyAccess PropertyInfo ------------ + * Removed the `Type::getCollectionKeyType()` and `Type::getCollectionValueType()` methods, use `Type::getCollectionKeyTypes()` and `Type::getCollectionValueTypes()` instead. * Dropped the `enable_magic_call_extraction` context option in `ReflectionExtractor::getWriteInfo()` and `ReflectionExtractor::getReadInfo()` in favor of `enable_magic_methods_extraction`. Routing @@ -141,10 +167,15 @@ Routing * Removed `RouteCollectionBuilder`. * Added argument `$priority` to `RouteCollection::add()` * Removed the `RouteCompiler::REGEX_DELIMITER` constant + * Removed the `$data` parameter from the constructor of the `Route` annotation class Security -------- + * Drop all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead + * Drop support for `SessionInterface $session` as constructor argument of `SessionTokenStorage`, inject a `\Symfony\Component\HttpFoundation\RequestStack $requestStack` instead + * Drop support for `session` provided by the ServiceLocator injected in `UsageTrackingTokenStorage`, provide a `request_stack` service instead + * Make `SessionTokenStorage` throw a `SessionNotFoundException` when called outside a request context * Removed `ROLE_PREVIOUS_ADMIN` role in favor of `IS_IMPERSONATOR` attribute * Removed `LogoutSuccessHandlerInterface` and `LogoutHandlerInterface`, register a listener on the `LogoutEvent` event instead. * Removed `DefaultLogoutSuccessHandler` in favor of `DefaultLogoutListener`. @@ -153,6 +184,23 @@ Security in `PreAuthenticatedToken`, `RememberMeToken`, `SwitchUserToken`, `UsernamePasswordToken`, `DefaultAuthenticationSuccessHandler`. * Removed the `AbstractRememberMeServices::$providerKey` property in favor of `AbstractRememberMeServices::$firewallName` + * `AccessDecisionManager` now throw an exception when a voter does not return a valid decision. + +SecurityBundle +-------------- + + * Remove the `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, + use `UserPasswordHashCommand` and `user:hash-password` instead + * Remove the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, + use `security.password_hasher_factory` and `Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface` instead + * Remove the `security.user_password_encoder.generic` service, the `security.password_encoder` and the `Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface` aliases, + use `security.user_password_hasher`, `security.password_hasher` and `Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface` instead + +Serializer +---------- + + * Removed `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead. + * `ArrayDenormalizer` does not implement `SerializerAwareInterface` anymore. TwigBundle ---------- @@ -219,6 +267,11 @@ Validator ->addDefaultDoctrineAnnotationReader(); ``` +Workflow +-------- + + * Remove `InvalidTokenConfigurationException` + Yaml ---- diff --git a/composer.json b/composer.json index 667c2dee04081..9a81fcdd2c8dd 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,6 @@ }, "replace": { "symfony/asset": "self.version", - "symfony/amazon-mailer": "self.version", "symfony/browser-kit": "self.version", "symfony/cache": "self.version", "symfony/config": "self.version", @@ -74,7 +73,6 @@ "symfony/finder": "self.version", "symfony/form": "self.version", "symfony/framework-bundle": "self.version", - "symfony/google-mailer": "self.version", "symfony/http-client": "self.version", "symfony/http-foundation": "self.version", "symfony/http-kernel": "self.version", @@ -82,15 +80,13 @@ "symfony/intl": "self.version", "symfony/ldap": "self.version", "symfony/lock": "self.version", - "symfony/mailchimp-mailer": "self.version", "symfony/mailer": "self.version", - "symfony/mailgun-mailer": "self.version", "symfony/messenger": "self.version", "symfony/mime": "self.version", "symfony/monolog-bridge": "self.version", "symfony/notifier": "self.version", "symfony/options-resolver": "self.version", - "symfony/postmark-mailer": "self.version", + "symfony/password-hasher": "self.version", "symfony/process": "self.version", "symfony/property-access": "self.version", "symfony/property-info": "self.version", @@ -103,7 +99,6 @@ "symfony/security-guard": "self.version", "symfony/security-http": "self.version", "symfony/semaphore": "self.version", - "symfony/sendgrid-mailer": "self.version", "symfony/serializer": "self.version", "symfony/stopwatch": "self.version", "symfony/string": "self.version", @@ -128,7 +123,7 @@ "async-aws/sqs": "^1.0", "cache/integration-tests": "dev-master", "composer/package-versions-deprecated": "^1.8", - "doctrine/annotations": "^1.10.4", + "doctrine/annotations": "^1.12", "doctrine/cache": "~1.6", "doctrine/collections": "~1.0", "doctrine/data-fixtures": "^1.1", @@ -146,6 +141,7 @@ "psr/http-client": "^1.0", "psr/simple-cache": "^1.0", "egulias/email-validator": "^2.1.10", + "symfony/mercure-bundle": "^0.2", "symfony/phpunit-bridge": "^5.2", "symfony/security-acl": "~2.8|~3.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", @@ -154,6 +150,8 @@ "twig/markdown-extra": "^2.12" }, "conflict": { + "async-aws/core": "<1.5", + "doctrine/annotations": "<1.12", "doctrine/dbal": "<2.10", "masterminds/html5": "<2.6", "phpdocumentor/reflection-docblock": "<3.2.2", @@ -191,7 +189,7 @@ "url": "src/Symfony/Contracts", "options": { "versions": { - "symfony/contracts": "2.3.x-dev" + "symfony/contracts": "2.4.x-dev" } } } diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index af0b9366a1b66..7afda2ec2d167 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +5.3 +--- + + * Deprecate `DoctrineTestHelper` and `TestRepositoryFactory` + * [BC BREAK] Remove `UuidV*Generator` classes + * Add `UuidGenerator` + 5.2.0 ----- diff --git a/src/Symfony/Bridge/Doctrine/ContainerAwareEventManager.php b/src/Symfony/Bridge/Doctrine/ContainerAwareEventManager.php index 9b3c1595a41f0..386d8c62703c6 100644 --- a/src/Symfony/Bridge/Doctrine/ContainerAwareEventManager.php +++ b/src/Symfony/Bridge/Doctrine/ContainerAwareEventManager.php @@ -13,6 +13,7 @@ use Doctrine\Common\EventArgs; use Doctrine\Common\EventManager; +use Doctrine\Common\EventSubscriber; use Psr\Container\ContainerInterface; /** @@ -34,6 +35,9 @@ class ContainerAwareEventManager extends EventManager private $methods = []; private $container; + /** + * @param list $subscriberIds List of subscribers, subscriber ids, or [events, listener] tuples + */ public function __construct(ContainerInterface $container, array $subscriberIds = []) { $this->container = $container; @@ -113,6 +117,10 @@ public function hasListeners($event) */ public function addEventListener($events, $listener) { + if (!$this->initializedSubscribers) { + $this->initializeSubscribers(); + } + $hash = $this->getHash($listener); foreach ((array) $events as $event) { @@ -135,6 +143,10 @@ public function addEventListener($events, $listener) */ public function removeEventListener($events, $listener) { + if (!$this->initializedSubscribers) { + $this->initializeSubscribers(); + } + $hash = $this->getHash($listener); foreach ((array) $events as $event) { @@ -149,6 +161,24 @@ public function removeEventListener($events, $listener) } } + public function addEventSubscriber(EventSubscriber $subscriber): void + { + if (!$this->initializedSubscribers) { + $this->initializeSubscribers(); + } + + parent::addEventSubscriber($subscriber); + } + + public function removeEventSubscriber(EventSubscriber $subscriber): void + { + if (!$this->initializedSubscribers) { + $this->initializeSubscribers(); + } + + parent::removeEventSubscriber($subscriber); + } + private function initializeListeners(string $eventName) { $this->initialized[$eventName] = true; @@ -164,20 +194,15 @@ private function initializeListeners(string $eventName) private function initializeSubscribers() { $this->initializedSubscribers = true; - - $eventListeners = $this->listeners; - // reset eventListener to respect priority: EventSubscribers have a higher priority - $this->listeners = []; - foreach ($this->subscribers as $id => $subscriber) { - if (\is_string($subscriber)) { - parent::addEventSubscriber($this->subscribers[$id] = $this->container->get($subscriber)); + foreach ($this->subscribers as $subscriber) { + if (\is_array($subscriber)) { + $this->addEventListener(...$subscriber); + continue; } - } - foreach ($eventListeners as $event => $listeners) { - if (!isset($this->listeners[$event])) { - $this->listeners[$event] = []; + if (\is_string($subscriber)) { + $subscriber = $this->container->get($subscriber); } - $this->listeners[$event] += $listeners; + parent::addEventSubscriber($subscriber); } $this->subscribers = []; } diff --git a/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPass.php b/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPass.php index 61046c28a5098..a6853fb4809b4 100644 --- a/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPass.php +++ b/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPass.php @@ -57,9 +57,7 @@ public function process(ContainerBuilder $container) } $this->connections = $container->getParameter($this->connections); - $listenerRefs = []; - $this->addTaggedSubscribers($container, $listenerRefs); - $this->addTaggedListeners($container, $listenerRefs); + $listenerRefs = $this->addTaggedServices($container); // replace service container argument of event managers with smaller service locator // so services can even remain private @@ -69,15 +67,20 @@ public function process(ContainerBuilder $container) } } - private function addTaggedSubscribers(ContainerBuilder $container, array &$listenerRefs) + private function addTaggedServices(ContainerBuilder $container): array { + $listenerTag = $this->tagPrefix.'.event_listener'; $subscriberTag = $this->tagPrefix.'.event_subscriber'; - $taggedSubscribers = $this->findAndSortTags($subscriberTag, $container); + $listenerRefs = []; + $taggedServices = $this->findAndSortTags([$subscriberTag, $listenerTag], $container); $managerDefs = []; - foreach ($taggedSubscribers as $taggedSubscriber) { - [$id, $tag] = $taggedSubscriber; + foreach ($taggedServices as $taggedSubscriber) { + [$tagName, $id, $tag] = $taggedSubscriber; $connections = isset($tag['connection']) ? [$tag['connection']] : array_keys($this->connections); + if ($listenerTag === $tagName && !isset($tag['event'])) { + throw new InvalidArgumentException(sprintf('Doctrine event listener "%s" must specify the "event" attribute.', $id)); + } foreach ($connections as $con) { if (!isset($this->connections[$con])) { throw new RuntimeException(sprintf('The Doctrine connection "%s" referenced in service "%s" does not exist. Available connections names: "%s".', $con, $id, implode('", "', array_keys($this->connections)))); @@ -95,39 +98,25 @@ private function addTaggedSubscribers(ContainerBuilder $container, array &$liste } if (ContainerAwareEventManager::class === $managerClass) { - $listenerRefs[$con][$id] = new Reference($id); $refs = $managerDef->getArguments()[1] ?? []; - $refs[] = $id; + $listenerRefs[$con][$id] = new Reference($id); + if ($subscriberTag === $tagName) { + $refs[] = $id; + } else { + $refs[] = [[$tag['event']], $id]; + } $managerDef->setArgument(1, $refs); } else { - $managerDef->addMethodCall('addEventSubscriber', [new Reference($id)]); + if ($subscriberTag === $tagName) { + $managerDef->addMethodCall('addEventSubscriber', [new Reference($id)]); + } else { + $managerDef->addMethodCall('addEventListener', [[$tag['event']], new Reference($id)]); + } } } } - } - - private function addTaggedListeners(ContainerBuilder $container, array &$listenerRefs) - { - $listenerTag = $this->tagPrefix.'.event_listener'; - $taggedListeners = $this->findAndSortTags($listenerTag, $container); - foreach ($taggedListeners as $taggedListener) { - [$id, $tag] = $taggedListener; - if (!isset($tag['event'])) { - throw new InvalidArgumentException(sprintf('Doctrine event listener "%s" must specify the "event" attribute.', $id)); - } - - $connections = isset($tag['connection']) ? [$tag['connection']] : array_keys($this->connections); - foreach ($connections as $con) { - if (!isset($this->connections[$con])) { - throw new RuntimeException(sprintf('The Doctrine connection "%s" referenced in service "%s" does not exist. Available connections names: "%s".', $con, $id, implode('", "', array_keys($this->connections)))); - } - $listenerRefs[$con][$id] = new Reference($id); - - // we add one call per event per service so we have the correct order - $this->getEventManagerDef($container, $con)->addMethodCall('addEventListener', [[$tag['event']], $id]); - } - } + return $listenerRefs; } private function getEventManagerDef(ContainerBuilder $container, string $name) @@ -149,14 +138,16 @@ private function getEventManagerDef(ContainerBuilder $container, string $name) * @see https://bugs.php.net/53710 * @see https://bugs.php.net/60926 */ - private function findAndSortTags(string $tagName, ContainerBuilder $container): array + private function findAndSortTags(array $tagNames, ContainerBuilder $container): array { $sortedTags = []; - foreach ($container->findTaggedServiceIds($tagName, true) as $serviceId => $tags) { - foreach ($tags as $attributes) { - $priority = $attributes['priority'] ?? 0; - $sortedTags[$priority][] = [$serviceId, $attributes]; + foreach ($tagNames as $tagName) { + foreach ($container->findTaggedServiceIds($tagName, true) as $serviceId => $tags) { + foreach ($tags as $attributes) { + $priority = $attributes['priority'] ?? 0; + $sortedTags[$priority][] = [$tagName, $serviceId, $attributes]; + } } } diff --git a/src/Symfony/Bridge/Doctrine/IdGenerator/UlidGenerator.php b/src/Symfony/Bridge/Doctrine/IdGenerator/UlidGenerator.php index 364bf3b3acb96..b3923d11c051a 100644 --- a/src/Symfony/Bridge/Doctrine/IdGenerator/UlidGenerator.php +++ b/src/Symfony/Bridge/Doctrine/IdGenerator/UlidGenerator.php @@ -13,15 +13,24 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Id\AbstractIdGenerator; +use Symfony\Component\Uid\Factory\UlidFactory; use Symfony\Component\Uid\Ulid; -/** - * @experimental in 5.2 - */ final class UlidGenerator extends AbstractIdGenerator { + private $factory; + + public function __construct(UlidFactory $factory = null) + { + $this->factory = $factory; + } + public function generate(EntityManager $em, $entity): Ulid { + if ($this->factory) { + return $this->factory->create(); + } + return new Ulid(); } } diff --git a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidGenerator.php b/src/Symfony/Bridge/Doctrine/IdGenerator/UuidGenerator.php new file mode 100644 index 0000000000000..272989a834ab7 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/IdGenerator/UuidGenerator.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\IdGenerator; + +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Id\AbstractIdGenerator; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\Uuid; + +final class UuidGenerator extends AbstractIdGenerator +{ + private $protoFactory; + private $factory; + private $entityGetter; + + public function __construct(UuidFactory $factory = null) + { + $this->protoFactory = $this->factory = $factory ?? new UuidFactory(); + } + + public function generate(EntityManager $em, $entity): Uuid + { + if (null !== $this->entityGetter) { + if (\is_callable([$entity, $this->entityGetter])) { + return $this->factory->create($entity->{$this->entityGetter}()); + } + + return $this->factory->create($entity->{$this->entityGetter}); + } + + return $this->factory->create(); + } + + /** + * @param Uuid|string|null $namespace + * + * @return static + */ + public function nameBased(string $entityGetter, $namespace = null): self + { + $clone = clone $this; + $clone->factory = $clone->protoFactory->nameBased($namespace); + $clone->entityGetter = $entityGetter; + + return $clone; + } + + /** + * @return static + */ + public function randomBased(): self + { + $clone = clone $this; + $clone->factory = $clone->protoFactory->randomBased(); + $clone->entityGetter = null; + + return $clone; + } + + /** + * @param Uuid|string|null $node + * + * @return static + */ + public function timeBased($node = null): self + { + $clone = clone $this; + $clone->factory = $clone->protoFactory->timeBased($node); + $clone->entityGetter = null; + + return $clone; + } +} diff --git a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV1Generator.php b/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV1Generator.php deleted file mode 100644 index 55f6eb1eb2113..0000000000000 --- a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV1Generator.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Doctrine\IdGenerator; - -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Id\AbstractIdGenerator; -use Symfony\Component\Uid\UuidV1; - -/** - * @experimental in 5.2 - */ -final class UuidV1Generator extends AbstractIdGenerator -{ - public function generate(EntityManager $em, $entity): UuidV1 - { - return new UuidV1(); - } -} diff --git a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV4Generator.php b/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV4Generator.php deleted file mode 100644 index 8731daa641032..0000000000000 --- a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV4Generator.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Doctrine\IdGenerator; - -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Id\AbstractIdGenerator; -use Symfony\Component\Uid\UuidV4; - -/** - * @experimental in 5.2 - */ -final class UuidV4Generator extends AbstractIdGenerator -{ - public function generate(EntityManager $em, $entity): UuidV4 - { - return new UuidV4(); - } -} diff --git a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV6Generator.php b/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV6Generator.php deleted file mode 100644 index cdcd908e93647..0000000000000 --- a/src/Symfony/Bridge/Doctrine/IdGenerator/UuidV6Generator.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Doctrine\IdGenerator; - -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Id\AbstractIdGenerator; -use Symfony\Component\Uid\UuidV6; - -/** - * @experimental in 5.2 - */ -final class UuidV6Generator extends AbstractIdGenerator -{ - public function generate(EntityManager $em, $entity): UuidV6 - { - return new UuidV6(); - } -} diff --git a/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php b/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php index d3d25c17b275d..4821fffc616e3 100644 --- a/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php +++ b/src/Symfony/Bridge/Doctrine/Test/DoctrineTestHelper.php @@ -24,6 +24,8 @@ * Provides utility functions needed in tests. * * @author Bernhard Schussek + * + * @deprecated in 5.3, will be removed in 6.0. */ class DoctrineTestHelper { @@ -38,6 +40,10 @@ public static function createTestEntityManager(Configuration $config = null) TestCase::markTestSkipped('Extension pdo_sqlite is required.'); } + if (__CLASS__ === static::class) { + trigger_deprecation('symfony/doctrine-bridge', '5.3', '"%s" is deprecated and will be removed in 6.0.', __CLASS__); + } + if (null === $config) { $config = self::createTestConfiguration(); } @@ -55,6 +61,10 @@ public static function createTestEntityManager(Configuration $config = null) */ public static function createTestConfiguration() { + if (__CLASS__ === static::class) { + trigger_deprecation('symfony/doctrine-bridge', '5.3', '"%s" is deprecated and will be removed in 6.0.', __CLASS__); + } + $config = new Configuration(); $config->setEntityNamespaces(['SymfonyTestsDoctrine' => 'Symfony\Bridge\Doctrine\Tests\Fixtures']); $config->setAutoGenerateProxyClasses(true); @@ -70,6 +80,10 @@ public static function createTestConfiguration() */ public static function createTestConfigurationWithXmlLoader() { + if (__CLASS__ === static::class) { + trigger_deprecation('symfony/doctrine-bridge', '5.3', '"%s" is deprecated and will be removed in 6.0.', __CLASS__); + } + $config = static::createTestConfiguration(); $driverChain = new MappingDriverChain(); diff --git a/src/Symfony/Bridge/Doctrine/Test/TestRepositoryFactory.php b/src/Symfony/Bridge/Doctrine/Test/TestRepositoryFactory.php index 6197c6ae5169c..ed63b6bd03bcb 100644 --- a/src/Symfony/Bridge/Doctrine/Test/TestRepositoryFactory.php +++ b/src/Symfony/Bridge/Doctrine/Test/TestRepositoryFactory.php @@ -18,8 +18,10 @@ /** * @author Andreas Braun + * + * @deprecated in 5.3, will be removed in 6.0. */ -final class TestRepositoryFactory implements RepositoryFactory +class TestRepositoryFactory implements RepositoryFactory { /** * @var ObjectRepository[] @@ -33,6 +35,10 @@ final class TestRepositoryFactory implements RepositoryFactory */ public function getRepository(EntityManagerInterface $entityManager, $entityName) { + if (__CLASS__ === static::class) { + trigger_deprecation('symfony/doctrine-bridge', '5.3', '"%s" is deprecated and will be removed in 6.0.', __CLASS__); + } + $repositoryHash = $this->getRepositoryHash($entityManager, $entityName); if (isset($this->repositoryList[$repositoryHash])) { @@ -44,6 +50,10 @@ public function getRepository(EntityManagerInterface $entityManager, $entityName public function setRepository(EntityManagerInterface $entityManager, string $entityName, ObjectRepository $repository) { + if (__CLASS__ === static::class) { + trigger_deprecation('symfony/doctrine-bridge', '5.3', '"%s" is deprecated and will be removed in 6.0.', __CLASS__); + } + $repositoryHash = $this->getRepositoryHash($entityManager, $entityName); $this->repositoryList[$repositoryHash] = $repository; diff --git a/src/Symfony/Bridge/Doctrine/Tests/ContainerAwareEventManagerTest.php b/src/Symfony/Bridge/Doctrine/Tests/ContainerAwareEventManagerTest.php index c77c13e59fecb..1631fa8ae37e7 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/ContainerAwareEventManagerTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/ContainerAwareEventManagerTest.php @@ -27,10 +27,24 @@ protected function setUp(): void $this->evm = new ContainerAwareEventManager($this->container); } + public function testDispatchEventRespectOrder() + { + $this->evm = new ContainerAwareEventManager($this->container, ['sub1', [['foo'], 'list1'], 'sub2']); + + $this->container->set('list1', $listener1 = new MyListener()); + $this->container->set('sub1', $subscriber1 = new MySubscriber(['foo'])); + $this->container->set('sub2', $subscriber2 = new MySubscriber(['foo'])); + + $this->assertSame([$subscriber1, $listener1, $subscriber2], array_values($this->evm->getListeners('foo'))); + } + public function testDispatchEvent() { $this->evm = new ContainerAwareEventManager($this->container, ['lazy4']); + $this->container->set('lazy4', $subscriber1 = new MySubscriber(['foo'])); + $this->assertSame(0, $subscriber1->calledSubscribedEventsCount); + $this->container->set('lazy1', $listener1 = new MyListener()); $this->evm->addEventListener('foo', 'lazy1'); $this->evm->addEventListener('foo', $listener2 = new MyListener()); @@ -40,10 +54,8 @@ public function testDispatchEvent() $this->container->set('lazy3', $listener5 = new MyListener()); $this->evm->addEventListener('foo', $listener5 = new MyListener()); $this->evm->addEventListener('bar', $listener5); - $this->container->set('lazy4', $subscriber1 = new MySubscriber(['foo'])); $this->evm->addEventSubscriber($subscriber2 = new MySubscriber(['bar'])); - $this->assertSame(0, $subscriber1->calledSubscribedEventsCount); $this->assertSame(1, $subscriber2->calledSubscribedEventsCount); $this->evm->dispatchEvent('foo'); @@ -72,8 +84,13 @@ public function testAddEventListenerAndSubscriberAfterDispatchEvent() { $this->evm = new ContainerAwareEventManager($this->container, ['lazy7']); + $this->container->set('lazy7', $subscriber1 = new MySubscriber(['foo'])); + $this->assertSame(0, $subscriber1->calledSubscribedEventsCount); + $this->container->set('lazy1', $listener1 = new MyListener()); $this->evm->addEventListener('foo', 'lazy1'); + $this->assertSame(1, $subscriber1->calledSubscribedEventsCount); + $this->evm->addEventListener('foo', $listener2 = new MyListener()); $this->container->set('lazy2', $listener3 = new MyListener()); $this->evm->addEventListener('bar', 'lazy2'); @@ -81,10 +98,8 @@ public function testAddEventListenerAndSubscriberAfterDispatchEvent() $this->container->set('lazy3', $listener5 = new MyListener()); $this->evm->addEventListener('foo', $listener5 = new MyListener()); $this->evm->addEventListener('bar', $listener5); - $this->container->set('lazy7', $subscriber1 = new MySubscriber(['foo'])); $this->evm->addEventSubscriber($subscriber2 = new MySubscriber(['bar'])); - $this->assertSame(0, $subscriber1->calledSubscribedEventsCount); $this->assertSame(1, $subscriber2->calledSubscribedEventsCount); $this->evm->dispatchEvent('foo'); diff --git a/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPassTest.php b/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPassTest.php index 28b983324e55d..358f6693cca92 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPassTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPassTest.php @@ -85,18 +85,18 @@ public function testProcessEventListenersWithPriorities() $this->process($container); $eventManagerDef = $container->getDefinition('doctrine.dbal.default_connection.event_manager'); - $methodCalls = $eventManagerDef->getMethodCalls(); $this->assertEquals( [ - ['addEventListener', [['foo_bar'], 'c']], - ['addEventListener', [['foo_bar'], 'a']], - ['addEventListener', [['bar'], 'a']], - ['addEventListener', [['foo'], 'b']], - ['addEventListener', [['foo'], 'a']], + [['foo_bar'], 'c'], + [['foo_bar'], 'a'], + [['bar'], 'a'], + [['foo'], 'b'], + [['foo'], 'a'], ], - $methodCalls + $eventManagerDef->getArgument(1) ); + $this->assertEquals([], $eventManagerDef->getMethodCalls()); $serviceLocatorDef = $container->getDefinition((string) $eventManagerDef->getArgument(0)); $this->assertSame(ServiceLocator::class, $serviceLocatorDef->getClass()); @@ -144,11 +144,12 @@ public function testProcessEventListenersWithMultipleConnections() // first connection $this->assertEquals( [ - ['addEventListener', [['onFlush'], 'a']], - ['addEventListener', [['onFlush'], 'b']], + [['onFlush'], 'a'], + [['onFlush'], 'b'], ], - $eventManagerDef->getMethodCalls() + $eventManagerDef->getArgument(1) ); + $this->assertEquals([], $eventManagerDef->getMethodCalls()); $serviceLocatorDef = $container->getDefinition((string) $eventManagerDef->getArgument(0)); $this->assertSame(ServiceLocator::class, $serviceLocatorDef->getClass()); @@ -164,11 +165,12 @@ public function testProcessEventListenersWithMultipleConnections() $secondEventManagerDef = $container->getDefinition('doctrine.dbal.second_connection.event_manager'); $this->assertEquals( [ - ['addEventListener', [['onFlush'], 'a']], - ['addEventListener', [['onFlush'], 'c']], + [['onFlush'], 'a'], + [['onFlush'], 'c'], ], - $secondEventManagerDef->getMethodCalls() + $secondEventManagerDef->getArgument(1) ); + $this->assertEquals([], $secondEventManagerDef->getMethodCalls()); $serviceLocatorDef = $container->getDefinition((string) $secondEventManagerDef->getArgument(0)); $this->assertSame(ServiceLocator::class, $serviceLocatorDef->getClass()); @@ -315,6 +317,104 @@ public function testProcessEventSubscribersWithPriorities() ); } + public function testProcessEventSubscribersAndListenersWithPriorities() + { + $container = $this->createBuilder(); + + $container + ->register('a', 'stdClass') + ->addTag('doctrine.event_subscriber') + ; + $container + ->register('b', 'stdClass') + ->addTag('doctrine.event_subscriber', [ + 'priority' => 5, + ]) + ; + $container + ->register('c', 'stdClass') + ->addTag('doctrine.event_subscriber', [ + 'priority' => 10, + ]) + ; + $container + ->register('d', 'stdClass') + ->addTag('doctrine.event_subscriber', [ + 'priority' => 10, + ]) + ; + $container + ->register('e', 'stdClass') + ->addTag('doctrine.event_subscriber', [ + 'priority' => 10, + ]) + ; + $container + ->register('f', 'stdClass') + ->setPublic(false) + ->addTag('doctrine.event_listener', [ + 'event' => 'bar', + ]) + ->addTag('doctrine.event_listener', [ + 'event' => 'foo', + 'priority' => -5, + ]) + ->addTag('doctrine.event_listener', [ + 'event' => 'foo_bar', + 'priority' => 3, + ]) + ; + $container + ->register('g', 'stdClass') + ->addTag('doctrine.event_listener', [ + 'event' => 'foo', + ]) + ; + $container + ->register('h', 'stdClass') + ->addTag('doctrine.event_listener', [ + 'event' => 'foo_bar', + 'priority' => 4, + ]) + ; + + $this->process($container); + + $eventManagerDef = $container->getDefinition('doctrine.dbal.default_connection.event_manager'); + + $this->assertEquals( + [ + 'c', + 'd', + 'e', + 'b', + [['foo_bar'], 'h'], + [['foo_bar'], 'f'], + 'a', + [['bar'], 'f'], + [['foo'], 'g'], + [['foo'], 'f'], + ], + $eventManagerDef->getArgument(1) + ); + + $serviceLocatorDef = $container->getDefinition((string) $eventManagerDef->getArgument(0)); + $this->assertSame(ServiceLocator::class, $serviceLocatorDef->getClass()); + $this->assertEquals( + [ + 'a' => new ServiceClosureArgument(new Reference('a')), + 'b' => new ServiceClosureArgument(new Reference('b')), + 'c' => new ServiceClosureArgument(new Reference('c')), + 'd' => new ServiceClosureArgument(new Reference('d')), + 'e' => new ServiceClosureArgument(new Reference('e')), + 'f' => new ServiceClosureArgument(new Reference('f')), + 'g' => new ServiceClosureArgument(new Reference('g')), + 'h' => new ServiceClosureArgument(new Reference('h')), + ], + $serviceLocatorDef->getArgument(0) + ); + } + public function testProcessNoTaggedServices() { $container = $this->createBuilder(true); diff --git a/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php b/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php new file mode 100644 index 0000000000000..21962088b0fc8 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests; + +use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper as TestDoctrineTestHelper; + +/** + * Provides utility functions needed in tests. + * + * @author Bernhard Schussek + */ +final class DoctrineTestHelper extends TestDoctrineTestHelper +{ +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/ORMQueryBuilderLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/ORMQueryBuilderLoaderTest.php index 1ac6640d9950c..7d253dc59b85d 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/ORMQueryBuilderLoaderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/ChoiceList/ORMQueryBuilderLoaderTest.php @@ -17,7 +17,7 @@ use Doctrine\ORM\Version; use PHPUnit\Framework\TestCase; use Symfony\Bridge\Doctrine\Form\ChoiceList\ORMQueryBuilderLoader; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; use Symfony\Bridge\Doctrine\Types\UlidType; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Form\Exception\TransformationFailedException; diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypePerformanceTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypePerformanceTest.php index 42c0c5fb8c639..622282b9ac5b3 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypePerformanceTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypePerformanceTest.php @@ -14,7 +14,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdEntity; use Symfony\Component\Form\Extension\Core\CoreExtension; use Symfony\Component\Form\Test\FormPerformanceTestCase; diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php index bd6b2f156280d..8833d0ac45e87 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php @@ -20,7 +20,7 @@ use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension; use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; use Symfony\Bridge\Doctrine\Form\Type\EntityType; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; use Symfony\Bridge\Doctrine\Tests\Fixtures\CompositeIntIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\CompositeStringIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\GroupableEntity; diff --git a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UlidGeneratorTest.php b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UlidGeneratorTest.php index c4373554e2b6b..957ac0f60aeb0 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UlidGeneratorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UlidGeneratorTest.php @@ -14,7 +14,7 @@ use Doctrine\ORM\Mapping\Entity; use PHPUnit\Framework\TestCase; use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator; -use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\Factory\UlidFactory; use Symfony\Component\Uid\Ulid; class UlidGeneratorTest extends TestCase @@ -25,8 +25,23 @@ public function testUlidCanBeGenerated() $generator = new UlidGenerator(); $ulid = $generator->generate($em, new Entity()); - $this->assertInstanceOf(AbstractUid::class, $ulid); $this->assertInstanceOf(Ulid::class, $ulid); $this->assertTrue(Ulid::isValid($ulid)); } + + /** + * @requires function \Symfony\Component\Uid\Factory\UlidFactory::create + */ + public function testUlidFactory() + { + $ulid = new Ulid('00000000000000000000000000'); + $em = new EntityManager(); + $factory = $this->createMock(UlidFactory::class); + $factory->expects($this->any()) + ->method('create') + ->willReturn($ulid); + $generator = new UlidGenerator($factory); + + $this->assertSame($ulid, $generator->generate($em, new Entity())); + } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidGeneratorTest.php b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidGeneratorTest.php new file mode 100644 index 0000000000000..bfca276a811ba --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidGeneratorTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\IdGenerator; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\NilUuid; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Uid\UuidV6; + +/** + * @requires function \Symfony\Component\Uid\Factory\UuidFactory::create + */ +class UuidGeneratorTest extends TestCase +{ + public function testUuidCanBeGenerated() + { + $em = new EntityManager(); + $generator = new UuidGenerator(); + $uuid = $generator->generate($em, new Entity()); + + $this->assertInstanceOf(Uuid::class, $uuid); + } + + public function testCustomUuidfactory() + { + $uuid = new NilUuid(); + $em = new EntityManager(); + $factory = $this->createMock(UuidFactory::class); + $factory->expects($this->any()) + ->method('create') + ->willReturn($uuid); + $generator = new UuidGenerator($factory); + + $this->assertSame($uuid, $generator->generate($em, new Entity())); + } + + public function testUuidfactory() + { + $em = new EntityManager(); + $generator = new UuidGenerator(); + $this->assertInstanceOf(UuidV6::class, $generator->generate($em, new Entity())); + + $generator = $generator->randomBased(); + $this->assertInstanceOf(UuidV4::class, $generator->generate($em, new Entity())); + + $generator = $generator->timeBased(); + $this->assertInstanceOf(UuidV6::class, $generator->generate($em, new Entity())); + + $generator = $generator->nameBased('prop1', Uuid::NAMESPACE_OID); + $this->assertEquals(Uuid::v5(new Uuid(Uuid::NAMESPACE_OID), '3'), $generator->generate($em, new Entity())); + + $generator = $generator->nameBased('prop2', Uuid::NAMESPACE_OID); + $this->assertEquals(Uuid::v5(new Uuid(Uuid::NAMESPACE_OID), '2'), $generator->generate($em, new Entity())); + + $generator = $generator->nameBased('getProp4', Uuid::NAMESPACE_OID); + $this->assertEquals(Uuid::v5(new Uuid(Uuid::NAMESPACE_OID), '4'), $generator->generate($em, new Entity())); + + $factory = new UuidFactory(6, 6, 5, 5, null, Uuid::NAMESPACE_OID); + $generator = new UuidGenerator($factory); + $generator = $generator->nameBased('prop1'); + $this->assertEquals(Uuid::v5(new Uuid(Uuid::NAMESPACE_OID), '3'), $generator->generate($em, new Entity())); + } +} + +class Entity +{ + public $prop1 = 1; + public $prop2 = 2; + + public function prop1() + { + return 3; + } + + public function getProp4() + { + return 4; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV1GeneratorTest.php b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV1GeneratorTest.php deleted file mode 100644 index b9010afe417ef..0000000000000 --- a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV1GeneratorTest.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Doctrine\Tests\IdGenerator; - -use Doctrine\ORM\Mapping\Entity; -use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Doctrine\IdGenerator\UuidV1Generator; -use Symfony\Component\Uid\AbstractUid; -use Symfony\Component\Uid\UuidV1; - -class UuidV1GeneratorTest extends TestCase -{ - public function testUuidv1CanBeGenerated() - { - $em = new EntityManager(); - $generator = new UuidV1Generator(); - - $uuid = $generator->generate($em, new Entity()); - - $this->assertInstanceOf(AbstractUid::class, $uuid); - $this->assertInstanceOf(UuidV1::class, $uuid); - $this->assertTrue(UuidV1::isValid($uuid)); - } -} diff --git a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV4GeneratorTest.php b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV4GeneratorTest.php deleted file mode 100644 index cc87f74428d84..0000000000000 --- a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV4GeneratorTest.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Doctrine\Tests\IdGenerator; - -use Doctrine\ORM\Mapping\Entity; -use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Doctrine\IdGenerator\UuidV4Generator; -use Symfony\Component\Uid\AbstractUid; -use Symfony\Component\Uid\UuidV4; - -class UuidV4GeneratorTest extends TestCase -{ - public function testUuidv4CanBeGenerated() - { - $em = new EntityManager(); - $generator = new UuidV4Generator(); - - $uuid = $generator->generate($em, new Entity()); - - $this->assertInstanceOf(AbstractUid::class, $uuid); - $this->assertInstanceOf(UuidV4::class, $uuid); - $this->assertTrue(UuidV4::isValid($uuid)); - } -} diff --git a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV6GeneratorTest.php b/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV6GeneratorTest.php deleted file mode 100644 index f0697b272c981..0000000000000 --- a/src/Symfony/Bridge/Doctrine/Tests/IdGenerator/UuidV6GeneratorTest.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Doctrine\Tests\IdGenerator; - -use Doctrine\ORM\Mapping\Entity; -use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Doctrine\IdGenerator\UuidV6Generator; -use Symfony\Component\Uid\AbstractUid; -use Symfony\Component\Uid\UuidV6; - -class UuidV6GeneratorTest extends TestCase -{ - public function testUuidv6CanBeGenerated() - { - $em = new EntityManager(); - $generator = new UuidV6Generator(); - - $uuid = $generator->generate($em, new Entity()); - - $this->assertInstanceOf(AbstractUid::class, $uuid); - $this->assertInstanceOf(UuidV6::class, $uuid); - $this->assertTrue(UuidV6::isValid($uuid)); - } -} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php index 49914454569e3..4f4755037d2a0 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php @@ -19,7 +19,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Doctrine\Security\User\EntityUserProvider; use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; use Symfony\Bridge\Doctrine\Tests\Fixtures\User; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; diff --git a/src/Symfony/Bridge/Doctrine/Tests/TestRepositoryFactory.php b/src/Symfony/Bridge/Doctrine/Tests/TestRepositoryFactory.php new file mode 100644 index 0000000000000..4ed1b7fe157cc --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/TestRepositoryFactory.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests; + +use Symfony\Bridge\Doctrine\Test\TestRepositoryFactory as TestTestRepositoryFactory; + +/** + * @author Andreas Braun + */ +final class TestRepositoryFactory extends TestTestRepositoryFactory +{ +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php index 268dc29b6ece0..9d794b430cdbb 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php @@ -18,8 +18,7 @@ use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectRepository; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; -use Symfony\Bridge\Doctrine\Test\TestRepositoryFactory; +use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; use Symfony\Bridge\Doctrine\Tests\Fixtures\AssociationEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\AssociationEntity2; use Symfony\Bridge\Doctrine\Tests\Fixtures\CompositeIntIdEntity; @@ -34,6 +33,7 @@ use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleStringIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\Type\StringWrapper; use Symfony\Bridge\Doctrine\Tests\Fixtures\Type\StringWrapperType; +use Symfony\Bridge\Doctrine\Tests\TestRepositoryFactory; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php index 31129a8c615d0..1ba0b6f254874 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php @@ -12,7 +12,7 @@ namespace Symfony\Bridge\Doctrine\Tests\Validator; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; use Symfony\Bridge\Doctrine\Tests\Fixtures\BaseUser; use Symfony\Bridge\Doctrine\Tests\Fixtures\DoctrineLoaderEmbed; use Symfony\Bridge\Doctrine\Tests\Fixtures\DoctrineLoaderEntity; diff --git a/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php b/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php index bf3433e154be0..ce92930713a6f 100644 --- a/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php +++ b/src/Symfony/Bridge/Monolog/Command/ServerLogCommand.php @@ -34,6 +34,7 @@ class ServerLogCommand extends Command private $handler; protected static $defaultName = 'server:log'; + protected static $defaultDescription = 'Starts a log server that displays logs in real time'; public function isEnabled() { @@ -60,7 +61,7 @@ protected function configure() ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The line format', ConsoleFormatter::SIMPLE_FORMAT) ->addOption('date-format', null, InputOption::VALUE_REQUIRED, 'The date format', ConsoleFormatter::SIMPLE_DATE) ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'An expression to filter log. Example: "level > 200 or channel in [\'app\', \'doctrine\']"') - ->setDescription('Starts a log server that displays logs in real time') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' %command.name% starts a log server to display in real time the log messages generated by your application: diff --git a/src/Symfony/Bridge/PhpUnit/CHANGELOG.md b/src/Symfony/Bridge/PhpUnit/CHANGELOG.md index 2808ad0c50903..788d7eedacba6 100644 --- a/src/Symfony/Bridge/PhpUnit/CHANGELOG.md +++ b/src/Symfony/Bridge/PhpUnit/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +5.3 +--- + + * bumped the minimum PHP version to 7.1.3 + * bumped the minimum PHPUnit version to 7.5 + * deprecated the `SetUpTearDownTrait` trait, use original methods with "void" return typehint. + * added `logFile` option to write deprecations to a file instead of echoing them + 5.1.0 ----- diff --git a/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php b/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php index d61d7887be891..70fdb9f9631ad 100644 --- a/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php +++ b/src/Symfony/Bridge/PhpUnit/ClassExistsMock.php @@ -51,7 +51,7 @@ public static function trait_exists($name, $autoload = true) public static function register($class) { - $self = \get_called_class(); + $self = static::class; $mockedNs = [substr($class, 0, strrpos($class, '\\'))]; if (0 < strpos($class, '\\Tests\\')) { diff --git a/src/Symfony/Bridge/PhpUnit/ClockMock.php b/src/Symfony/Bridge/PhpUnit/ClockMock.php index 2cc834cd4f679..7280d44dc16f8 100644 --- a/src/Symfony/Bridge/PhpUnit/ClockMock.php +++ b/src/Symfony/Bridge/PhpUnit/ClockMock.php @@ -92,7 +92,7 @@ public static function gmdate($format, $timestamp = null) public static function register($class) { - $self = \get_called_class(); + $self = static::class; $mockedNs = [substr($class, 0, strrpos($class, '\\'))]; if (0 < strpos($class, '\\Tests\\')) { diff --git a/src/Symfony/Bridge/PhpUnit/ConstraintTrait.php b/src/Symfony/Bridge/PhpUnit/ConstraintTrait.php index 446dbf2f4fe03..478eee187c67e 100644 --- a/src/Symfony/Bridge/PhpUnit/ConstraintTrait.php +++ b/src/Symfony/Bridge/PhpUnit/ConstraintTrait.php @@ -15,12 +15,7 @@ use ReflectionClass; $r = new ReflectionClass(Constraint::class); -if (\PHP_VERSION_ID < 70000 || !$r->getMethod('matches')->hasReturnType()) { - trait ConstraintTrait - { - use Legacy\ConstraintTraitForV6; - } -} elseif ($r->getProperty('exporter')->isProtected()) { +if ($r->getProperty('exporter')->isProtected()) { trait ConstraintTrait { use Legacy\ConstraintTraitForV7; diff --git a/src/Symfony/Bridge/PhpUnit/CoverageListener.php b/src/Symfony/Bridge/PhpUnit/CoverageListener.php index 805f9222a50d9..766252b8728b7 100644 --- a/src/Symfony/Bridge/PhpUnit/CoverageListener.php +++ b/src/Symfony/Bridge/PhpUnit/CoverageListener.php @@ -11,16 +11,109 @@ namespace Symfony\Bridge\PhpUnit; -if (version_compare(\PHPUnit\Runner\Version::id(), '6.0.0', '<')) { - class_alias('Symfony\Bridge\PhpUnit\Legacy\CoverageListenerForV5', 'Symfony\Bridge\PhpUnit\CoverageListener'); -} elseif (version_compare(\PHPUnit\Runner\Version::id(), '7.0.0', '<')) { - class_alias('Symfony\Bridge\PhpUnit\Legacy\CoverageListenerForV6', 'Symfony\Bridge\PhpUnit\CoverageListener'); -} else { - class_alias('Symfony\Bridge\PhpUnit\Legacy\CoverageListenerForV7', 'Symfony\Bridge\PhpUnit\CoverageListener'); -} +use PHPUnit\Framework\Test; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestListener; +use PHPUnit\Framework\TestListenerDefaultImplementation; +use PHPUnit\Framework\Warning; +use PHPUnit\Util\Annotation\Registry; +use PHPUnit\Util\Test as TestUtil; + +class CoverageListener implements TestListener +{ + use TestListenerDefaultImplementation; + + private $sutFqcnResolver; + private $warningOnSutNotFound; + + public function __construct(callable $sutFqcnResolver = null, bool $warningOnSutNotFound = false) + { + $this->sutFqcnResolver = $sutFqcnResolver ?? static function (Test $test): ?string { + $class = \get_class($test); + + $sutFqcn = str_replace('\\Tests\\', '\\', $class); + $sutFqcn = preg_replace('{Test$}', '', $sutFqcn); + + return class_exists($sutFqcn) ? $sutFqcn : null; + }; + + $this->warningOnSutNotFound = $warningOnSutNotFound; + } + + public function startTest(Test $test): void + { + if (!$test instanceof TestCase) { + return; + } + + $annotations = TestUtil::parseTestMethodAnnotations(\get_class($test), $test->getName(false)); + + $ignoredAnnotations = ['covers', 'coversDefaultClass', 'coversNothing']; + + foreach ($ignoredAnnotations as $annotation) { + if (isset($annotations['class'][$annotation]) || isset($annotations['method'][$annotation])) { + return; + } + } + + $sutFqcn = ($this->sutFqcnResolver)($test); + if (!$sutFqcn) { + if ($this->warningOnSutNotFound) { + $test->getTestResultObject()->addWarning($test, new Warning('Could not find the tested class.'), 0); + } -if (false) { - class CoverageListener + return; + } + + $covers = $sutFqcn; + if (!\is_array($sutFqcn)) { + $covers = [$sutFqcn]; + while ($parent = get_parent_class($sutFqcn)) { + $covers[] = $parent; + $sutFqcn = $parent; + } + } + + if (class_exists(Registry::class)) { + $this->addCoversForDocBlockInsideRegistry($test, $covers); + + return; + } + + $this->addCoversForClassToAnnotationCache($test, $covers); + } + + private function addCoversForClassToAnnotationCache(Test $test, array $covers): void { + $r = new \ReflectionProperty(TestUtil::class, 'annotationCache'); + $r->setAccessible(true); + + $cache = $r->getValue(); + $cache = array_replace_recursive($cache, [ + \get_class($test) => [ + 'covers' => $covers, + ], + ]); + + $r->setValue(TestUtil::class, $cache); + } + + private function addCoversForDocBlockInsideRegistry(Test $test, array $covers): void + { + $docBlock = Registry::getInstance()->forClassName(\get_class($test)); + + $symbolAnnotations = new \ReflectionProperty($docBlock, 'symbolAnnotations'); + $symbolAnnotations->setAccessible(true); + + // Exclude internal classes; PHPUnit 9.1+ is picky about tests covering, say, a \RuntimeException + $covers = array_filter($covers, function (string $class) { + $reflector = new \ReflectionClass($class); + + return $reflector->isUserDefined(); + }); + + $symbolAnnotations->setValue($docBlock, array_replace($docBlock->symbolAnnotations(), [ + 'covers' => $covers, + ])); } } diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php index feabafb760bde..cfa2ddc124d4b 100644 --- a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php @@ -25,9 +25,9 @@ */ class DeprecationErrorHandler { - const MODE_DISABLED = 'disabled'; - const MODE_WEAK = 'max[total]=999999&verbose=0'; - const MODE_STRICT = 'max[total]=0'; + public const MODE_DISABLED = 'disabled'; + public const MODE_WEAK = 'max[total]=999999&verbose=0'; + public const MODE_STRICT = 'max[total]=0'; private $mode; private $configuration; @@ -242,13 +242,7 @@ private function getConfiguration() return $this->configuration; } if (false === $mode = $this->mode) { - if (isset($_SERVER['SYMFONY_DEPRECATIONS_HELPER'])) { - $mode = $_SERVER['SYMFONY_DEPRECATIONS_HELPER']; - } elseif (isset($_ENV['SYMFONY_DEPRECATIONS_HELPER'])) { - $mode = $_ENV['SYMFONY_DEPRECATIONS_HELPER']; - } else { - $mode = getenv('SYMFONY_DEPRECATIONS_HELPER'); - } + $mode = $_SERVER['SYMFONY_DEPRECATIONS_HELPER'] ?? $_ENV['SYMFONY_DEPRECATIONS_HELPER'] ?? getenv('SYMFONY_DEPRECATIONS_HELPER'); } if ('strict' === $mode) { return $this->configuration = Configuration::inStrictMode(); @@ -295,6 +289,8 @@ private static function colorize($str, $red) * @param string[] $groups * @param Configuration $configuration * @param bool $isFailing + * + * @throws \InvalidArgumentException */ private function displayDeprecations($groups, $configuration, $isFailing) { @@ -302,16 +298,26 @@ private function displayDeprecations($groups, $configuration, $isFailing) return $b->count() - $a->count(); }; + if ($configuration->shouldWriteToLogFile()) { + if (false === $handle = @fopen($file = $configuration->getLogFile(), 'a')) { + throw new \InvalidArgumentException(sprintf('The configured log file "%s" is not writeable.', $file)); + } + } else { + $handle = fopen('php://output', 'w'); + } + foreach ($groups as $group) { if ($this->deprecationGroups[$group]->count()) { - echo "\n", self::colorize( - sprintf( - '%s deprecation notices (%d)', - \in_array($group, ['direct', 'indirect', 'self'], true) ? "Remaining $group" : ucfirst($group), - $this->deprecationGroups[$group]->count() - ), - 'legacy' !== $group && 'indirect' !== $group - ), "\n"; + $deprecationGroupMessage = sprintf( + '%s deprecation notices (%d)', + \in_array($group, ['direct', 'indirect', 'self'], true) ? "Remaining $group" : ucfirst($group), + $this->deprecationGroups[$group]->count() + ); + if ($configuration->shouldWriteToLogFile()) { + fwrite($handle, "\n$deprecationGroupMessage\n"); + } else { + fwrite($handle, "\n".self::colorize($deprecationGroupMessage, 'legacy' !== $group && 'indirect' !== $group)."\n"); + } if ('legacy' !== $group && !$configuration->verboseOutput($group) && !$isFailing) { continue; @@ -320,14 +326,14 @@ private function displayDeprecations($groups, $configuration, $isFailing) uasort($notices, $cmp); foreach ($notices as $msg => $notice) { - echo "\n ", $notice->count(), 'x: ', $msg, "\n"; + fwrite($handle, sprintf("\n %sx: %s\n", $notice->count(), $msg)); $countsByCaller = $notice->getCountsByCaller(); arsort($countsByCaller); foreach ($countsByCaller as $method => $count) { if ('count' !== $method) { - echo ' ', $count, 'x in ', preg_replace('/(.*)\\\\(.*?::.*?)$/', '$2 from $1', $method), "\n"; + fwrite($handle, sprintf(" %dx in %s\n", $count, preg_replace('/(.*)\\\\(.*?::.*?)$/', '$2 from $1', $method))); } } } @@ -335,7 +341,7 @@ private function displayDeprecations($groups, $configuration, $isFailing) } if (!empty($notices)) { - echo "\n"; + fwrite($handle, "\n"); } } diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php index 20ffd9651b8ed..99248c508ccab 100644 --- a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Configuration.php @@ -52,13 +52,19 @@ class Configuration private $baselineDeprecations = []; /** - * @param int[] $thresholds A hash associating groups to thresholds - * @param string $regex Will be matched against messages, to decide whether to display a stack trace - * @param bool[] $verboseOutput Keyed by groups - * @param bool $generateBaseline Whether to generate or update the baseline file - * @param string $baselineFile The path to the baseline file + * @var string|null */ - private function __construct(array $thresholds = [], $regex = '', $verboseOutput = [], $generateBaseline = false, $baselineFile = '') + private $logFile = null; + + /** + * @param int[] $thresholds A hash associating groups to thresholds + * @param string $regex Will be matched against messages, to decide whether to display a stack trace + * @param bool[] $verboseOutput Keyed by groups + * @param bool $generateBaseline Whether to generate or update the baseline file + * @param string $baselineFile The path to the baseline file + * @param string|null $logFile The path to the log file + */ + private function __construct(array $thresholds = [], $regex = '', $verboseOutput = [], $generateBaseline = false, $baselineFile = '', $logFile = null) { $groups = ['total', 'indirect', 'direct', 'self']; @@ -119,6 +125,8 @@ private function __construct(array $thresholds = [], $regex = '', $verboseOutput throw new \InvalidArgumentException(sprintf('The baselineFile "%s" does not exist.', $this->baselineFile)); } } + + $this->logFile = $logFile; } /** @@ -238,6 +246,16 @@ public function verboseOutput($group) return $this->verboseOutput[$group]; } + public function shouldWriteToLogFile() + { + return null !== $this->logFile; + } + + public function getLogFile() + { + return $this->logFile; + } + /** * @param string $serializedConfiguration an encoded string, for instance * max[total]=1234&max[indirect]=42 @@ -248,7 +266,7 @@ public static function fromUrlEncodedString($serializedConfiguration) { parse_str($serializedConfiguration, $normalizedConfiguration); foreach (array_keys($normalizedConfiguration) as $key) { - if (!\in_array($key, ['max', 'disabled', 'verbose', 'quiet', 'generateBaseline', 'baselineFile'], true)) { + if (!\in_array($key, ['max', 'disabled', 'verbose', 'quiet', 'generateBaseline', 'baselineFile', 'logFile'], true)) { throw new \InvalidArgumentException(sprintf('Unknown configuration option "%s".', $key)); } } @@ -260,6 +278,7 @@ public static function fromUrlEncodedString($serializedConfiguration) 'quiet' => [], 'generateBaseline' => false, 'baselineFile' => '', + 'logFile' => null, ]; if ('' === $normalizedConfiguration['disabled'] || filter_var($normalizedConfiguration['disabled'], \FILTER_VALIDATE_BOOLEAN)) { @@ -278,11 +297,12 @@ public static function fromUrlEncodedString($serializedConfiguration) } return new self( - isset($normalizedConfiguration['max']) ? $normalizedConfiguration['max'] : [], + $normalizedConfiguration['max'] ?? [], '', $verboseOutput, filter_var($normalizedConfiguration['generateBaseline'], \FILTER_VALIDATE_BOOLEAN), - $normalizedConfiguration['baselineFile'] + $normalizedConfiguration['baselineFile'], + $normalizedConfiguration['logFile'] ); } diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php index 3e3ebef48c7c3..68e6b668bdb43 100644 --- a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/Deprecation.php @@ -21,14 +21,14 @@ */ class Deprecation { - const PATH_TYPE_VENDOR = 'path_type_vendor'; - const PATH_TYPE_SELF = 'path_type_internal'; - const PATH_TYPE_UNDETERMINED = 'path_type_undetermined'; + public const PATH_TYPE_VENDOR = 'path_type_vendor'; + public const PATH_TYPE_SELF = 'path_type_internal'; + public const PATH_TYPE_UNDETERMINED = 'path_type_undetermined'; - const TYPE_SELF = 'type_self'; - const TYPE_DIRECT = 'type_direct'; - const TYPE_INDIRECT = 'type_indirect'; - const TYPE_UNDETERMINED = 'type_undetermined'; + public const TYPE_SELF = 'type_self'; + public const TYPE_DIRECT = 'type_direct'; + public const TYPE_INDIRECT = 'type_indirect'; + public const TYPE_UNDETERMINED = 'type_undetermined'; private $trace = []; private $message; @@ -135,7 +135,7 @@ private function lineShouldBeSkipped(array $line) } $class = $line['class']; - return 'ReflectionMethod' === $class || 0 === strpos($class, 'PHPUnit_') || 0 === strpos($class, 'PHPUnit\\'); + return 'ReflectionMethod' === $class || 0 === strpos($class, 'PHPUnit\\'); } /** @@ -311,7 +311,7 @@ private static function getVendors() foreach (get_declared_classes() as $class) { if ('C' === $class[0] && 0 === strpos($class, 'ComposerAutoloaderInit')) { $r = new \ReflectionClass($class); - $v = \dirname(\dirname($r->getFileName())); + $v = \dirname($r->getFileName(), 2); if (file_exists($v.'/composer/installed.json')) { self::$vendors[] = $v; $loader = require $v.'/autoload.php'; diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/DeprecationGroup.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/DeprecationGroup.php index f2b0323135dd4..6ad2b84ea3fd6 100644 --- a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/DeprecationGroup.php +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler/DeprecationGroup.php @@ -55,11 +55,7 @@ public function addNotice() */ private function deprecationNotice($message) { - if (!isset($this->deprecationNotices[$message])) { - $this->deprecationNotices[$message] = new DeprecationNotice(); - } - - return $this->deprecationNotices[$message]; + return $this->deprecationNotices[$message] ?? $this->deprecationNotices[$message] = new DeprecationNotice(); } public function count() diff --git a/src/Symfony/Bridge/PhpUnit/DnsMock.php b/src/Symfony/Bridge/PhpUnit/DnsMock.php index 1e2f55b371be3..642da0a6dfcde 100644 --- a/src/Symfony/Bridge/PhpUnit/DnsMock.php +++ b/src/Symfony/Bridge/PhpUnit/DnsMock.php @@ -152,7 +152,7 @@ public static function dns_get_record($hostname, $type = \DNS_ANY, &$authns = nu $records = []; foreach (self::$hosts[$hostname] as $record) { - if (isset(self::$dnsTypes[$record['type']]) && (self::$dnsTypes[$record['type']] & $type)) { + if ((self::$dnsTypes[$record['type']] ?? 0) & $type) { $records[] = array_merge(['host' => $hostname, 'class' => 'IN', 'ttl' => 1, 'type' => $record['type']], $record); } } @@ -163,7 +163,7 @@ public static function dns_get_record($hostname, $type = \DNS_ANY, &$authns = nu public static function register($class) { - $self = \get_called_class(); + $self = static::class; $mockedNs = [substr($class, 0, strrpos($class, '\\'))]; if (0 < strpos($class, '\\Tests\\')) { diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php deleted file mode 100644 index 2ce390df38609..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php +++ /dev/null @@ -1,57 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -/** - * {@inheritdoc} - * - * @internal - */ -class CommandForV5 extends \PHPUnit_TextUI_Command -{ - /** - * {@inheritdoc} - */ - protected function createRunner() - { - $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : []; - - $registeredLocally = false; - - foreach ($this->arguments['listeners'] as $registeredListener) { - if ($registeredListener instanceof SymfonyTestsListenerForV5) { - $registeredListener->globalListenerDisabled(); - $registeredLocally = true; - break; - } - } - - if (isset($this->arguments['configuration'])) { - $configuration = $this->arguments['configuration']; - if (!$configuration instanceof \PHPUnit_Util_Configuration) { - $configuration = \PHPUnit_Util_Configuration::getInstance($this->arguments['configuration']); - } - foreach ($configuration->getListenerConfiguration() as $registeredListener) { - if ('Symfony\Bridge\PhpUnit\SymfonyTestsListener' === ltrim($registeredListener['class'], '\\')) { - $registeredLocally = true; - break; - } - } - } - - if (!$registeredLocally) { - $this->arguments['listeners'][] = new SymfonyTestsListenerForV5(); - } - - return parent::createRunner(); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV7.php similarity index 91% rename from src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php rename to src/Symfony/Bridge/PhpUnit/Legacy/CommandForV7.php index 93e1ad975b7e4..fcf5c4505d3da 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV7.php @@ -21,14 +21,14 @@ * * @internal */ -class CommandForV6 extends BaseCommand +class CommandForV7 extends BaseCommand { /** * {@inheritdoc} */ protected function createRunner(): BaseRunner { - $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : []; + $this->arguments['listeners'] ?? $this->arguments['listeners'] = []; $registeredLocally = false; diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV9.php b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV9.php index 2511380257fd8..351f02f2230ec 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV9.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV9.php @@ -31,7 +31,7 @@ class CommandForV9 extends BaseCommand */ protected function createRunner(): BaseRunner { - $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : []; + $this->arguments['listeners'] ?? $this->arguments['listeners'] = []; $registeredLocally = false; diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/ConstraintTraitForV6.php b/src/Symfony/Bridge/PhpUnit/Legacy/ConstraintTraitForV6.php deleted file mode 100644 index 53819e4b3c4d7..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/ConstraintTraitForV6.php +++ /dev/null @@ -1,130 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -use SebastianBergmann\Exporter\Exporter; - -/** - * @internal - */ -trait ConstraintTraitForV6 -{ - /** - * @return bool|null - */ - public function evaluate($other, $description = '', $returnResult = false) - { - return $this->doEvaluate($other, $description, $returnResult); - } - - /** - * @return int - */ - public function count() - { - return $this->doCount(); - } - - /** - * @return string - */ - public function toString() - { - return $this->doToString(); - } - - /** - * @param mixed $other - * - * @return string - */ - protected function additionalFailureDescription($other) - { - return $this->doAdditionalFailureDescription($other); - } - - /** - * @return Exporter - */ - protected function exporter() - { - if (null === $this->exporter) { - $this->exporter = new Exporter(); - } - - return $this->exporter; - } - - /** - * @param mixed $other - * - * @return string - */ - protected function failureDescription($other) - { - return $this->doFailureDescription($other); - } - - /** - * @param mixed $other - * - * @return bool - */ - protected function matches($other) - { - return $this->doMatches($other); - } - - private function doAdditionalFailureDescription($other) - { - return ''; - } - - private function doCount() - { - return 1; - } - - private function doEvaluate($other, $description, $returnResult) - { - $success = false; - - if ($this->matches($other)) { - $success = true; - } - - if ($returnResult) { - return $success; - } - - if (!$success) { - $this->fail($other, $description); - } - - return null; - } - - private function doFailureDescription($other) - { - return $this->exporter()->export($other).' '.$this->toString(); - } - - private function doMatches($other) - { - return false; - } - - private function doToString() - { - return ''; - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV5.php b/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV5.php deleted file mode 100644 index 9d754eebc85df..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV5.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -/** - * CoverageListener adds `@covers ` on each test when possible to - * make the code coverage more accurate. - * - * @author Grégoire Pineau - * - * @internal - */ -class CoverageListenerForV5 extends \PHPUnit_Framework_BaseTestListener -{ - private $trait; - - public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false) - { - $this->trait = new CoverageListenerTrait($sutFqcnResolver, $warningOnSutNotFound); - } - - public function startTest(\PHPUnit_Framework_Test $test) - { - $this->trait->startTest($test); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV6.php b/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV6.php deleted file mode 100644 index 1b3ceec161f8a..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV6.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -use PHPUnit\Framework\Test; -use PHPUnit\Framework\TestListener; -use PHPUnit\Framework\TestListenerDefaultImplementation; - -/** - * CoverageListener adds `@covers ` on each test when possible to - * make the code coverage more accurate. - * - * @author Grégoire Pineau - * - * @internal - */ -class CoverageListenerForV6 implements TestListener -{ - use TestListenerDefaultImplementation; - - private $trait; - - public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false) - { - $this->trait = new CoverageListenerTrait($sutFqcnResolver, $warningOnSutNotFound); - } - - public function startTest(Test $test) - { - $this->trait->startTest($test); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV7.php b/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV7.php deleted file mode 100644 index a35034c48b32b..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerForV7.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -use PHPUnit\Framework\Test; -use PHPUnit\Framework\TestListener; -use PHPUnit\Framework\TestListenerDefaultImplementation; - -/** - * CoverageListener adds `@covers ` on each test when possible to - * make the code coverage more accurate. - * - * @author Grégoire Pineau - * - * @internal - */ -class CoverageListenerForV7 implements TestListener -{ - use TestListenerDefaultImplementation; - - private $trait; - - public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false) - { - $this->trait = new CoverageListenerTrait($sutFqcnResolver, $warningOnSutNotFound); - } - - public function startTest(Test $test): void - { - $this->trait->startTest($test); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerTrait.php b/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerTrait.php deleted file mode 100644 index 4ca396ece164b..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CoverageListenerTrait.php +++ /dev/null @@ -1,160 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\Warning; -use PHPUnit\Util\Annotation\Registry; -use PHPUnit\Util\Test; - -/** - * PHP 5.3 compatible trait-like shared implementation. - * - * @author Grégoire Pineau - * - * @internal - */ -class CoverageListenerTrait -{ - private $sutFqcnResolver; - private $warningOnSutNotFound; - private $warnings; - - public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false) - { - $this->sutFqcnResolver = $sutFqcnResolver; - $this->warningOnSutNotFound = $warningOnSutNotFound; - $this->warnings = []; - } - - public function startTest($test) - { - if (!$test instanceof TestCase) { - return; - } - - $annotations = Test::parseTestMethodAnnotations(\get_class($test), $test->getName(false)); - - $ignoredAnnotations = ['covers', 'coversDefaultClass', 'coversNothing']; - - foreach ($ignoredAnnotations as $annotation) { - if (isset($annotations['class'][$annotation]) || isset($annotations['method'][$annotation])) { - return; - } - } - - $sutFqcn = $this->findSutFqcn($test); - if (!$sutFqcn) { - if ($this->warningOnSutNotFound) { - $message = 'Could not find the tested class.'; - // addWarning does not exist on old PHPUnit version - if (method_exists($test->getTestResultObject(), 'addWarning') && class_exists(Warning::class)) { - $test->getTestResultObject()->addWarning($test, new Warning($message), 0); - } else { - $this->warnings[] = sprintf("%s::%s\n%s", \get_class($test), $test->getName(), $message); - } - } - - return; - } - - $covers = $sutFqcn; - if (!\is_array($sutFqcn)) { - $covers = [$sutFqcn]; - while ($parent = get_parent_class($sutFqcn)) { - $covers[] = $parent; - $sutFqcn = $parent; - } - } - - if (class_exists(Registry::class)) { - $this->addCoversForDocBlockInsideRegistry($test, $covers); - - return; - } - - $this->addCoversForClassToAnnotationCache($test, $covers); - } - - private function addCoversForClassToAnnotationCache($test, $covers) - { - $r = new \ReflectionProperty(Test::class, 'annotationCache'); - $r->setAccessible(true); - - $cache = $r->getValue(); - $cache = array_replace_recursive($cache, [ - \get_class($test) => [ - 'covers' => $covers, - ], - ]); - - $r->setValue(Test::class, $cache); - } - - private function addCoversForDocBlockInsideRegistry($test, $covers) - { - $docBlock = Registry::getInstance()->forClassName(\get_class($test)); - - $symbolAnnotations = new \ReflectionProperty($docBlock, 'symbolAnnotations'); - $symbolAnnotations->setAccessible(true); - - // Exclude internal classes; PHPUnit 9.1+ is picky about tests covering, say, a \RuntimeException - $covers = array_filter($covers, function ($class) { - $reflector = new \ReflectionClass($class); - - return $reflector->isUserDefined(); - }); - - $symbolAnnotations->setValue($docBlock, array_replace($docBlock->symbolAnnotations(), [ - 'covers' => $covers, - ])); - } - - private function findSutFqcn($test) - { - if ($this->sutFqcnResolver) { - $resolver = $this->sutFqcnResolver; - - return $resolver($test); - } - - $class = \get_class($test); - - $sutFqcn = str_replace('\\Tests\\', '\\', $class); - $sutFqcn = preg_replace('{Test$}', '', $sutFqcn); - - return class_exists($sutFqcn) ? $sutFqcn : null; - } - - public function __sleep() - { - throw new \BadMethodCallException('Cannot serialize '.__CLASS__); - } - - public function __wakeup() - { - throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); - } - - public function __destruct() - { - if (!$this->warnings) { - return; - } - - echo "\n"; - - foreach ($this->warnings as $key => $warning) { - echo sprintf("%d) %s\n", ++$key, $warning); - } - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillAssertTrait.php b/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillAssertTrait.php index 5a66282d855ca..7424b7226ea14 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillAssertTrait.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillAssertTrait.php @@ -11,9 +11,7 @@ namespace Symfony\Bridge\PhpUnit\Legacy; -use PHPUnit\Framework\Constraint\IsEqual; use PHPUnit\Framework\Constraint\LogicalNot; -use PHPUnit\Framework\Constraint\StringContains; use PHPUnit\Framework\Constraint\TraversableContains; /** @@ -21,18 +19,6 @@ */ trait PolyfillAssertTrait { - /** - * @param float $delta - * @param string $message - * - * @return void - */ - public static function assertEqualsWithDelta($expected, $actual, $delta, $message = '') - { - $constraint = new IsEqual($expected, $delta); - static::assertThat($actual, $constraint, $message); - } - /** * @param iterable $haystack * @param string $message @@ -57,225 +43,6 @@ public static function assertNotContainsEquals($needle, $haystack, $message = '' static::assertThat($haystack, $constraint, $message); } - /** - * @param string $message - * - * @return void - */ - public static function assertIsArray($actual, $message = '') - { - static::assertInternalType('array', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsBool($actual, $message = '') - { - static::assertInternalType('bool', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsFloat($actual, $message = '') - { - static::assertInternalType('float', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsInt($actual, $message = '') - { - static::assertInternalType('int', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsNumeric($actual, $message = '') - { - static::assertInternalType('numeric', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsObject($actual, $message = '') - { - static::assertInternalType('object', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsResource($actual, $message = '') - { - static::assertInternalType('resource', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsString($actual, $message = '') - { - static::assertInternalType('string', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsScalar($actual, $message = '') - { - static::assertInternalType('scalar', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsCallable($actual, $message = '') - { - static::assertInternalType('callable', $actual, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertIsIterable($actual, $message = '') - { - static::assertInternalType('iterable', $actual, $message); - } - - /** - * @param string $needle - * @param string $haystack - * @param string $message - * - * @return void - */ - public static function assertStringContainsString($needle, $haystack, $message = '') - { - $constraint = new StringContains($needle, false); - static::assertThat($haystack, $constraint, $message); - } - - /** - * @param string $needle - * @param string $haystack - * @param string $message - * - * @return void - */ - public static function assertStringContainsStringIgnoringCase($needle, $haystack, $message = '') - { - $constraint = new StringContains($needle, true); - static::assertThat($haystack, $constraint, $message); - } - - /** - * @param string $needle - * @param string $haystack - * @param string $message - * - * @return void - */ - public static function assertStringNotContainsString($needle, $haystack, $message = '') - { - $constraint = new LogicalNot(new StringContains($needle, false)); - static::assertThat($haystack, $constraint, $message); - } - - /** - * @param string $needle - * @param string $haystack - * @param string $message - * - * @return void - */ - public static function assertStringNotContainsStringIgnoringCase($needle, $haystack, $message = '') - { - $constraint = new LogicalNot(new StringContains($needle, true)); - static::assertThat($haystack, $constraint, $message); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertFinite($actual, $message = '') - { - static::assertInternalType('float', $actual, $message); - static::assertTrue(is_finite($actual), $message ?: "Failed asserting that $actual is finite."); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertInfinite($actual, $message = '') - { - static::assertInternalType('float', $actual, $message); - static::assertTrue(is_infinite($actual), $message ?: "Failed asserting that $actual is infinite."); - } - - /** - * @param string $message - * - * @return void - */ - public static function assertNan($actual, $message = '') - { - static::assertInternalType('float', $actual, $message); - static::assertTrue(is_nan($actual), $message ?: "Failed asserting that $actual is nan."); - } - - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertIsReadable($filename, $message = '') - { - static::assertInternalType('string', $filename, $message); - static::assertTrue(is_readable($filename), $message ?: "Failed asserting that $filename is readable."); - } - - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertNotIsReadable($filename, $message = '') - { - static::assertInternalType('string', $filename, $message); - static::assertFalse(is_readable($filename), $message ?: "Failed asserting that $filename is not readable."); - } - /** * @param string $filename * @param string $message @@ -287,30 +54,6 @@ public static function assertIsNotReadable($filename, $message = '') static::assertNotIsReadable($filename, $message); } - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertIsWritable($filename, $message = '') - { - static::assertInternalType('string', $filename, $message); - static::assertTrue(is_writable($filename), $message ?: "Failed asserting that $filename is writable."); - } - - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertNotIsWritable($filename, $message = '') - { - static::assertInternalType('string', $filename, $message); - static::assertFalse(is_writable($filename), $message ?: "Failed asserting that $filename is not writable."); - } - /** * @param string $filename * @param string $message @@ -322,30 +65,6 @@ public static function assertIsNotWritable($filename, $message = '') static::assertNotIsWritable($filename, $message); } - /** - * @param string $directory - * @param string $message - * - * @return void - */ - public static function assertDirectoryExists($directory, $message = '') - { - static::assertInternalType('string', $directory, $message); - static::assertTrue(is_dir($directory), $message ?: "Failed asserting that $directory exists."); - } - - /** - * @param string $directory - * @param string $message - * - * @return void - */ - public static function assertDirectoryNotExists($directory, $message = '') - { - static::assertInternalType('string', $directory, $message); - static::assertFalse(is_dir($directory), $message ?: "Failed asserting that $directory does not exist."); - } - /** * @param string $directory * @param string $message @@ -357,30 +76,6 @@ public static function assertDirectoryDoesNotExist($directory, $message = '') static::assertDirectoryNotExists($directory, $message); } - /** - * @param string $directory - * @param string $message - * - * @return void - */ - public static function assertDirectoryIsReadable($directory, $message = '') - { - static::assertDirectoryExists($directory, $message); - static::assertIsReadable($directory, $message); - } - - /** - * @param string $directory - * @param string $message - * - * @return void - */ - public static function assertDirectoryNotIsReadable($directory, $message = '') - { - static::assertDirectoryExists($directory, $message); - static::assertNotIsReadable($directory, $message); - } - /** * @param string $directory * @param string $message @@ -392,30 +87,6 @@ public static function assertDirectoryIsNotReadable($directory, $message = '') static::assertDirectoryNotIsReadable($directory, $message); } - /** - * @param string $directory - * @param string $message - * - * @return void - */ - public static function assertDirectoryIsWritable($directory, $message = '') - { - static::assertDirectoryExists($directory, $message); - static::assertIsWritable($directory, $message); - } - - /** - * @param string $directory - * @param string $message - * - * @return void - */ - public static function assertDirectoryNotIsWritable($directory, $message = '') - { - static::assertDirectoryExists($directory, $message); - static::assertNotIsWritable($directory, $message); - } - /** * @param string $directory * @param string $message @@ -427,30 +98,6 @@ public static function assertDirectoryIsNotWritable($directory, $message = '') static::assertDirectoryNotIsWritable($directory, $message); } - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertFileExists($filename, $message = '') - { - static::assertInternalType('string', $filename, $message); - static::assertTrue(file_exists($filename), $message ?: "Failed asserting that $filename exists."); - } - - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertFileNotExists($filename, $message = '') - { - static::assertInternalType('string', $filename, $message); - static::assertFalse(file_exists($filename), $message ?: "Failed asserting that $filename does not exist."); - } - /** * @param string $filename * @param string $message @@ -462,30 +109,6 @@ public static function assertFileDoesNotExist($filename, $message = '') static::assertFileNotExists($filename, $message); } - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertFileIsReadable($filename, $message = '') - { - static::assertFileExists($filename, $message); - static::assertIsReadable($filename, $message); - } - - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertFileNotIsReadable($filename, $message = '') - { - static::assertFileExists($filename, $message); - static::assertNotIsReadable($filename, $message); - } - /** * @param string $filename * @param string $message @@ -497,30 +120,6 @@ public static function assertFileIsNotReadable($filename, $message = '') static::assertFileNotIsReadable($filename, $message); } - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertFileIsWritable($filename, $message = '') - { - static::assertFileExists($filename, $message); - static::assertIsWritable($filename, $message); - } - - /** - * @param string $filename - * @param string $message - * - * @return void - */ - public static function assertFileNotIsWritable($filename, $message = '') - { - static::assertFileExists($filename, $message); - static::assertNotIsWritable($filename, $message); - } - /** * @param string $filename * @param string $message diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillTestCaseTrait.php b/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillTestCaseTrait.php index ad2150436833d..8673bdc0a1d2b 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillTestCaseTrait.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/PolyfillTestCaseTrait.php @@ -14,88 +14,12 @@ use PHPUnit\Framework\Error\Error; use PHPUnit\Framework\Error\Notice; use PHPUnit\Framework\Error\Warning; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; /** * This trait is @internal. */ trait PolyfillTestCaseTrait { - /** - * @param string|string[] $originalClassName - * - * @return MockObject - */ - protected function createMock($originalClassName) - { - $mock = $this->getMockBuilder($originalClassName) - ->disableOriginalConstructor() - ->disableOriginalClone() - ->disableArgumentCloning(); - - if (method_exists($mock, 'disallowMockingUnknownTypes')) { - $mock = $mock->disallowMockingUnknownTypes(); - } - - return $mock->getMock(); - } - - /** - * @param string|string[] $originalClassName - * @param string[] $methods - * - * @return MockObject - */ - protected function createPartialMock($originalClassName, array $methods) - { - $mock = $this->getMockBuilder($originalClassName) - ->disableOriginalConstructor() - ->disableOriginalClone() - ->disableArgumentCloning() - ->setMethods(empty($methods) ? null : $methods); - - if (method_exists($mock, 'disallowMockingUnknownTypes')) { - $mock = $mock->disallowMockingUnknownTypes(); - } - - return $mock->getMock(); - } - - /** - * @param string $exception - * - * @return void - */ - public function expectException($exception) - { - $this->doExpectException($exception); - } - - /** - * @param int|string $code - * - * @return void - */ - public function expectExceptionCode($code) - { - $property = new \ReflectionProperty(TestCase::class, 'expectedExceptionCode'); - $property->setAccessible(true); - $property->setValue($this, $code); - } - - /** - * @param string $message - * - * @return void - */ - public function expectExceptionMessage($message) - { - $property = new \ReflectionProperty(TestCase::class, 'expectedExceptionMessage'); - $property->setAccessible(true); - $property->setValue($this, $message); - } - /** * @param string $messageRegExp * @@ -106,24 +30,12 @@ public function expectExceptionMessageMatches($messageRegExp) $this->expectExceptionMessageRegExp($messageRegExp); } - /** - * @param string $messageRegExp - * - * @return void - */ - public function expectExceptionMessageRegExp($messageRegExp) - { - $property = new \ReflectionProperty(TestCase::class, 'expectedExceptionMessageRegExp'); - $property->setAccessible(true); - $property->setValue($this, $messageRegExp); - } - /** * @return void */ public function expectNotice() { - $this->doExpectException(Notice::class); + $this->expectException(Notice::class); } /** @@ -151,7 +63,7 @@ public function expectNoticeMessageMatches($regularExpression) */ public function expectWarning() { - $this->doExpectException(Warning::class); + $this->expectException(Warning::class); } /** @@ -179,7 +91,7 @@ public function expectWarningMessageMatches($regularExpression) */ public function expectError() { - $this->doExpectException(Error::class); + $this->expectException(Error::class); } /** @@ -201,11 +113,4 @@ public function expectErrorMessageMatches($regularExpression) { $this->expectExceptionMessageMatches($regularExpression); } - - private function doExpectException($exception) - { - $property = new \ReflectionProperty(TestCase::class, 'expectedException'); - $property->setAccessible(true); - $property->setValue($this, $exception); - } } diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/SetUpTearDownTraitForV5.php b/src/Symfony/Bridge/PhpUnit/Legacy/SetUpTearDownTraitForV7.php similarity index 97% rename from src/Symfony/Bridge/PhpUnit/Legacy/SetUpTearDownTraitForV5.php rename to src/Symfony/Bridge/PhpUnit/Legacy/SetUpTearDownTraitForV7.php index ca29c2ae49ab8..599ffcad9f19f 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/SetUpTearDownTraitForV5.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/SetUpTearDownTraitForV7.php @@ -14,7 +14,7 @@ /** * @internal */ -trait SetUpTearDownTraitForV5 +trait SetUpTearDownTraitForV7 { /** * @return void diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerForV5.php b/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerForV5.php deleted file mode 100644 index 9b646dca8dfab..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerForV5.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -/** - * Collects and replays skipped tests. - * - * @author Nicolas Grekas - * - * @internal - */ -class SymfonyTestsListenerForV5 extends \PHPUnit_Framework_BaseTestListener -{ - private $trait; - - public function __construct(array $mockedNamespaces = []) - { - $this->trait = new SymfonyTestsListenerTrait($mockedNamespaces); - } - - public function globalListenerDisabled() - { - $this->trait->globalListenerDisabled(); - } - - public function startTestSuite(\PHPUnit_Framework_TestSuite $suite) - { - $this->trait->startTestSuite($suite); - } - - public function addSkippedTest(\PHPUnit_Framework_Test $test, \Exception $e, $time) - { - $this->trait->addSkippedTest($test, $e, $time); - } - - public function startTest(\PHPUnit_Framework_Test $test) - { - $this->trait->startTest($test); - } - - public function endTest(\PHPUnit_Framework_Test $test, $time) - { - $this->trait->endTest($test, $time); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerForV6.php b/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerForV6.php deleted file mode 100644 index 8f2f6b5a7ed54..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerForV6.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Legacy; - -use PHPUnit\Framework\BaseTestListener; -use PHPUnit\Framework\Test; -use PHPUnit\Framework\TestSuite; - -/** - * Collects and replays skipped tests. - * - * @author Nicolas Grekas - * - * @internal - */ -class SymfonyTestsListenerForV6 extends BaseTestListener -{ - private $trait; - - public function __construct(array $mockedNamespaces = []) - { - $this->trait = new SymfonyTestsListenerTrait($mockedNamespaces); - } - - public function globalListenerDisabled() - { - $this->trait->globalListenerDisabled(); - } - - public function startTestSuite(TestSuite $suite) - { - $this->trait->startTestSuite($suite); - } - - public function addSkippedTest(Test $test, \Exception $e, $time) - { - $this->trait->addSkippedTest($test, $e, $time); - } - - public function startTest(Test $test) - { - $this->trait->startTest($test); - } - - public function endTest(Test $test, $time) - { - $this->trait->endTest($test, $time); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php b/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php index 44723f06ec937..ca3757986682a 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php @@ -123,7 +123,7 @@ public function startTestSuite($suite) $suiteName = $suite->getName(); foreach ($suite->tests() as $test) { - if (!($test instanceof \PHPUnit_Framework_TestCase || $test instanceof TestCase)) { + if (!$test instanceof TestCase) { continue; } if (null === Test::getPreserveGlobalStateSettings(\get_class($test), $test->getName(false))) { @@ -158,7 +158,7 @@ public function startTestSuite($suite) $testSuites = [$suite]; for ($i = 0; isset($testSuites[$i]); ++$i) { foreach ($testSuites[$i]->tests() as $test) { - if ($test instanceof \PHPUnit_Framework_TestSuite || $test instanceof TestSuite) { + if ($test instanceof TestSuite) { if (!class_exists($test->getName(), false)) { $testSuites[] = $test; continue; @@ -178,11 +178,11 @@ public function startTestSuite($suite) $skipped = []; while ($s = array_shift($suites)) { foreach ($s->tests() as $test) { - if ($test instanceof \PHPUnit_Framework_TestSuite || $test instanceof TestSuite) { + if ($test instanceof TestSuite) { $suites[] = $test; continue; } - if (($test instanceof \PHPUnit_Framework_TestCase || $test instanceof TestCase) + if ($test instanceof TestCase && isset($this->wasSkipped[\get_class($test)][$test->getName()]) ) { $skipped[] = $test; @@ -202,7 +202,7 @@ public function addSkippedTest($test, \Exception $e, $time) public function startTest($test) { - if (-2 < $this->state && ($test instanceof \PHPUnit_Framework_TestCase || $test instanceof TestCase)) { + if (-2 < $this->state && $test instanceof TestCase) { // This event is triggered before the test is re-run in isolation if ($this->willBeIsolated($test)) { $this->runsInSeparateProcess = tempnam(sys_get_temp_dir(), 'deprec'); @@ -280,7 +280,7 @@ public function endTest($test, $time) unlink($this->runsInSeparateProcess); putenv('SYMFONY_DEPRECATIONS_SERIALIZE'); foreach ($deprecations ? unserialize($deprecations) : [] as $deprecation) { - $error = serialize(['deprecation' => $deprecation[1], 'class' => $className, 'method' => $test->getName(false), 'triggering_file' => isset($deprecation[2]) ? $deprecation[2] : null, 'files_stack' => isset($deprecation[3]) ? $deprecation[3] : []]); + $error = serialize(['deprecation' => $deprecation[1], 'class' => $className, 'method' => $test->getName(false), 'triggering_file' => $deprecation[2] ?? null, 'files_stack' => $deprecation[3] ?? []]); if ($deprecation[0]) { // unsilenced on purpose trigger_error($error, \E_USER_DEPRECATED); @@ -312,7 +312,7 @@ public function endTest($test, $time) self::$expectedDeprecations = self::$gatheredDeprecations = []; self::$previousErrorHandler = null; } - if (!$this->runsInSeparateProcess && -2 < $this->state && ($test instanceof \PHPUnit_Framework_TestCase || $test instanceof TestCase)) { + if (!$this->runsInSeparateProcess && -2 < $this->state && $test instanceof TestCase) { if (\in_array('time-sensitive', $groups, true)) { ClockMock::withClockMock(false); } diff --git a/src/Symfony/Bridge/PhpUnit/SetUpTearDownTrait.php b/src/Symfony/Bridge/PhpUnit/SetUpTearDownTrait.php index e27c3a4fb0934..04eee45be13a3 100644 --- a/src/Symfony/Bridge/PhpUnit/SetUpTearDownTrait.php +++ b/src/Symfony/Bridge/PhpUnit/SetUpTearDownTrait.php @@ -13,12 +13,14 @@ use PHPUnit\Framework\TestCase; +trigger_deprecation('symfony/phpunit-bridge', '5.3', 'The "%s" trait is deprecated, use original methods with "void" return typehint.', SetUpTearDownTrait::class); + // A trait to provide forward compatibility with newest PHPUnit versions $r = new \ReflectionClass(TestCase::class); -if (\PHP_VERSION_ID < 70000 || !$r->getMethod('setUp')->hasReturnType()) { +if (!$r->getMethod('setUp')->hasReturnType()) { trait SetUpTearDownTrait { - use Legacy\SetUpTearDownTraitForV5; + use Legacy\SetUpTearDownTraitForV7; } } else { trait SetUpTearDownTrait diff --git a/src/Symfony/Bridge/PhpUnit/SymfonyTestsListener.php b/src/Symfony/Bridge/PhpUnit/SymfonyTestsListener.php index d3cd7563bd41f..47f0f42afc8fd 100644 --- a/src/Symfony/Bridge/PhpUnit/SymfonyTestsListener.php +++ b/src/Symfony/Bridge/PhpUnit/SymfonyTestsListener.php @@ -11,13 +11,7 @@ namespace Symfony\Bridge\PhpUnit; -if (version_compare(\PHPUnit\Runner\Version::id(), '6.0.0', '<')) { - class_alias('Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV5', 'Symfony\Bridge\PhpUnit\SymfonyTestsListener'); -} elseif (version_compare(\PHPUnit\Runner\Version::id(), '7.0.0', '<')) { - class_alias('Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV6', 'Symfony\Bridge\PhpUnit\SymfonyTestsListener'); -} else { - class_alias('Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV7', 'Symfony\Bridge\PhpUnit\SymfonyTestsListener'); -} +class_alias('Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV7', 'Symfony\Bridge\PhpUnit\SymfonyTestsListener'); if (false) { class SymfonyTestsListener diff --git a/src/Symfony/Bridge/PhpUnit/Tests/BootstrapTest.php b/src/Symfony/Bridge/PhpUnit/Tests/BootstrapTest.php deleted file mode 100644 index d1811575087df..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Tests/BootstrapTest.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Tests; - -use PHPUnit\Framework\TestCase; - -class BootstrapTest extends TestCase -{ - /** - * @requires PHPUnit < 6.0 - */ - public function testAliasingOfErrorClasses() - { - $this->assertInstanceOf( - \PHPUnit_Framework_Error::class, - new \PHPUnit\Framework\Error\Error('message', 0, __FILE__, __LINE__) - ); - $this->assertInstanceOf( - \PHPUnit_Framework_Error_Deprecated::class, - new \PHPUnit\Framework\Error\Deprecated('message', 0, __FILE__, __LINE__) - ); - $this->assertInstanceOf( - \PHPUnit_Framework_Error_Notice::class, - new \PHPUnit\Framework\Error\Notice('message', 0, __FILE__, __LINE__) - ); - $this->assertInstanceOf( - \PHPUnit_Framework_Error_Warning::class, - new \PHPUnit\Framework\Error\Warning('message', 0, __FILE__, __LINE__) - ); - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/CoverageListenerTest.php b/src/Symfony/Bridge/PhpUnit/Tests/CoverageListenerTest.php index 53b2bb8d6cdff..b309606d5bd4e 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/CoverageListenerTest.php +++ b/src/Symfony/Bridge/PhpUnit/Tests/CoverageListenerTest.php @@ -14,7 +14,7 @@ public function test() exec('type phpdbg 2> /dev/null', $output, $returnCode); - if (\PHP_VERSION_ID >= 70000 && 0 === $returnCode) { + if (0 === $returnCode) { $php = 'phpdbg -qrr'; } else { exec('php --ri xdebug -d zend_extension=xdebug.so 2> /dev/null', $output, $returnCode); diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php index 9cb0a0e32ce3a..a1d3c06ea668f 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/DeprecationTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\DeprecationErrorHandler; use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Deprecation; -use Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV5; +use Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerForV7; class DeprecationTest extends TestCase { @@ -30,7 +30,7 @@ private static function getVendorDir() foreach (get_declared_classes() as $class) { if ('C' === $class[0] && 0 === strpos($class, 'ComposerAutoloaderInit')) { $r = new \ReflectionClass($class); - $vendorDir = \dirname(\dirname($r->getFileName())); + $vendorDir = \dirname($r->getFileName(), 2); if (file_exists($vendorDir.'/composer/installed.json') && @mkdir($vendorDir.'/myfakevendor/myfakepackage1', 0777, true)) { break; } @@ -58,7 +58,7 @@ public function testItCanTellWhetherItIsInternal() { $r = new \ReflectionClass(Deprecation::class); - if (\dirname(\dirname($r->getFileName())) !== \dirname(\dirname(__DIR__))) { + if (\dirname($r->getFileName(), 2) !== \dirname(__DIR__, 2)) { $this->markTestSkipped('Test case is not compatible with having the bridge in vendor/'); } @@ -161,7 +161,7 @@ public function providerGetTypeDetectsSelf() 'triggering_file' => 'dummy_vendor_path', 'files_stack' => [], ]), - SymfonyTestsListenerForV5::class, + SymfonyTestsListenerForV7::class, '', ], ]; @@ -188,7 +188,7 @@ public function providerGetTypeUsesRightTrace() $fakeTrace = [ ['function' => 'trigger_error'], ['class' => SymfonyTestsListenerTrait::class, 'function' => 'endTest'], - ['class' => SymfonyTestsListenerForV5::class, 'function' => 'endTest'], + ['class' => SymfonyTestsListenerForV7::class, 'function' => 'endTest'], ]; return [ @@ -270,7 +270,7 @@ public static function setupBeforeClass(): void foreach (get_declared_classes() as $class) { if ('C' === $class[0] && 0 === strpos($class, 'ComposerAutoloaderInit')) { $r = new \ReflectionClass($class); - $v = \dirname(\dirname($r->getFileName())); + $v = \dirname($r->getFileName(), 2); if (file_exists($v.'/composer/installed.json')) { $loader = require $v.'/autoload.php'; $reflection = new \ReflectionClass($loader); diff --git a/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/log_file.phpt b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/log_file.phpt new file mode 100644 index 0000000000000..7f114ab5e2e5a --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/DeprecationErrorHandler/log_file.phpt @@ -0,0 +1,58 @@ +--TEST-- +Test DeprecationErrorHandler with log file +--FILE-- +testLegacyFoo(); +$foo->testLegacyBar(); + +register_shutdown_function(function () use ($filename) { + var_dump(file_get_contents($filename)); +}); +?> +--EXPECTF-- +string(234) " +Unsilenced deprecation notices (3) + + 2x: unsilenced foo deprecation + 2x in FooTestCase::testLegacyFoo + + 1x: unsilenced bar deprecation + 1x in FooTestCase::testLegacyBar + +Other deprecation notices (1) + + 1x: root deprecation + +" diff --git a/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/coverage/tests/bootstrap.php b/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/coverage/tests/bootstrap.php index 3e45381dce2a0..e302fa05ea74c 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/coverage/tests/bootstrap.php +++ b/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/coverage/tests/bootstrap.php @@ -12,14 +12,4 @@ require __DIR__.'/../src/BarCov.php'; require __DIR__.'/../src/FooCov.php'; -require __DIR__.'/../../../../Legacy/CoverageListenerTrait.php'; - -if (version_compare(\PHPUnit\Runner\Version::id(), '6.0.0', '<')) { - require_once __DIR__.'/../../../../Legacy/CoverageListenerForV5.php'; -} elseif (version_compare(\PHPUnit\Runner\Version::id(), '7.0.0', '<')) { - require_once __DIR__.'/../../../../Legacy/CoverageListenerForV6.php'; -} else { - require_once __DIR__.'/../../../../Legacy/CoverageListenerForV7.php'; -} - require __DIR__.'/../../../../CoverageListener.php'; diff --git a/src/Symfony/Bridge/PhpUnit/TextUI/Command.php b/src/Symfony/Bridge/PhpUnit/TextUI/Command.php index 8690812b56b57..3cc158f6b8e72 100644 --- a/src/Symfony/Bridge/PhpUnit/TextUI/Command.php +++ b/src/Symfony/Bridge/PhpUnit/TextUI/Command.php @@ -11,10 +11,8 @@ namespace Symfony\Bridge\PhpUnit\TextUI; -if (version_compare(\PHPUnit\Runner\Version::id(), '6.0.0', '<')) { - class_alias('Symfony\Bridge\PhpUnit\Legacy\CommandForV5', 'Symfony\Bridge\PhpUnit\TextUI\Command'); -} elseif (version_compare(\PHPUnit\Runner\Version::id(), '9.0.0', '<')) { - class_alias('Symfony\Bridge\PhpUnit\Legacy\CommandForV6', 'Symfony\Bridge\PhpUnit\TextUI\Command'); +if (version_compare(\PHPUnit\Runner\Version::id(), '9.0.0', '<')) { + class_alias('Symfony\Bridge\PhpUnit\Legacy\CommandForV7', 'Symfony\Bridge\PhpUnit\TextUI\Command'); } else { class_alias('Symfony\Bridge\PhpUnit\Legacy\CommandForV9', 'Symfony\Bridge\PhpUnit\TextUI\Command'); } diff --git a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php index 077050688b3c9..e59b6670f07ac 100644 --- a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php +++ b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php @@ -15,8 +15,8 @@ error_reporting(-1); global $argv, $argc; -$argv = isset($_SERVER['argv']) ? $_SERVER['argv'] : []; -$argc = isset($_SERVER['argc']) ? $_SERVER['argc'] : 0; +$argv = $_SERVER['argv'] ?? []; +$argc = $_SERVER['argc'] ?? 0; $getEnvVar = function ($name, $default = false) use ($argv) { if (false !== $value = getenv($name)) { return $value; @@ -98,19 +98,9 @@ $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '9.4') ?: '9.4'; } elseif (\PHP_VERSION_ID >= 70200) { // PHPUnit 8 requires PHP 7.2+ - $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '8.3') ?: '8.3'; -} elseif (\PHP_VERSION_ID >= 70100) { - // PHPUnit 7 requires PHP 7.1+ - $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '7.5') ?: '7.5'; -} elseif (\PHP_VERSION_ID >= 70000) { - // PHPUnit 6 requires PHP 7.0+ - $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '6.5') ?: '6.5'; -} elseif (\PHP_VERSION_ID >= 50600) { - // PHPUnit 4 does not support PHP 7 - $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '5.7') ?: '5.7'; + $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '8.5') ?: '8.5'; } else { - // PHPUnit 5.1 requires PHP 5.6+ - $PHPUNIT_VERSION = '4.8'; + $PHPUNIT_VERSION = $getEnvVar('SYMFONY_PHPUNIT_VERSION', '7.5') ?: '7.5'; } $MAX_PHPUNIT_VERSION = $getEnvVar('SYMFONY_MAX_PHPUNIT_VERSION', false); @@ -177,7 +167,8 @@ } } $SYMFONY_PHPUNIT_REMOVE = $getEnvVar('SYMFONY_PHPUNIT_REMOVE', 'phpspec/prophecy'.($PHPUNIT_VERSION < 6.0 ? ' symfony/yaml' : '')); -$configurationHash = md5(implode(\PHP_EOL, [md5_file(__FILE__), $SYMFONY_PHPUNIT_REMOVE, (int) $PHPUNIT_REMOVE_RETURN_TYPEHINT])); +$SYMFONY_PHPUNIT_REQUIRE = $getEnvVar('SYMFONY_PHPUNIT_REQUIRE', ''); +$configurationHash = md5(implode(\PHP_EOL, [md5_file(__FILE__), $SYMFONY_PHPUNIT_REMOVE, $SYMFONY_PHPUNIT_REQUIRE, (int) $PHPUNIT_REMOVE_RETURN_TYPEHINT])); $PHPUNIT_VERSION_DIR = sprintf('phpunit-%s-%d', $PHPUNIT_VERSION, $PHPUNIT_REMOVE_RETURN_TYPEHINT); if (!file_exists("$PHPUNIT_DIR/$PHPUNIT_VERSION_DIR/phpunit") || $configurationHash !== @file_get_contents("$PHPUNIT_DIR/.$PHPUNIT_VERSION_DIR.md5")) { // Build a standalone phpunit without symfony/yaml nor prophecy by default @@ -234,6 +225,9 @@ if ($SYMFONY_PHPUNIT_REMOVE) { $passthruOrFail("$COMPOSER remove --no-update ".$SYMFONY_PHPUNIT_REMOVE); } + if ($SYMFONY_PHPUNIT_REQUIRE) { + $passthruOrFail("$COMPOSER require --no-update ".$SYMFONY_PHPUNIT_REQUIRE); + } if (5.1 <= $PHPUNIT_VERSION && $PHPUNIT_VERSION < 5.4) { $passthruOrFail("$COMPOSER require --no-update phpunit/phpunit-mock-objects \"~3.1.0\""); } @@ -270,12 +264,12 @@ if ($PHPUNIT_REMOVE_RETURN_TYPEHINT) { $alteredCode = preg_replace('/^ ((?:protected|public)(?: static)? function \w+\(\)): void/m', ' $1', $alteredCode); } - $alteredCode = preg_replace('/abstract class (?:TestCase|PHPUnit_Framework_TestCase)[^\{]+\{/', '$0 '.\PHP_EOL." use \Symfony\Bridge\PhpUnit\Legacy\PolyfillTestCaseTrait;", $alteredCode, 1); + $alteredCode = preg_replace('/abstract class TestCase[^\{]+\{/', '$0 '.\PHP_EOL." use \Symfony\Bridge\PhpUnit\Legacy\PolyfillTestCaseTrait;", $alteredCode, 1); file_put_contents($alteredFile, $alteredCode); // Mutate Assert code $alteredCode = file_get_contents($alteredFile = './src/Framework/Assert.php'); - $alteredCode = preg_replace('/abstract class (?:Assert|PHPUnit_Framework_Assert)[^\{]+\{/', '$0 '.\PHP_EOL." use \Symfony\Bridge\PhpUnit\Legacy\PolyfillAssertTrait;", $alteredCode, 1); + $alteredCode = preg_replace('/abstract class Assert[^\{]+\{/', '$0 '.\PHP_EOL." use \Symfony\Bridge\PhpUnit\Legacy\PolyfillAssertTrait;", $alteredCode, 1); file_put_contents($alteredFile, $alteredCode); file_put_contents('phpunit', <<<'EOPHP' @@ -367,7 +361,7 @@ class_exists(\SymfonyExcludeListSimplePhpunit::class, false) && PHPUnit\Util\Bla } if ($components) { - $skippedTests = isset($_SERVER['SYMFONY_PHPUNIT_SKIPPED_TESTS']) ? $_SERVER['SYMFONY_PHPUNIT_SKIPPED_TESTS'] : false; + $skippedTests = $_SERVER['SYMFONY_PHPUNIT_SKIPPED_TESTS'] ?? false; $runningProcs = []; foreach ($components as $component) { diff --git a/src/Symfony/Bridge/PhpUnit/bootstrap.php b/src/Symfony/Bridge/PhpUnit/bootstrap.php index d9947a7f4e1c8..e07c8d6cf5de8 100644 --- a/src/Symfony/Bridge/PhpUnit/bootstrap.php +++ b/src/Symfony/Bridge/PhpUnit/bootstrap.php @@ -12,96 +12,6 @@ use Doctrine\Common\Annotations\AnnotationRegistry; use Symfony\Bridge\PhpUnit\DeprecationErrorHandler; -if (class_exists(\PHPUnit_Runner_Version::class) && version_compare(\PHPUnit_Runner_Version::id(), '6.0.0', '<')) { - $classes = [ - 'PHPUnit_Framework_Assert', // override PhpUnit's ForwardCompat child class - 'PHPUnit_Framework_AssertionFailedError', // override PhpUnit's ForwardCompat child class - 'PHPUnit_Framework_BaseTestListener', // override PhpUnit's ForwardCompat child class - - 'PHPUnit_Framework_Constraint', - 'PHPUnit_Framework_Constraint_ArrayHasKey', - 'PHPUnit_Framework_Constraint_ArraySubset', - 'PHPUnit_Framework_Constraint_Attribute', - 'PHPUnit_Framework_Constraint_Callback', - 'PHPUnit_Framework_Constraint_ClassHasAttribute', - 'PHPUnit_Framework_Constraint_ClassHasStaticAttribute', - 'PHPUnit_Framework_Constraint_Composite', - 'PHPUnit_Framework_Constraint_Count', - 'PHPUnit_Framework_Constraint_Exception', - 'PHPUnit_Framework_Constraint_ExceptionCode', - 'PHPUnit_Framework_Constraint_ExceptionMessage', - 'PHPUnit_Framework_Constraint_ExceptionMessageRegExp', - 'PHPUnit_Framework_Constraint_FileExists', - 'PHPUnit_Framework_Constraint_GreaterThan', - 'PHPUnit_Framework_Constraint_IsAnything', - 'PHPUnit_Framework_Constraint_IsEmpty', - 'PHPUnit_Framework_Constraint_IsEqual', - 'PHPUnit_Framework_Constraint_IsFalse', - 'PHPUnit_Framework_Constraint_IsIdentical', - 'PHPUnit_Framework_Constraint_IsInstanceOf', - 'PHPUnit_Framework_Constraint_IsJson', - 'PHPUnit_Framework_Constraint_IsNull', - 'PHPUnit_Framework_Constraint_IsTrue', - 'PHPUnit_Framework_Constraint_IsType', - 'PHPUnit_Framework_Constraint_JsonMatches', - 'PHPUnit_Framework_Constraint_JsonMatches_ErrorMessageProvider', - 'PHPUnit_Framework_Constraint_LessThan', - 'PHPUnit_Framework_Constraint_ObjectHasAttribute', - 'PHPUnit_Framework_Constraint_PCREMatch', - 'PHPUnit_Framework_Constraint_SameSize', - 'PHPUnit_Framework_Constraint_StringContains', - 'PHPUnit_Framework_Constraint_StringEndsWith', - 'PHPUnit_Framework_Constraint_StringMatches', - 'PHPUnit_Framework_Constraint_StringStartsWith', - 'PHPUnit_Framework_Constraint_TraversableContains', - 'PHPUnit_Framework_Constraint_TraversableContainsOnly', - - 'PHPUnit_Framework_Error_Deprecated', - 'PHPUnit_Framework_Error_Notice', - 'PHPUnit_Framework_Error_Warning', - 'PHPUnit_Framework_Exception', - 'PHPUnit_Framework_ExpectationFailedException', - - 'PHPUnit_Framework_MockObject_MockObject', - - 'PHPUnit_Framework_IncompleteTest', - 'PHPUnit_Framework_IncompleteTestCase', - 'PHPUnit_Framework_IncompleteTestError', - 'PHPUnit_Framework_RiskyTest', - 'PHPUnit_Framework_RiskyTestError', - 'PHPUnit_Framework_SkippedTest', - 'PHPUnit_Framework_SkippedTestCase', - 'PHPUnit_Framework_SkippedTestError', - 'PHPUnit_Framework_SkippedTestSuiteError', - - 'PHPUnit_Framework_SyntheticError', - - 'PHPUnit_Framework_Test', - 'PHPUnit_Framework_TestCase', // override PhpUnit's ForwardCompat child class - 'PHPUnit_Framework_TestFailure', - 'PHPUnit_Framework_TestListener', - 'PHPUnit_Framework_TestResult', - 'PHPUnit_Framework_TestSuite', // override PhpUnit's ForwardCompat child class - - 'PHPUnit_Runner_BaseTestRunner', - 'PHPUnit_Runner_Version', - - 'PHPUnit_Util_Blacklist', - 'PHPUnit_Util_ErrorHandler', - 'PHPUnit_Util_Test', - 'PHPUnit_Util_XML', - ]; - foreach ($classes as $class) { - class_alias($class, '\\'.strtr($class, '_', '\\')); - } - - class_alias('PHPUnit_Framework_Constraint_And', 'PHPUnit\Framework\Constraint\LogicalAnd'); - class_alias('PHPUnit_Framework_Constraint_Not', 'PHPUnit\Framework\Constraint\LogicalNot'); - class_alias('PHPUnit_Framework_Constraint_Or', 'PHPUnit\Framework\Constraint\LogicalOr'); - class_alias('PHPUnit_Framework_Constraint_Xor', 'PHPUnit\Framework\Constraint\LogicalXor'); - class_alias('PHPUnit_Framework_Error', 'PHPUnit\Framework\Error\Error'); -} - // Detect if we need to serialize deprecations to a file. if ($file = getenv('SYMFONY_DEPRECATIONS_SERIALIZE')) { DeprecationErrorHandler::collectDeprecations($file); @@ -110,7 +20,7 @@ class_alias('PHPUnit_Framework_Error', 'PHPUnit\Framework\Error\Error'); } // Detect if we're loaded by an actual run of phpunit -if (!defined('PHPUNIT_COMPOSER_INSTALL') && !class_exists(\PHPUnit_TextUI_Command::class, false) && !class_exists(\PHPUnit\TextUI\Command::class, false)) { +if (!defined('PHPUNIT_COMPOSER_INSTALL') && !class_exists(\PHPUnit\TextUI\Command::class, false)) { return; } diff --git a/src/Symfony/Bridge/PhpUnit/composer.json b/src/Symfony/Bridge/PhpUnit/composer.json index 2d7c04040127f..00dc40452757c 100644 --- a/src/Symfony/Bridge/PhpUnit/composer.json +++ b/src/Symfony/Bridge/PhpUnit/composer.json @@ -16,19 +16,19 @@ } ], "require": { - "php": ">=5.5.9 EVEN ON LATEST SYMFONY VERSIONS TO ALLOW USING", + "php": ">=7.1.3 EVEN ON LATEST SYMFONY VERSIONS TO ALLOW USING", "php": "THIS BRIDGE WHEN TESTING LOWEST SYMFONY VERSIONS.", - "php": ">=5.5.9" + "php": ">=7.1.3", + "symfony/deprecation-contracts": "^2.1" }, "require-dev": { - "symfony/deprecation-contracts": "^2.1", "symfony/error-handler": "^4.4|^5.0" }, "suggest": { "symfony/error-handler": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" }, "conflict": { - "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0|<6.4,>=6.0|9.1.2" + "phpunit/phpunit": "<7.5|9.1.2" }, "autoload": { "files": [ "bootstrap.php" ], diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index d44edb22fc923..b2d25bce4f6f7 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3.0 +----- + +* Added a new `serialize` filter to serialize objects using the Serializer component + 5.2.0 ----- diff --git a/src/Symfony/Bridge/Twig/Command/DebugCommand.php b/src/Symfony/Bridge/Twig/Command/DebugCommand.php index 8962a41dd366e..88fae85aaf102 100644 --- a/src/Symfony/Bridge/Twig/Command/DebugCommand.php +++ b/src/Symfony/Bridge/Twig/Command/DebugCommand.php @@ -33,6 +33,7 @@ class DebugCommand extends Command { protected static $defaultName = 'debug:twig'; + protected static $defaultDescription = 'Shows a list of twig functions, filters, globals and tests'; private $twig; private $projectDir; @@ -60,7 +61,7 @@ protected function configure() new InputOption('filter', null, InputOption::VALUE_REQUIRED, 'Show details for all entries matching this filter'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (text or json)', 'text'), ]) - ->setDescription('Shows a list of twig functions, filters, globals and tests') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command outputs a list of twig functions, filters, globals and tests. diff --git a/src/Symfony/Bridge/Twig/Command/LintCommand.php b/src/Symfony/Bridge/Twig/Command/LintCommand.php index 505f05959bb68..9b4c7b9af1299 100644 --- a/src/Symfony/Bridge/Twig/Command/LintCommand.php +++ b/src/Symfony/Bridge/Twig/Command/LintCommand.php @@ -35,6 +35,7 @@ class LintCommand extends Command { protected static $defaultName = 'lint:twig'; + protected static $defaultDescription = 'Lints a Twig template and outputs encountered errors'; private $twig; @@ -48,7 +49,7 @@ public function __construct(Environment $twig) protected function configure() { $this - ->setDescription('Lints a template and outputs encountered errors') + ->setDescription(self::$defaultDescription) ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') ->addOption('show-deprecations', null, InputOption::VALUE_NONE, 'Show deprecations as errors') ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') diff --git a/src/Symfony/Bridge/Twig/Extension/CodeExtension.php b/src/Symfony/Bridge/Twig/Extension/CodeExtension.php index 7acf75fb9c17a..5ecc09060b4cd 100644 --- a/src/Symfony/Bridge/Twig/Extension/CodeExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/CodeExtension.php @@ -137,7 +137,7 @@ public function fileExcerpt(string $file, int $line, int $srcContext = 3): ?stri } for ($i = max($line - $srcContext, 1), $max = min($line + $srcContext, \count($content)); $i <= $max; ++$i) { - $lines[] = ''.self::fixCodeMarkup($content[$i - 1]).''; + $lines[] = ''.self::fixCodeMarkup($content[$i - 1]).''; } return '
    '.implode("\n", $lines).'
'; diff --git a/src/Symfony/Bridge/Twig/Extension/SerializerExtension.php b/src/Symfony/Bridge/Twig/Extension/SerializerExtension.php new file mode 100644 index 0000000000000..f38571efaaac8 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/SerializerExtension.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; + +/** + * @author Jesse Rushlow + */ +final class SerializerExtension extends AbstractExtension +{ + public function getFilters(): array + { + return [ + new TwigFilter('serialize', [SerializerRuntime::class, 'serialize']), + ]; + } +} diff --git a/src/Symfony/Bridge/Twig/Extension/SerializerRuntime.php b/src/Symfony/Bridge/Twig/Extension/SerializerRuntime.php new file mode 100644 index 0000000000000..3a4087aa79e26 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/SerializerRuntime.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\Serializer\SerializerInterface; +use Twig\Extension\RuntimeExtensionInterface; + +/** + * @author Jesse Rushlow + */ +final class SerializerRuntime implements RuntimeExtensionInterface +{ + private $serializer; + + public function __construct(SerializerInterface $serializer) + { + $this->serializer = $serializer; + } + + public function serialize($data, string $format = 'json', array $context = []): string + { + return $this->serializer->serialize($data, $format, $context); + } +} diff --git a/src/Symfony/Bridge/Twig/NodeVisitor/TranslationNodeVisitor.php b/src/Symfony/Bridge/Twig/NodeVisitor/TranslationNodeVisitor.php index 23c244e4248f7..e5b0fc4ea1b73 100644 --- a/src/Symfony/Bridge/Twig/NodeVisitor/TranslationNodeVisitor.php +++ b/src/Symfony/Bridge/Twig/NodeVisitor/TranslationNodeVisitor.php @@ -13,6 +13,7 @@ use Symfony\Bridge\Twig\Node\TransNode; use Twig\Environment; +use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\FunctionExpression; @@ -87,6 +88,16 @@ protected function doEnterNode(Node $node, Environment $env): Node $node->getNode('body')->getAttribute('data'), $node->hasNode('domain') ? $this->getReadDomainFromNode($node->getNode('domain')) : null, ]; + } elseif ( + $node instanceof FilterExpression && + 'trans' === $node->getNode('filter')->getAttribute('value') && + $node->getNode('node') instanceof ConcatBinary && + $message = $this->getConcatValueFromNode($node->getNode('node'), null) + ) { + $this->messages[] = [ + $message, + $this->getReadDomainFromArguments($node->getNode('arguments'), 1), + ]; } return $node; @@ -151,4 +162,28 @@ private function getReadDomainFromNode(Node $node): ?string return self::UNDEFINED_DOMAIN; } + + private function getConcatValueFromNode(Node $node, ?string $value): ?string + { + if ($node instanceof ConcatBinary) { + foreach ($node as $nextNode) { + if ($nextNode instanceof ConcatBinary) { + $nextValue = $this->getConcatValueFromNode($nextNode, $value); + if (null === $nextValue) { + return null; + } + $value .= $nextValue; + } elseif ($nextNode instanceof ConstantExpression) { + $value .= $nextNode->getAttribute('value'); + } else { + // this is a node we cannot process (variable, or translation in translation) + return null; + } + } + } elseif ($node instanceof ConstantExpression) { + $value .= $node->getAttribute('value'); + } + + return $value; + } } diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig index 3f31c5f31c8c6..94f87dc165ec6 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig @@ -85,7 +85,7 @@ {{- block('choice_widget_options') -}} {%- else -%} - + {%- endif -%} {% endfor %} {%- endblock choice_widget_options -%} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/SerializerModelFixture.php b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/SerializerModelFixture.php new file mode 100644 index 0000000000000..07493ea9d8db7 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/SerializerModelFixture.php @@ -0,0 +1,18 @@ + + */ +class SerializerModelFixture +{ + /** + * @Groups({"read"}) + */ + public $name = 'howdy'; + + public $title = 'fixture'; +} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/SerializerExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/SerializerExtensionTest.php new file mode 100644 index 0000000000000..ef54ee2775f15 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/SerializerExtensionTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Doctrine\Common\Annotations\AnnotationReader; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Extension\SerializerExtension; +use Symfony\Bridge\Twig\Extension\SerializerRuntime; +use Symfony\Bridge\Twig\Tests\Extension\Fixtures\SerializerModelFixture; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Encoder\YamlEncoder; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Twig\Environment; +use Twig\Loader\ArrayLoader; +use Twig\RuntimeLoader\RuntimeLoaderInterface; + +/** + * @author Jesse Rushlow + */ +class SerializerExtensionTest extends TestCase +{ + /** + * @dataProvider serializerDataProvider + */ + public function testSerializeFilter(string $template, string $expectedResult) + { + $twig = $this->getTwig($template); + + self::assertSame($expectedResult, $twig->render('template', ['object' => new SerializerModelFixture()])); + } + + public function serializerDataProvider(): \Generator + { + yield ['{{ object|serialize }}', '{"name":"howdy","title":"fixture"}']; + yield ['{{ object|serialize(\'yaml\') }}', '{ name: howdy, title: fixture }']; + yield ['{{ object|serialize(\'yaml\', {groups: \'read\'}) }}', '{ name: howdy }']; + } + + private function getTwig(string $template): Environment + { + $meta = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $runtime = new SerializerRuntime(new Serializer([new ObjectNormalizer($meta)], [new JsonEncoder(), new YamlEncoder()])); + + $mockRuntimeLoader = $this->createMock(RuntimeLoaderInterface::class); + $mockRuntimeLoader + ->method('load') + ->willReturnMap([ + ['Symfony\Bridge\Twig\Extension\SerializerRuntime', $runtime], + ]) + ; + + $twig = new Environment(new ArrayLoader(['template' => $template])); + $twig->addExtension(new SerializerExtension()); + $twig->addRuntimeLoader($mockRuntimeLoader); + + return $twig; + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Translation/TwigExtractorTest.php b/src/Symfony/Bridge/Twig/Tests/Translation/TwigExtractorTest.php index 7b353630a50be..6a7336d7b1995 100644 --- a/src/Symfony/Bridge/Twig/Tests/Translation/TwigExtractorTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Translation/TwigExtractorTest.php @@ -44,6 +44,10 @@ public function testExtract($template, $messages) $m->setAccessible(true); $m->invoke($extractor, $template, $catalogue); + if (0 === \count($messages)) { + $this->assertSame($catalogue->all(), $messages); + } + foreach ($messages as $key => $domain) { $this->assertTrue($catalogue->has($key, $domain)); $this->assertEquals('prefix'.$key, $catalogue->get($key, $domain)); @@ -71,6 +75,15 @@ public function getExtractData() // make sure this works with twig's named arguments ['{{ "new key" | trans(domain="domain") }}', ['new key' => 'domain']], + + // concat translations + ['{{ ("new" ~ " key") | trans() }}', ['new key' => 'messages']], + ['{{ ("another " ~ "new " ~ "key") | trans() }}', ['another new key' => 'messages']], + ['{{ ("new" ~ " key") | trans(domain="domain") }}', ['new key' => 'domain']], + ['{{ ("another " ~ "new " ~ "key") | trans(domain="domain") }}', ['another new key' => 'domain']], + // if it has a variable or other expression, we can not extract it + ['{% set foo = "new" %} {{ ("new " ~ foo ~ "key") | trans() }}', []], + ['{{ ("foo " ~ "new"|trans ~ "key") | trans() }}', ['new' => 'messages']], ]; } diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index 1a1f364eb9a85..29dc959e5c18c 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -22,12 +22,13 @@ "twig/twig": "^2.13|^3.0.4" }, "require-dev": { + "doctrine/annotations": "^1.12", "egulias/email-validator": "^2.1.10", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "symfony/asset": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", "symfony/finder": "^4.4|^5.0", - "symfony/form": "^5.1.9", + "symfony/form": "^5.3", "symfony/http-foundation": "^4.4|^5.0", "symfony/http-kernel": "^4.4|^5.0", "symfony/intl": "^4.4|^5.0", @@ -55,7 +56,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/console": "<4.4", - "symfony/form": "<5.1", + "symfony/form": "<5.3", "symfony/http-foundation": "<4.4", "symfony/http-kernel": "<4.4", "symfony/translation": "<5.2", diff --git a/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php index e3a724cd2448a..3ed12fc3f692d 100644 --- a/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php @@ -13,7 +13,6 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -use Symfony\Component\VarDumper\Dumper\HtmlDumper; /** * DebugExtension configuration structure. @@ -51,21 +50,13 @@ public function getConfigTreeBuilder() ->example('php://stderr, or tcp://%env(VAR_DUMPER_SERVER)% when using the "server:dump" command') ->defaultNull() ->end() - ->end() - ; - - if (method_exists(HtmlDumper::class, 'setTheme')) { - $rootNode - ->children() - ->enumNode('theme') - ->info('Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light"') - ->example('dark') - ->values(['dark', 'light']) - ->defaultValue('dark') - ->end() + ->enumNode('theme') + ->info('Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light"') + ->example('dark') + ->values(['dark', 'light']) + ->defaultValue('dark') ->end() ; - } return $treeBuilder; } diff --git a/src/Symfony/Bundle/DebugBundle/Resources/config/services.php b/src/Symfony/Bundle/DebugBundle/Resources/config/services.php index abde96d0625ec..d0f57c092872e 100644 --- a/src/Symfony/Bundle/DebugBundle/Resources/config/services.php +++ b/src/Symfony/Bundle/DebugBundle/Resources/config/services.php @@ -11,7 +11,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Monolog\Formatter\FormatterInterface; use Symfony\Bridge\Monolog\Command\ServerLogCommand; +use Symfony\Bridge\Monolog\Formatter\ConsoleFormatter; use Symfony\Bridge\Twig\Extension\DumpExtension; use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; use Symfony\Component\HttpKernel\EventListener\DumpListener; @@ -127,9 +129,12 @@ 'html' => inline_service(HtmlDescriptor::class)->args([service('var_dumper.html_dumper')]), ], ]) - ->tag('console.command', ['command' => 'server:dump']) + ->tag('console.command') ->set('monolog.command.server_log', ServerLogCommand::class) - ->tag('console.command', ['command' => 'server:log']) ; + + if (class_exists(ConsoleFormatter::class) && interface_exists(FormatterInterface::class)) { + $container->services()->get('monolog.command.server_log')->tag('console.command'); + } }; diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index c4b5786744fb3..e8924119b9927 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -1,6 +1,22 @@ CHANGELOG ========= +5.3 +--- + + * Deprecate the `session.storage` alias and `session.storage.*` services, use the `session.storage.factory` alias and `session.storage.factory.*` services instead + * Deprecate the `framework.session.storage_id` configuration option, use the `framework.session.storage_factory_id` configuration option instead + * Deprecate the `session` service and the `SessionInterface` alias, use the `Request::getSession()` or the new `RequestStack::getSession()` methods instead + * Added `AbstractController::renderForm()` to render a form and set the appropriate HTTP status code + * Added support for configuring PHP error level to log levels + * Added the `dispatcher` option to `debug:event-dispatcher` + * Added the `event_dispatcher.dispatcher` tag + * Added `assertResponseFormatSame()` in `BrowserKitAssertionsTrait` + * Add support for configuring UUID factory services + * Add tag `assets.package` to register asset packages + * Add support to use a PSR-6 compatible cache for Doctrine annotations + * Deprecate all other values than "none", "php_array" and "file" for `framework.annotation.cache` + 5.2.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php index 4c9f968736c59..d1de7c651b9b1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php @@ -30,6 +30,7 @@ class AboutCommand extends Command { protected static $defaultName = 'about'; + protected static $defaultDescription = 'Displays information about the current project'; /** * {@inheritdoc} @@ -37,7 +38,7 @@ class AboutCommand extends Command protected function configure() { $this - ->setDescription('Displays information about the current project') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOT' The %command.name% command displays information about the current Symfony project. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/AssetsInstallCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/AssetsInstallCommand.php index 70ad92343eb25..ae8ca199879fc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/AssetsInstallCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/AssetsInstallCommand.php @@ -40,6 +40,7 @@ class AssetsInstallCommand extends Command public const METHOD_RELATIVE_SYMLINK = 'relative symlink'; protected static $defaultName = 'assets:install'; + protected static $defaultDescription = 'Installs bundles web assets under a public directory'; private $filesystem; private $projectDir; @@ -64,7 +65,7 @@ protected function configure() ->addOption('symlink', null, InputOption::VALUE_NONE, 'Symlinks the assets instead of copying it') ->addOption('relative', null, InputOption::VALUE_NONE, 'Make relative symlinks') ->addOption('no-cleanup', null, InputOption::VALUE_NONE, 'Do not remove the assets of the bundles that no longer exist') - ->setDescription('Installs bundles web assets under a public directory') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOT' The %command.name% command installs bundle assets into a given directory (e.g. the public directory). diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CacheClearCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CacheClearCommand.php index b9a829471f098..5b9271920e7ae 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CacheClearCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CacheClearCommand.php @@ -36,6 +36,7 @@ class CacheClearCommand extends Command { protected static $defaultName = 'cache:clear'; + protected static $defaultDescription = 'Clears the cache'; private $cacheClearer; private $filesystem; @@ -58,7 +59,7 @@ protected function configure() new InputOption('no-warmup', '', InputOption::VALUE_NONE, 'Do not warm up the cache'), new InputOption('no-optional-warmers', '', InputOption::VALUE_NONE, 'Skip optional cache warmers (faster)'), ]) - ->setDescription('Clears the cache') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command clears the application cache for a given environment and debug mode: diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolClearCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolClearCommand.php index 50aacd9bbd73d..ae78409b70fb7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolClearCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolClearCommand.php @@ -28,6 +28,7 @@ final class CachePoolClearCommand extends Command { protected static $defaultName = 'cache:pool:clear'; + protected static $defaultDescription = 'Clears cache pools'; private $poolClearer; @@ -47,7 +48,7 @@ protected function configure() ->setDefinition([ new InputArgument('pools', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'A list of cache pools or cache pool clearers'), ]) - ->setDescription('Clears cache pools') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command clears the given cache pools or cache pool clearers. @@ -88,16 +89,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int $clearer->clear($kernel->getContainer()->getParameter('kernel.cache_dir')); } + $failure = false; foreach ($pools as $id => $pool) { $io->comment(sprintf('Clearing cache pool: %s', $id)); if ($pool instanceof CacheItemPoolInterface) { - $pool->clear(); + if (!$pool->clear()) { + $io->warning(sprintf('Cache pool "%s" could not be cleared.', $pool)); + $failure = true; + } } else { - $this->poolClearer->clearPool($id); + if (false === $this->poolClearer->clearPool($id)) { + $io->warning(sprintf('Cache pool "%s" could not be cleared.', $pool)); + $failure = true; + } } } + if ($failure) { + return 1; + } + $io->success('Cache was successfully cleared.'); return 0; diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolDeleteCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolDeleteCommand.php index 2a7a2fe513040..6ac82925e6a3d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolDeleteCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolDeleteCommand.php @@ -26,6 +26,7 @@ final class CachePoolDeleteCommand extends Command { protected static $defaultName = 'cache:pool:delete'; + protected static $defaultDescription = 'Deletes an item from a cache pool'; private $poolClearer; @@ -46,7 +47,7 @@ protected function configure() new InputArgument('pool', InputArgument::REQUIRED, 'The cache pool from which to delete an item'), new InputArgument('key', InputArgument::REQUIRED, 'The cache key to delete from the pool'), ]) - ->setDescription('Deletes an item from a cache pool') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% deletes an item from a given cache pool. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolListCommand.php index 7b725411d5015..4a4b1eb2fa49e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolListCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolListCommand.php @@ -24,6 +24,7 @@ final class CachePoolListCommand extends Command { protected static $defaultName = 'cache:pool:list'; + protected static $defaultDescription = 'List available cache pools'; private $poolNames; @@ -40,7 +41,7 @@ public function __construct(array $poolNames) protected function configure() { $this - ->setDescription('List available cache pools') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command lists all available cache pools. EOF diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php index 65f3ff6b5802e..bc5b7f861fb72 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php @@ -25,6 +25,7 @@ final class CachePoolPruneCommand extends Command { protected static $defaultName = 'cache:pool:prune'; + protected static $defaultDescription = 'Prunes cache pools'; private $pools; @@ -44,7 +45,7 @@ public function __construct(iterable $pools) protected function configure() { $this - ->setDescription('Prunes cache pools') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command deletes all expired items from all pruneable pools. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php index 8feb2dd9c51b2..a79a1106d6e6d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php @@ -29,6 +29,7 @@ class CacheWarmupCommand extends Command { protected static $defaultName = 'cache:warmup'; + protected static $defaultDescription = 'Warms up an empty cache'; private $cacheWarmer; @@ -48,7 +49,7 @@ protected function configure() ->setDefinition([ new InputOption('no-optional-warmers', '', InputOption::VALUE_NONE, 'Skip optional cache warmers (faster)'), ]) - ->setDescription('Warms up an empty cache') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command warms up the cache. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php index c68f17e120bbd..8a526cb99143d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php @@ -33,6 +33,7 @@ class ConfigDebugCommand extends AbstractConfigCommand { protected static $defaultName = 'debug:config'; + protected static $defaultDescription = 'Dumps the current configuration for an extension'; /** * {@inheritdoc} @@ -44,7 +45,7 @@ protected function configure() new InputArgument('name', InputArgument::OPTIONAL, 'The bundle name or the extension alias'), new InputArgument('path', InputArgument::OPTIONAL, 'The configuration option path'), ]) - ->setDescription('Dumps the current configuration for an extension') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command dumps the current configuration for an extension/bundle. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php index 62dcc856e0f56..c1b0cf1626c19 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php @@ -36,6 +36,7 @@ class ConfigDumpReferenceCommand extends AbstractConfigCommand { protected static $defaultName = 'config:dump-reference'; + protected static $defaultDescription = 'Dumps the default configuration for an extension'; /** * {@inheritdoc} @@ -48,7 +49,7 @@ protected function configure() new InputArgument('path', InputArgument::OPTIONAL, 'The configuration option path'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (yaml or xml)', 'yaml'), ]) - ->setDescription('Dumps the default configuration for an extension') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command dumps the default configuration for an extension/bundle. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php index 7c330dbdf4f85..8b41fc1dce7a0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php @@ -35,6 +35,7 @@ class ContainerDebugCommand extends Command use BuildDebugContainerTrait; protected static $defaultName = 'debug:container'; + protected static $defaultDescription = 'Displays current services for an application'; /** * {@inheritdoc} @@ -57,7 +58,7 @@ protected function configure() new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw description'), new InputOption('deprecations', null, InputOption::VALUE_NONE, 'Displays deprecations generated when compiling and warming up the container'), ]) - ->setDescription('Displays current services for an application') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command displays all configured public services: diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ContainerLintCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ContainerLintCommand.php index f059df1ee62fe..02ef668cfbee6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ContainerLintCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ContainerLintCommand.php @@ -30,6 +30,7 @@ final class ContainerLintCommand extends Command { protected static $defaultName = 'lint:container'; + protected static $defaultDescription = 'Ensures that arguments injected into services match type declarations'; /** * @var ContainerBuilder @@ -42,7 +43,7 @@ final class ContainerLintCommand extends Command protected function configure() { $this - ->setDescription('Ensures that arguments injected into services match type declarations') + ->setDescription(self::$defaultDescription) ->setHelp('This command parses service definitions and ensures that injected values match the type declarations of each services\' class.') ; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php index 32bd630f32516..6a67d9d89d5df 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/DebugAutowiringCommand.php @@ -30,6 +30,8 @@ class DebugAutowiringCommand extends ContainerDebugCommand { protected static $defaultName = 'debug:autowiring'; + protected static $defaultDescription = 'Lists classes/interfaces you can use for autowiring'; + private $supportsHref; private $fileLinkFormatter; @@ -50,7 +52,7 @@ protected function configure() new InputArgument('search', InputArgument::OPTIONAL, 'A search filter'), new InputOption('all', null, InputOption::VALUE_NONE, 'Show also services that are not aliased'), ]) - ->setDescription('Lists classes/interfaces you can use for autowiring') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command displays the classes and interfaces that you can use as type-hints for autowiring: diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/EventDispatcherDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/EventDispatcherDebugCommand.php index ad49cdeeaa87f..b828dee78139e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/EventDispatcherDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/EventDispatcherDebugCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\FrameworkBundle\Command; +use Psr\Container\ContainerInterface; use Symfony\Bundle\FrameworkBundle\Console\Helper\DescriptorHelper; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -29,14 +30,17 @@ */ class EventDispatcherDebugCommand extends Command { + private const DEFAULT_DISPATCHER = 'event_dispatcher'; + protected static $defaultName = 'debug:event-dispatcher'; - private $dispatcher; + protected static $defaultDescription = 'Displays configured listeners for an application'; + private $dispatchers; - public function __construct(EventDispatcherInterface $dispatcher) + public function __construct(ContainerInterface $dispatchers) { parent::__construct(); - $this->dispatcher = $dispatcher; + $this->dispatchers = $dispatchers; } /** @@ -46,11 +50,12 @@ protected function configure() { $this ->setDefinition([ - new InputArgument('event', InputArgument::OPTIONAL, 'An event name'), + new InputArgument('event', InputArgument::OPTIONAL, 'An event name or a part of the event name'), + new InputOption('dispatcher', null, InputOption::VALUE_REQUIRED, 'To view events of a specific event dispatcher', self::DEFAULT_DISPATCHER), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt'), new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw description'), ]) - ->setDescription('Displays configured listeners for an application') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command displays all configured listeners: @@ -74,22 +79,57 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $options = []; - if ($event = $input->getArgument('event')) { - if (!$this->dispatcher->hasListeners($event)) { - $io->getErrorStyle()->warning(sprintf('The event "%s" does not have any registered listeners.', $event)); + $dispatcherServiceName = $input->getOption('dispatcher'); + if (!$this->dispatchers->has($dispatcherServiceName)) { + $io->getErrorStyle()->error(sprintf('Event dispatcher "%s" is not available.', $dispatcherServiceName)); - return 0; - } + return 1; + } - $options = ['event' => $event]; + $dispatcher = $this->dispatchers->get($dispatcherServiceName); + + if ($event = $input->getArgument('event')) { + if ($dispatcher->hasListeners($event)) { + $options = ['event' => $event]; + } else { + // if there is no direct match, try find partial matches + $events = $this->searchForEvent($dispatcher, $event); + if (0 === \count($events)) { + $io->getErrorStyle()->warning(sprintf('The event "%s" does not have any registered listeners.', $event)); + + return 0; + } elseif (1 === \count($events)) { + $options = ['event' => $events[array_key_first($events)]]; + } else { + $options = ['events' => $events]; + } + } } $helper = new DescriptorHelper(); + + if (self::DEFAULT_DISPATCHER !== $dispatcherServiceName) { + $options['dispatcher_service_name'] = $dispatcherServiceName; + } + $options['format'] = $input->getOption('format'); $options['raw_text'] = $input->getOption('raw'); $options['output'] = $io; - $helper->describe($io, $this->dispatcher, $options); + $helper->describe($io, $dispatcher, $options); return 0; } + + private function searchForEvent(EventDispatcherInterface $dispatcher, $needle): array + { + $output = []; + $allEvents = array_keys($dispatcher->getListeners()); + foreach ($allEvents as $event) { + if (str_contains($event, $needle)) { + $output[] = $event; + } + } + + return $output; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php index 16acf7a7db9c4..24c306c9d901d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php @@ -36,6 +36,7 @@ class RouterDebugCommand extends Command use BuildDebugContainerTrait; protected static $defaultName = 'debug:router'; + protected static $defaultDescription = 'Displays current routes for an application'; private $router; private $fileLinkFormatter; @@ -59,7 +60,7 @@ protected function configure() new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt'), new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw route(s)'), ]) - ->setDescription('Displays current routes for an application') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% displays the configured routes: diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/RouterMatchCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/RouterMatchCommand.php index 1e2fefbbacb26..85ce52608d0d4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/RouterMatchCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/RouterMatchCommand.php @@ -31,6 +31,7 @@ class RouterMatchCommand extends Command { protected static $defaultName = 'router:match'; + protected static $defaultDescription = 'Helps debug routes by simulating a path info match'; private $router; private $expressionLanguageProviders; @@ -55,7 +56,7 @@ protected function configure() new InputOption('scheme', null, InputOption::VALUE_REQUIRED, 'Sets the URI scheme (usually http or https)'), new InputOption('host', null, InputOption::VALUE_REQUIRED, 'Sets the URI host'), ]) - ->setDescription('Helps debug routes by simulating a path info match') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% shows which routes match a given request and which don't and for what reason: diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php index 1571c7f1b7c79..0ca168b5fd177 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php @@ -27,6 +27,7 @@ final class SecretsDecryptToLocalCommand extends Command { protected static $defaultName = 'secrets:decrypt-to-local'; + protected static $defaultDescription = 'Decrypts all secrets and stores them in the local vault'; private $vault; private $localVault; @@ -42,7 +43,7 @@ public function __construct(AbstractVault $vault, AbstractVault $localVault = nu protected function configure() { $this - ->setDescription('Decrypts all secrets and stores them in the local vault') + ->setDescription(self::$defaultDescription) ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the local vault') ->setHelp(<<<'EOF' The %command.name% command decrypts all secrets and copies them in the local vault. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php index e1c8904c698b5..d9a635f210391 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php @@ -26,6 +26,7 @@ final class SecretsEncryptFromLocalCommand extends Command { protected static $defaultName = 'secrets:encrypt-from-local'; + protected static $defaultDescription = 'Encrypts all local secrets to the vault'; private $vault; private $localVault; @@ -41,7 +42,7 @@ public function __construct(AbstractVault $vault, AbstractVault $localVault = nu protected function configure() { $this - ->setDescription('Encrypts all local secrets to the vault') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command encrypts all locally overridden secrets to the vault. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php index 18dba29ac9797..abd052918e2eb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php @@ -29,6 +29,7 @@ final class SecretsGenerateKeysCommand extends Command { protected static $defaultName = 'secrets:generate-keys'; + protected static $defaultDescription = 'Generates new encryption keys'; private $vault; private $localVault; @@ -44,7 +45,7 @@ public function __construct(AbstractVault $vault, AbstractVault $localVault = nu protected function configure() { $this - ->setDescription('Generates new encryption keys') + ->setDescription(self::$defaultDescription) ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') ->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypts existing secrets with the newly generated keys.') ->setHelp(<<<'EOF' diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php index 9848ab993331e..f828776b16da3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php @@ -30,6 +30,7 @@ final class SecretsListCommand extends Command { protected static $defaultName = 'secrets:list'; + protected static $defaultDescription = 'Lists all secrets'; private $vault; private $localVault; @@ -45,7 +46,7 @@ public function __construct(AbstractVault $vault, AbstractVault $localVault = nu protected function configure() { $this - ->setDescription('Lists all secrets') + ->setDescription(self::$defaultDescription) ->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names') ->setHelp(<<<'EOF' The %command.name% command list all stored secrets. diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php index 2f06cb4592545..1ea079b3dc338 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php @@ -29,6 +29,7 @@ final class SecretsRemoveCommand extends Command { protected static $defaultName = 'secrets:remove'; + protected static $defaultDescription = 'Removes a secret from the vault'; private $vault; private $localVault; @@ -44,7 +45,7 @@ public function __construct(AbstractVault $vault, AbstractVault $localVault = nu protected function configure() { $this - ->setDescription('Removes a secret from the vault') + ->setDescription(self::$defaultDescription) ->addArgument('name', InputArgument::REQUIRED, 'The name of the secret') ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') ->setHelp(<<<'EOF' diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php index 95b8f6a0f622b..3753691a7379b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php @@ -30,6 +30,7 @@ final class SecretsSetCommand extends Command { protected static $defaultName = 'secrets:set'; + protected static $defaultDescription = 'Sets a secret in the vault'; private $vault; private $localVault; @@ -45,7 +46,7 @@ public function __construct(AbstractVault $vault, AbstractVault $localVault = nu protected function configure() { $this - ->setDescription('Sets a secret in the vault') + ->setDescription(self::$defaultDescription) ->addArgument('name', InputArgument::REQUIRED, 'The name of the secret') ->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN') ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php index ec17387d9b078..8cb80babe61cb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php @@ -47,6 +47,7 @@ class TranslationDebugCommand extends Command public const MESSAGE_EQUALS_FALLBACK = 2; protected static $defaultName = 'debug:translation'; + protected static $defaultDescription = 'Displays translation messages information'; private $translator; private $reader; @@ -83,7 +84,7 @@ protected function configure() new InputOption('only-unused', null, InputOption::VALUE_NONE, 'Displays only unused messages'), new InputOption('all', null, InputOption::VALUE_NONE, 'Load messages from all registered bundles'), ]) - ->setDescription('Displays translation messages information') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command helps finding unused or missing translation messages and comparing them with the fallback ones by inspecting the diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php index 392541e656848..5f61f7f7fbebc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -42,6 +42,7 @@ class TranslationUpdateCommand extends Command private const SORT_ORDERS = [self::ASC, self::DESC]; protected static $defaultName = 'translation:update'; + protected static $defaultDescription = 'Updates the translation file'; private $writer; private $reader; @@ -85,7 +86,7 @@ protected function configure() new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically', 'asc'), new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'), ]) - ->setDescription('Updates the translation file') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command extracts translation strings from templates of a given bundle or the default translations directory. It can display them or merge diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php index cec930da1c0da..d3cc460318e4b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php @@ -30,6 +30,7 @@ class WorkflowDumpCommand extends Command { protected static $defaultName = 'workflow:dump'; + protected static $defaultDescription = 'Dump a workflow'; /** * {@inheritdoc} @@ -43,7 +44,7 @@ protected function configure() new InputOption('label', 'l', InputOption::VALUE_REQUIRED, 'Labels a graph'), new InputOption('dump-format', null, InputOption::VALUE_REQUIRED, 'The dump format [dot|puml]', 'dot'), ]) - ->setDescription('Dump a workflow') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command dumps the graphical representation of a workflow in different formats diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/XliffLintCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/XliffLintCommand.php index 0b5bb061d66e2..046d1cb3edd96 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/XliffLintCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/XliffLintCommand.php @@ -25,6 +25,7 @@ class XliffLintCommand extends BaseLintCommand { protected static $defaultName = 'lint:xliff'; + protected static $defaultDescription = 'Lints an XLIFF file and outputs encountered errors'; public function __construct() { diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/YamlLintCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/YamlLintCommand.php index 1163ff1c28fb1..fbd74ff6061d7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/YamlLintCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/YamlLintCommand.php @@ -24,6 +24,7 @@ class YamlLintCommand extends BaseLintCommand { protected static $defaultName = 'lint:yaml'; + protected static $defaultDescription = 'Lints a YAML file and outputs encountered errors'; public function __construct() { diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php index ce4e66ca04541..68adbed1cc0b5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php @@ -136,7 +136,7 @@ protected function describeContainerAlias(Alias $alias, array $options = [], Con protected function describeEventDispatcherListeners(EventDispatcherInterface $eventDispatcher, array $options = []) { - $this->writeData($this->getEventDispatcherListenersData($eventDispatcher, \array_key_exists('event', $options) ? $options['event'] : null), $options); + $this->writeData($this->getEventDispatcherListenersData($eventDispatcher, $options), $options); } protected function describeCallable($callable, array $options = []) @@ -275,18 +275,19 @@ private function getContainerAliasData(Alias $alias): array ]; } - private function getEventDispatcherListenersData(EventDispatcherInterface $eventDispatcher, string $event = null): array + private function getEventDispatcherListenersData(EventDispatcherInterface $eventDispatcher, array $options): array { $data = []; + $event = \array_key_exists('event', $options) ? $options['event'] : null; - $registeredListeners = $eventDispatcher->getListeners($event); if (null !== $event) { - foreach ($registeredListeners as $listener) { + foreach ($eventDispatcher->getListeners($event) as $listener) { $l = $this->getCallableData($listener); $l['priority'] = $eventDispatcher->getListenerPriority($event, $listener); $data[] = $l; } } else { + $registeredListeners = \array_key_exists('events', $options) ? array_combine($options['events'], array_map(function ($event) use ($eventDispatcher) { return $eventDispatcher->getListeners($event); }, $options['events'])) : $eventDispatcher->getListeners(); ksort($registeredListeners); foreach ($registeredListeners as $eventListened => $eventListeners) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php index 96170d32ad1b6..250e29c83cffe 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php @@ -287,15 +287,24 @@ protected function describeContainerEnvVars(array $envs, array $options = []) protected function describeEventDispatcherListeners(EventDispatcherInterface $eventDispatcher, array $options = []) { $event = \array_key_exists('event', $options) ? $options['event'] : null; + $dispatcherServiceName = $options['dispatcher_service_name'] ?? null; $title = 'Registered listeners'; + + if (null !== $dispatcherServiceName) { + $title .= sprintf(' of event dispatcher "%s"', $dispatcherServiceName); + } + if (null !== $event) { $title .= sprintf(' for event `%s` ordered by descending priority', $event); + $registeredListeners = $eventDispatcher->getListeners($event); + } else { + // Try to see if "events" exists + $registeredListeners = \array_key_exists('events', $options) ? array_combine($options['events'], array_map(function ($event) use ($eventDispatcher) { return $eventDispatcher->getListeners($event); }, $options['events'])) : $eventDispatcher->getListeners(); } $this->write(sprintf('# %s', $title)."\n"); - $registeredListeners = $eventDispatcher->getListeners($event); if (null !== $event) { foreach ($registeredListeners as $order => $listener) { $this->write("\n".sprintf('## Listener %d', $order + 1)."\n"); diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php index 33566f7f3eb74..32bb20f51954e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php @@ -477,16 +477,24 @@ protected function describeContainerEnvVars(array $envs, array $options = []) protected function describeEventDispatcherListeners(EventDispatcherInterface $eventDispatcher, array $options = []) { $event = \array_key_exists('event', $options) ? $options['event'] : null; + $dispatcherServiceName = $options['dispatcher_service_name'] ?? null; + + $title = 'Registered Listeners'; + + if (null !== $dispatcherServiceName) { + $title .= sprintf(' of Event Dispatcher "%s"', $dispatcherServiceName); + } if (null !== $event) { - $title = sprintf('Registered Listeners for "%s" Event', $event); + $title .= sprintf(' for "%s" Event', $event); + $registeredListeners = $eventDispatcher->getListeners($event); } else { - $title = 'Registered Listeners Grouped by Event'; + $title .= ' Grouped by Event'; + // Try to see if "events" exists + $registeredListeners = \array_key_exists('events', $options) ? array_combine($options['events'], array_map(function ($event) use ($eventDispatcher) { return $eventDispatcher->getListeners($event); }, $options['events'])) : $eventDispatcher->getListeners(); } $options['output']->title($title); - - $registeredListeners = $eventDispatcher->getListeners($event); if (null !== $event) { $this->renderEventListenerTable($eventDispatcher, $event, $registeredListeners, $options['output']); } else { diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php index 38780ec1359a6..25a00ea1bce3e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php @@ -90,7 +90,7 @@ protected function describeContainerAlias(Alias $alias, array $options = [], Con protected function describeEventDispatcherListeners(EventDispatcherInterface $eventDispatcher, array $options = []) { - $this->writeDocument($this->getEventDispatcherListenersDocument($eventDispatcher, \array_key_exists('event', $options) ? $options['event'] : null)); + $this->writeDocument($this->getEventDispatcherListenersDocument($eventDispatcher, $options)); } protected function describeCallable($callable, array $options = []) @@ -458,15 +458,18 @@ private function getContainerParameterDocument($parameter, array $options = []): return $dom; } - private function getEventDispatcherListenersDocument(EventDispatcherInterface $eventDispatcher, string $event = null): \DOMDocument + private function getEventDispatcherListenersDocument(EventDispatcherInterface $eventDispatcher, array $options): \DOMDocument { + $event = \array_key_exists('event', $options) ? $options['event'] : null; $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->appendChild($eventDispatcherXML = $dom->createElement('event-dispatcher')); - $registeredListeners = $eventDispatcher->getListeners($event); if (null !== $event) { + $registeredListeners = $eventDispatcher->getListeners($event); $this->appendEventListenerDocument($eventDispatcher, $event, $eventDispatcherXML, $registeredListeners); } else { + // Try to see if "events" exists + $registeredListeners = \array_key_exists('events', $options) ? array_combine($options['events'], array_map(function ($event) use ($eventDispatcher) { return $eventDispatcher->getListeners($event); }, $options['events'])) : $eventDispatcher->getListeners(); ksort($registeredListeners); foreach ($registeredListeners as $eventListened => $eventListeners) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index 61049b607a288..697725344c070 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -17,10 +17,12 @@ use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Form; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -199,11 +201,11 @@ protected function file($file, string $fileName = null, string $disposition = Re */ protected function addFlash(string $type, $message): void { - if (!$this->container->has('session')) { - throw new \LogicException('You can not use the addFlash method if sessions are disabled. Enable them in "config/packages/framework.yaml".'); + try { + $this->container->get('request_stack')->getSession()->getFlashBag()->add($type, $message); + } catch (SessionNotFoundException $e) { + throw new \LogicException('You can not use the addFlash method if sessions are disabled. Enable them in "config/packages/framework.yaml".', 0, $e); } - - $this->container->get('session')->getFlashBag()->add($type, $message); } /** @@ -289,6 +291,22 @@ protected function stream(string $view, array $parameters = [], StreamedResponse return $response; } + /** + * Renders a form. + * + * The FormView instance is passed to the template in a variable named "form". + * If the form is invalid, a 422 status code is returned. + */ + public function renderForm(string $view, FormInterface $form, array $parameters = [], Response $response = null): Response + { + $response = $this->render($view, ['form' => $form->createView()] + $parameters, $response); + if ($form->isSubmitted() && !$form->isValid()) { + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $response; + } + /** * Returns a NotFoundHttpException. * diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/SessionPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/SessionPass.php index 0f4950615fbce..c0c9dd6758999 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/SessionPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/SessionPass.php @@ -22,21 +22,41 @@ class SessionPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { - if (!$container->hasDefinition('session')) { + if (!$container->has('session.factory')) { return; } + // BC layer: Make "session" an alias of ".session.do-not-use" when not overriden by the user + if (!$container->has('session')) { + $alias = $container->setAlias('session', '.session.do-not-use'); + $alias->setDeprecated('symfony/framework-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "$requestStack->getSession()" instead.'); + + return; + } + + if ($container->hasDefinition('session')) { + $definition = $container->getDefinition('session'); + $definition->setDeprecated('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, use "$requestStack->getSession()" instead.'); + } else { + $alias = $container->getAlias('session'); + $alias->setDeprecated('symfony/framework-bundle', '5.3', 'The "%alias_id%" alias is deprecated, use "$requestStack->getSession()" instead.'); + $definition = $container->findDefinition('session'); + } + + // Convert internal service `.session.do-not-use` into alias of `session`. + $container->setAlias('.session.do-not-use', 'session'); + $bags = [ 'session.flash_bag' => $container->hasDefinition('session.flash_bag') ? $container->getDefinition('session.flash_bag') : null, 'session.attribute_bag' => $container->hasDefinition('session.attribute_bag') ? $container->getDefinition('session.attribute_bag') : null, ]; - foreach ($container->getDefinition('session')->getArguments() as $v) { + foreach ($definition->getArguments() as $v) { if (!$v instanceof Reference || !isset($bags[$bag = (string) $v]) || !\is_array($factory = $bags[$bag]->getFactory())) { continue; } - if ([0, 1] !== array_keys($factory) || !$factory[0] instanceof Reference || 'session' !== (string) $factory[0]) { + if ([0, 1] !== array_keys($factory) || !$factory[0] instanceof Reference || !\in_array((string) $factory[0], ['session', '.session.do-not-use'], true)) { continue; } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 37f545468d813..ded8b90028609 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -23,6 +23,7 @@ class UnusedTagsPass implements CompilerPassInterface { private $knownTags = [ 'annotations.cached_reader', + 'assets.package', 'auto_alias', 'cache.pool', 'cache.pool.clearer', @@ -43,6 +44,7 @@ class UnusedTagsPass implements CompilerPassInterface 'controller.argument_value_resolver', 'controller.service_arguments', 'data_collector', + 'event_dispatcher.dispatcher', 'form.type', 'form.type_extension', 'form.type_guesser', @@ -72,9 +74,9 @@ class UnusedTagsPass implements CompilerPassInterface 'routing.expression_language_provider', 'routing.loader', 'routing.route_loader', + 'security.authenticator.login_linker', 'security.expression_language_provider', 'security.remember_me_aware', - 'security.authenticator.login_linker', 'security.voter', 'serializer.encoder', 'serializer.normalizer', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index a0efab6b5ba64..0fdb34cbb644f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\FrameworkBundle\DependencyInjection; use Doctrine\Common\Annotations\Annotation; +use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Cache\Cache; use Doctrine\DBAL\Connection; use Symfony\Bundle\FullStack; @@ -21,6 +22,7 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\Form\Form; use Symfony\Component\HttpClient\HttpClient; @@ -30,10 +32,12 @@ use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Notifier\Notifier; +use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\Translator; +use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Validator\Validation; use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow\WorkflowEvents; @@ -107,8 +111,19 @@ public function getConfigTreeBuilder() ->end() ; + $willBeAvailable = static function (string $package, string $class, string $parentPackage = null) { + $parentPackages = (array) $parentPackage; + $parentPackages[] = 'symfony/framework-bundle'; + + return ContainerBuilder::willBeAvailable($package, $class, $parentPackages); + }; + + $enableIfStandalone = static function (string $package, string $class) use ($willBeAvailable) { + return !class_exists(FullStack::class) && $willBeAvailable($package, $class) ? 'canBeDisabled' : 'canBeEnabled'; + }; + $this->addCsrfSection($rootNode); - $this->addFormSection($rootNode); + $this->addFormSection($rootNode, $enableIfStandalone); $this->addHttpCacheSection($rootNode); $this->addEsiSection($rootNode); $this->addSsiSection($rootNode); @@ -118,24 +133,25 @@ public function getConfigTreeBuilder() $this->addRouterSection($rootNode); $this->addSessionSection($rootNode); $this->addRequestSection($rootNode); - $this->addAssetsSection($rootNode); - $this->addTranslatorSection($rootNode); - $this->addValidationSection($rootNode); - $this->addAnnotationsSection($rootNode); - $this->addSerializerSection($rootNode); - $this->addPropertyAccessSection($rootNode); - $this->addPropertyInfoSection($rootNode); - $this->addCacheSection($rootNode); + $this->addAssetsSection($rootNode, $enableIfStandalone); + $this->addTranslatorSection($rootNode, $enableIfStandalone); + $this->addValidationSection($rootNode, $enableIfStandalone, $willBeAvailable); + $this->addAnnotationsSection($rootNode, $willBeAvailable); + $this->addSerializerSection($rootNode, $enableIfStandalone, $willBeAvailable); + $this->addPropertyAccessSection($rootNode, $willBeAvailable); + $this->addPropertyInfoSection($rootNode, $enableIfStandalone); + $this->addCacheSection($rootNode, $willBeAvailable); $this->addPhpErrorsSection($rootNode); - $this->addWebLinkSection($rootNode); - $this->addLockSection($rootNode); - $this->addMessengerSection($rootNode); + $this->addWebLinkSection($rootNode, $enableIfStandalone); + $this->addLockSection($rootNode, $enableIfStandalone); + $this->addMessengerSection($rootNode, $enableIfStandalone); $this->addRobotsIndexSection($rootNode); - $this->addHttpClientSection($rootNode); - $this->addMailerSection($rootNode); + $this->addHttpClientSection($rootNode, $enableIfStandalone); + $this->addMailerSection($rootNode, $enableIfStandalone); $this->addSecretsSection($rootNode); - $this->addNotifierSection($rootNode); - $this->addRateLimiterSection($rootNode); + $this->addNotifierSection($rootNode, $enableIfStandalone); + $this->addRateLimiterSection($rootNode, $enableIfStandalone); + $this->addUidSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -174,13 +190,13 @@ private function addCsrfSection(ArrayNodeDefinition $rootNode) ; } - private function addFormSection(ArrayNodeDefinition $rootNode) + private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('form') ->info('form configuration') - ->{!class_exists(FullStack::class) && class_exists(Form::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/form', Form::class)}() ->children() ->arrayNode('csrf_protection') ->treatFalseLike(['enabled' => false]) @@ -598,8 +614,15 @@ private function addSessionSection(ArrayNodeDefinition $rootNode) ->arrayNode('session') ->info('session configuration') ->canBeEnabled() + ->beforeNormalization() + ->ifTrue(function ($v) { + return \is_array($v) && isset($v['storage_id']) && isset($v['storage_factory_id']); + }) + ->thenInvalid('You cannot use both "storage_id" and "storage_factory_id" at the same time under "framework.session"') + ->end() ->children() ->scalarNode('storage_id')->defaultValue('session.storage.native')->end() + ->scalarNode('storage_factory_id')->defaultNull()->end() ->scalarNode('handler_id')->defaultValue('session.handler.native_file')->end() ->scalarNode('name') ->validate() @@ -666,13 +689,13 @@ private function addRequestSection(ArrayNodeDefinition $rootNode) ; } - private function addAssetsSection(ArrayNodeDefinition $rootNode) + private function addAssetsSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('assets') ->info('assets configuration') - ->{!class_exists(FullStack::class) && class_exists(Package::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/asset', Package::class)}() ->fixXmlConfig('base_url') ->children() ->scalarNode('version_strategy')->defaultNull()->end() @@ -754,13 +777,13 @@ private function addAssetsSection(ArrayNodeDefinition $rootNode) ; } - private function addTranslatorSection(ArrayNodeDefinition $rootNode) + private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('translator') ->info('translator configuration') - ->{!class_exists(FullStack::class) && class_exists(Translator::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/translation', Translator::class)}() ->fixXmlConfig('fallback') ->fixXmlConfig('path') ->fixXmlConfig('enabled_locale') @@ -807,16 +830,16 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode) ; } - private function addValidationSection(ArrayNodeDefinition $rootNode) + private function addValidationSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone, callable $willBeAvailable) { $rootNode ->children() ->arrayNode('validation') ->info('validation configuration') - ->{!class_exists(FullStack::class) && class_exists(Validation::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/validator', Validation::class)}() ->children() ->scalarNode('cache')->end() - ->booleanNode('enable_annotations')->{!class_exists(FullStack::class) && class_exists(Annotation::class) ? 'defaultTrue' : 'defaultFalse'}()->end() + ->booleanNode('enable_annotations')->{!class_exists(FullStack::class) && $willBeAvailable('doctrine/annotations', Annotation::class, 'symfony/validator') ? 'defaultTrue' : 'defaultFalse'}()->end() ->arrayNode('static_method') ->defaultValue(['loadValidatorMetadata']) ->prototype('scalar')->end() @@ -897,15 +920,18 @@ private function addValidationSection(ArrayNodeDefinition $rootNode) ; } - private function addAnnotationsSection(ArrayNodeDefinition $rootNode) + private function addAnnotationsSection(ArrayNodeDefinition $rootNode, callable $willBeAvailable) { + $doctrineCache = $willBeAvailable('doctrine/cache', Cache::class, 'doctrine/annotation'); + $psr6Cache = $willBeAvailable('symfony/cache', PsrCachedReader::class, 'doctrine/annotation'); + $rootNode ->children() ->arrayNode('annotations') ->info('annotation configuration') - ->{class_exists(Annotation::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$willBeAvailable('doctrine/annotations', Annotation::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->children() - ->scalarNode('cache')->defaultValue(interface_exists(Cache::class) ? 'php_array' : 'none')->end() + ->scalarNode('cache')->defaultValue(($doctrineCache || $psr6Cache) ? 'php_array' : 'none')->end() ->scalarNode('file_cache_dir')->defaultValue('%kernel.cache_dir%/annotations')->end() ->booleanNode('debug')->defaultValue($this->debug)->end() ->end() @@ -914,15 +940,15 @@ private function addAnnotationsSection(ArrayNodeDefinition $rootNode) ; } - private function addSerializerSection(ArrayNodeDefinition $rootNode) + private function addSerializerSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone, $willBeAvailable) { $rootNode ->children() ->arrayNode('serializer') ->info('serializer configuration') - ->{!class_exists(FullStack::class) && class_exists(Serializer::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/serializer', Serializer::class)}() ->children() - ->booleanNode('enable_annotations')->{!class_exists(FullStack::class) && class_exists(Annotation::class) ? 'defaultTrue' : 'defaultFalse'}()->end() + ->booleanNode('enable_annotations')->{!class_exists(FullStack::class) && $willBeAvailable('doctrine/annotations', Annotation::class, 'symfony/serializer') ? 'defaultTrue' : 'defaultFalse'}()->end() ->scalarNode('name_converter')->end() ->scalarNode('circular_reference_handler')->end() ->scalarNode('max_depth_handler')->end() @@ -941,13 +967,14 @@ private function addSerializerSection(ArrayNodeDefinition $rootNode) ; } - private function addPropertyAccessSection(ArrayNodeDefinition $rootNode) + private function addPropertyAccessSection(ArrayNodeDefinition $rootNode, callable $willBeAvailable) { $rootNode ->children() ->arrayNode('property_access') ->addDefaultsIfNotSet() ->info('Property access configuration') + ->{$willBeAvailable('symfony/property-access', PropertyAccessor::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->children() ->booleanNode('magic_call')->defaultFalse()->end() ->booleanNode('magic_get')->defaultTrue()->end() @@ -960,19 +987,19 @@ private function addPropertyAccessSection(ArrayNodeDefinition $rootNode) ; } - private function addPropertyInfoSection(ArrayNodeDefinition $rootNode) + private function addPropertyInfoSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('property_info') ->info('Property info configuration') - ->{!class_exists(FullStack::class) && interface_exists(PropertyInfoExtractorInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/property-info', PropertyInfoExtractorInterface::class)}() ->end() ->end() ; } - private function addCacheSection(ArrayNodeDefinition $rootNode) + private function addCacheSection(ArrayNodeDefinition $rootNode, callable $willBeAvailable) { $rootNode ->children() @@ -999,7 +1026,7 @@ private function addCacheSection(ArrayNodeDefinition $rootNode) ->scalarNode('default_psr6_provider')->end() ->scalarNode('default_redis_provider')->defaultValue('redis://localhost')->end() ->scalarNode('default_memcached_provider')->defaultValue('memcached://localhost')->end() - ->scalarNode('default_pdo_provider')->defaultValue(class_exists(Connection::class) ? 'database_connection' : null)->end() + ->scalarNode('default_pdo_provider')->defaultValue($willBeAvailable('doctrine/dbal', Connection::class) ? 'database_connection' : null)->end() ->arrayNode('pools') ->useAttributeAsKey('name') ->prototype('array') @@ -1070,14 +1097,31 @@ private function addPhpErrorsSection(ArrayNodeDefinition $rootNode) ->info('PHP errors handling configuration') ->addDefaultsIfNotSet() ->children() - ->scalarNode('log') + ->variableNode('log') ->info('Use the application logger instead of the PHP logger for logging PHP errors.') - ->example('"true" to use the default configuration: log all errors. "false" to disable. An integer bit field of E_* constants.') + ->example('"true" to use the default configuration: log all errors. "false" to disable. An integer bit field of E_* constants, or an array mapping E_* constants to log levels.') ->defaultValue($this->debug) ->treatNullLike($this->debug) + ->beforeNormalization() + ->ifArray() + ->then(function (array $v): array { + if (!($v[0]['type'] ?? false)) { + return $v; + } + + // Fix XML normalization + + $ret = []; + foreach ($v as ['type' => $type, 'logLevel' => $logLevel]) { + $ret[$type] = $logLevel; + } + + return $ret; + }) + ->end() ->validate() - ->ifTrue(function ($v) { return !(\is_int($v) || \is_bool($v)); }) - ->thenInvalid('The "php_errors.log" parameter should be either an integer or a boolean.') + ->ifTrue(function ($v) { return !(\is_int($v) || \is_bool($v) || \is_array($v)); }) + ->thenInvalid('The "php_errors.log" parameter should be either an integer, a boolean, or an array') ->end() ->end() ->booleanNode('throw') @@ -1091,13 +1135,13 @@ private function addPhpErrorsSection(ArrayNodeDefinition $rootNode) ; } - private function addLockSection(ArrayNodeDefinition $rootNode) + private function addLockSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('lock') ->info('Lock configuration') - ->{!class_exists(FullStack::class) && class_exists(Lock::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/lock', Lock::class)}() ->beforeNormalization() ->ifString()->then(function ($v) { return ['enabled' => true, 'resources' => $v]; }) ->end() @@ -1153,25 +1197,25 @@ private function addLockSection(ArrayNodeDefinition $rootNode) ; } - private function addWebLinkSection(ArrayNodeDefinition $rootNode) + private function addWebLinkSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('web_link') ->info('web links configuration') - ->{!class_exists(FullStack::class) && class_exists(HttpHeaderSerializer::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/weblink', HttpHeaderSerializer::class)}() ->end() ->end() ; } - private function addMessengerSection(ArrayNodeDefinition $rootNode) + private function addMessengerSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('messenger') ->info('Messenger configuration') - ->{!class_exists(FullStack::class) && interface_exists(MessageBusInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/messenger', MessageBusInterface::class)}() ->fixXmlConfig('transport') ->fixXmlConfig('bus', 'buses') ->validate() @@ -1366,13 +1410,13 @@ private function addRobotsIndexSection(ArrayNodeDefinition $rootNode) ; } - private function addHttpClientSection(ArrayNodeDefinition $rootNode) + private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('http_client') ->info('HTTP Client configuration') - ->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/http-client', HttpClient::class)}() ->fixXmlConfig('scoped_client') ->beforeNormalization() ->always(function ($config) { @@ -1702,13 +1746,13 @@ private function addHttpClientRetrySection() ; } - private function addMailerSection(ArrayNodeDefinition $rootNode) + private function addMailerSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('mailer') ->info('Mailer configuration') - ->{!class_exists(FullStack::class) && class_exists(Mailer::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/mailer', Mailer::class)}() ->validate() ->ifTrue(function ($v) { return isset($v['dsn']) && \count($v['transports']); }) ->thenInvalid('"dsn" and "transports" cannot be used together.') @@ -1758,13 +1802,13 @@ private function addMailerSection(ArrayNodeDefinition $rootNode) ; } - private function addNotifierSection(ArrayNodeDefinition $rootNode) + private function addNotifierSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('notifier') ->info('Notifier configuration') - ->{!class_exists(FullStack::class) && class_exists(Notifier::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/notifier', Notifier::class)}() ->fixXmlConfig('chatter_transport') ->children() ->arrayNode('chatter_transports') @@ -1807,13 +1851,13 @@ private function addNotifierSection(ArrayNodeDefinition $rootNode) ; } - private function addRateLimiterSection(ArrayNodeDefinition $rootNode) + private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode ->children() ->arrayNode('rate_limiter') ->info('Rate limiter configuration') - ->{!class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{$enableIfStandalone('symfony/rate-limiter', TokenBucketLimiter::class)}() ->fixXmlConfig('limiter') ->beforeNormalization() ->ifTrue(function ($v) { return \is_array($v) && !isset($v['limiters']) && !isset($v['limiter']); }) @@ -1834,7 +1878,7 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode) ->arrayPrototype() ->children() ->scalarNode('lock_factory') - ->info('The service ID of the lock factory used by this limiter') + ->info('The service ID of the lock factory used by this limiter (or null to disable locking)') ->defaultValue('lock.factory') ->end() ->scalarNode('cache_pool') @@ -1874,4 +1918,37 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode) ->end() ; } + + private function addUidSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) + { + $rootNode + ->children() + ->arrayNode('uid') + ->info('Uid configuration') + ->{$enableIfStandalone('symfony/uid', UuidFactory::class)}() + ->addDefaultsIfNotSet() + ->children() + ->enumNode('default_uuid_version') + ->defaultValue(6) + ->values([6, 4, 1]) + ->end() + ->enumNode('name_based_uuid_version') + ->defaultValue(5) + ->values([5, 3]) + ->end() + ->scalarNode('name_based_uuid_namespace') + ->cannotBeEmpty() + ->end() + ->enumNode('time_based_uuid_version') + ->defaultValue(6) + ->values([6, 1]) + ->end() + ->scalarNode('time_based_uuid_node') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c6cd60cc32fb1..3ef5e51939e9c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -14,6 +14,7 @@ use Doctrine\Common\Annotations\AnnotationRegistry; use Doctrine\Common\Annotations\Reader; use Http\Client\HttpClient; +use phpDocumentor\Reflection\DocBlockFactoryInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface as PsrEventDispatcherInterface; @@ -43,6 +44,8 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -56,10 +59,12 @@ use Symfony\Component\DependencyInjection\Parameter; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\EventDispatcher\Attribute\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Finder\Finder; use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator; +use Symfony\Component\Form\Form; use Symfony\Component\Form\FormTypeExtensionInterface; use Symfony\Component\Form\FormTypeGuesserInterface; use Symfony\Component\Form\FormTypeInterface; @@ -68,6 +73,7 @@ use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; @@ -95,28 +101,37 @@ use Symfony\Component\Messenger\Handler\MessageHandlerInterface; use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Middleware\RouterContextMiddleware; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\TransportFactoryInterface; use Symfony\Component\Messenger\Transport\TransportInterface; use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\MimeTypeGuesserInterface; use Symfony\Component\Mime\MimeTypes; +use Symfony\Component\Notifier\Bridge\AllMySms\AllMySmsTransportFactory; +use Symfony\Component\Notifier\Bridge\Clickatell\ClickatellTransportFactory; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory; use Symfony\Component\Notifier\Bridge\Esendex\EsendexTransportFactory; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; use Symfony\Component\Notifier\Bridge\FreeMobile\FreeMobileTransportFactory; +use Symfony\Component\Notifier\Bridge\GatewayApi\GatewayApiTransportFactory; +use Symfony\Component\Notifier\Bridge\Gitter\GitterTransportFactory; use Symfony\Component\Notifier\Bridge\GoogleChat\GoogleChatTransportFactory; use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; +use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; +use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; use Symfony\Component\Notifier\Bridge\OvhCloud\OvhCloudTransportFactory; use Symfony\Component\Notifier\Bridge\RocketChat\RocketChatTransportFactory; use Symfony\Component\Notifier\Bridge\Sendinblue\SendinblueTransportFactory as SendinblueNotifierTransportFactory; use Symfony\Component\Notifier\Bridge\Sinch\SinchTransportFactory; use Symfony\Component\Notifier\Bridge\Slack\SlackTransportFactory; use Symfony\Component\Notifier\Bridge\Smsapi\SmsapiTransportFactory; +use Symfony\Component\Notifier\Bridge\SpotHit\SpotHitTransportFactory; use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransportFactory; use Symfony\Component\Notifier\Bridge\Twilio\TwilioTransportFactory; use Symfony\Component\Notifier\Bridge\Zulip\ZulipTransportFactory; @@ -136,6 +151,7 @@ use Symfony\Component\RateLimiter\Storage\CacheStorage; use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader; use Symfony\Component\Routing\Loader\AnnotationFileLoader; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Serializer\Encoder\DecoderInterface; @@ -149,9 +165,12 @@ use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\UuidV4; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\ObjectInitializerInterface; +use Symfony\Component\Validator\Validation; use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow; use Symfony\Component\Workflow\WorkflowInterface; @@ -160,6 +179,7 @@ use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CallbackInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -184,7 +204,8 @@ class FrameworkExtension extends Extension private $mailerConfigEnabled = false; private $httpClientConfigEnabled = false; private $notifierConfigEnabled = false; - private $lockConfigEnabled = false; + private $propertyAccessConfigEnabled = false; + private static $lockConfigEnabled = false; /** * Responds to the app.config configuration parameter. @@ -200,7 +221,7 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('fragment_renderer.php'); $loader->load('error_renderer.php'); - if (interface_exists(PsrEventDispatcherInterface::class)) { + if (ContainerBuilder::willBeAvailable('psr/event-dispatcher', PsrEventDispatcherInterface::class, ['symfony/framework-bundle'])) { $container->setAlias(PsrEventDispatcherInterface::class, 'event_dispatcher'); } @@ -240,11 +261,11 @@ public function load(array $configs, ContainerBuilder $container) } // If the slugger is used but the String component is not available, we should throw an error - if (!interface_exists(SluggerInterface::class)) { + if (!ContainerBuilder::willBeAvailable('symfony/string', SluggerInterface::class, ['symfony/framework-bundle'])) { $container->register('slugger', 'stdClass') ->addError('You cannot use the "slugger" service since the String component is not installed. Try running "composer require symfony/string".'); } else { - if (!interface_exists(LocaleAwareInterface::class)) { + if (!ContainerBuilder::willBeAvailable('symfony/translation', LocaleAwareInterface::class, ['symfony/framework-bundle'])) { $container->register('slugger', 'stdClass') ->addError('You cannot use the "slugger" service since the Translation contracts are not installed. Try running "composer require symfony/translation".'); } @@ -313,19 +334,19 @@ public function load(array $configs, ContainerBuilder $container) } if (null === $config['csrf_protection']['enabled']) { - $config['csrf_protection']['enabled'] = $this->sessionConfigEnabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class); + $config['csrf_protection']['enabled'] = $this->sessionConfigEnabled && !class_exists(FullStack::class) && ContainerBuilder::willBeAvailable('symfony/security-csrf', CsrfTokenManagerInterface::class, ['symfony/framework-bundle']); } $this->registerSecurityCsrfConfiguration($config['csrf_protection'], $container, $loader); if ($this->isConfigEnabled($container, $config['form'])) { - if (!class_exists(\Symfony\Component\Form\Form::class)) { + if (!class_exists(Form::class)) { throw new LogicException('Form support cannot be enabled as the Form component is not installed. Try running "composer require symfony/form".'); } $this->formConfigEnabled = true; $this->registerFormConfiguration($config, $container, $loader); - if (class_exists(\Symfony\Component\Validator\Validation::class)) { + if (ContainerBuilder::willBeAvailable('symfony/validator', Validation::class, ['symfony/framework-bundle', 'symfony/form'])) { $config['validation']['enabled'] = true; } else { $container->setParameter('validator.translation_domain', 'validators'); @@ -417,7 +438,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerPropertyInfoConfiguration($container, $loader); } - if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) { + if (self::$lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) { $this->registerLockConfiguration($config['lock'], $container, $loader); } @@ -437,6 +458,14 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('web_link.php'); } + if ($this->isConfigEnabled($container, $config['uid'])) { + if (!class_exists(UuidFactory::class)) { + throw new LogicException('Uid support cannot be enabled as the Uid component is not installed. Try running "composer require symfony/uid".'); + } + + $this->registerUidConfiguration($config['uid'], $container, $loader); + } + $this->addAnnotatedClassesToCompile([ '**\\Controller\\', '**\\Entity\\', @@ -445,10 +474,12 @@ public function load(array $configs, ContainerBuilder $container) 'Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController', ]); - if (class_exists(MimeTypes::class)) { + if (ContainerBuilder::willBeAvailable('symfony/mime', MimeTypes::class, ['symfony/framework-bundle'])) { $loader->load('mime_type.php'); } + $container->registerForAutoconfiguration(PackageInterface::class) + ->addTag('assets.package'); $container->registerForAutoconfiguration(Command::class) ->addTag('console.command'); $container->registerForAutoconfiguration(ResourceCheckerInterface::class) @@ -479,6 +510,8 @@ public function load(array $configs, ContainerBuilder $container) ->addTag('kernel.cache_clearer'); $container->registerForAutoconfiguration(CacheWarmerInterface::class) ->addTag('kernel.cache_warmer'); + $container->registerForAutoconfiguration(EventDispatcherInterface::class) + ->addTag('event_dispatcher.dispatcher'); $container->registerForAutoconfiguration(EventSubscriberInterface::class) ->addTag('kernel.event_subscriber'); $container->registerForAutoconfiguration(LocaleAwareInterface::class) @@ -522,6 +555,10 @@ public function load(array $configs, ContainerBuilder $container) $container->registerForAutoconfiguration(LoggerAwareInterface::class) ->addMethodCall('setLogger', [new Reference('logger')]); + $container->registerAttributeForAutoconfiguration(EventListener::class, static function (ChildDefinition $definition, EventListener $attribute): void { + $definition->addTag('kernel.event_listener', get_object_vars($attribute)); + }); + if (!$container->getParameter('kernel.debug')) { // remove tagged iterator argument for resource checkers $container->getDefinition('config_cache_factory')->setArguments([]); @@ -570,7 +607,7 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont $container->setParameter('form.type_extension.csrf.enabled', false); } - if (!class_exists(Translator::class)) { + if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) { $container->removeDefinition('form.type_extension.upload.validator'); } if (!method_exists(CachingFactoryDecorator::class, 'reset')) { @@ -931,9 +968,13 @@ private function registerRouterConfiguration(array $config, ContainerBuilder $co if (!$this->isConfigEnabled($container, $config)) { $container->removeDefinition('console.command.router_debug'); $container->removeDefinition('console.command.router_match'); + $container->removeDefinition('messenger.middleware.router_context'); return; } + if (!class_exists(RouterContextMiddleware::class)) { + $container->removeDefinition('messenger.middleware.router_context'); + } $loader->load('routing.php'); @@ -950,7 +991,7 @@ private function registerRouterConfiguration(array $config, ContainerBuilder $co $container->getDefinition('routing.loader')->replaceArgument(2, ['_locale' => $enabledLocales]); } - if (!class_exists(ExpressionLanguage::class)) { + if (!ContainerBuilder::willBeAvailable('symfony/expression-language', ExpressionLanguage::class, ['symfony/framework-bundle', 'symfony/routing'])) { $container->removeDefinition('router.expression_language_provider'); } @@ -975,7 +1016,10 @@ private function registerRouterConfiguration(array $config, ContainerBuilder $co $container->register('routing.loader.annotation', AnnotatedRouteControllerLoader::class) ->setPublic(false) ->addTag('routing.loader', ['priority' => -10]) - ->addArgument(new Reference('annotation_reader', ContainerInterface::NULL_ON_INVALID_REFERENCE)); + ->setArguments([ + new Reference('annotation_reader', ContainerInterface::NULL_ON_INVALID_REFERENCE), + '%kernel.environment%', + ]); $container->register('routing.loader.annotation.directory', AnnotationDirectoryLoader::class) ->setPublic(false) @@ -1000,7 +1044,21 @@ private function registerSessionConfiguration(array $config, ContainerBuilder $c $loader->load('session.php'); // session storage - $container->setAlias('session.storage', $config['storage_id']); + if (null === $config['storage_factory_id']) { + trigger_deprecation('symfony/framework-bundle', '5.3', 'Not setting the "framework.session.storage_factory_id" configuration option is deprecated, it will default to "session.storage.factory.native" and will replace the "framework.session.storage_id" configuration option in version 6.0.'); + $container->setAlias('session.storage', $config['storage_id']); + $container->setAlias('session.storage.factory', 'session.storage.factory.service'); + } else { + $container->setAlias('session.storage.factory', $config['storage_factory_id']); + + $container->removeAlias(SessionStorageInterface::class); + $container->removeDefinition('session.storage.metadata_bag'); + $container->removeDefinition('session.storage.native'); + $container->removeDefinition('session.storage.php_bridge'); + $container->removeDefinition('session.storage.mock_file'); + $container->removeAlias('session.storage.filesystem'); + } + $options = ['cache_limiter' => '0']; foreach (['name', 'cookie_lifetime', 'cookie_path', 'cookie_domain', 'cookie_secure', 'cookie_httponly', 'cookie_samesite', 'use_cookies', 'gc_maxlifetime', 'gc_probability', 'gc_divisor', 'sid_length', 'sid_bits_per_character'] as $key) { if (isset($config[$key])) { @@ -1009,11 +1067,16 @@ private function registerSessionConfiguration(array $config, ContainerBuilder $c } if ('auto' === ($options['cookie_secure'] ?? null)) { - $locator = $container->getDefinition('session_listener')->getArgument(0); - $locator->setValues($locator->getValues() + [ - 'session_storage' => new Reference('session.storage', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), - 'request_stack' => new Reference('request_stack'), - ]); + if (null === $config['storage_factory_id']) { + $locator = $container->getDefinition('session_listener')->getArgument(0); + $locator->setValues($locator->getValues() + [ + 'session_storage' => new Reference('session.storage', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + 'request_stack' => new Reference('request_stack'), + ]); + } else { + $container->getDefinition('session.storage.factory.native')->replaceArgument(3, true); + $container->getDefinition('session.storage.factory.php_bridge')->replaceArgument(2, true); + } } $container->setParameter('session.storage.options', $options); @@ -1021,8 +1084,14 @@ private function registerSessionConfiguration(array $config, ContainerBuilder $c // session handler (the internal callback registered with PHP session management) if (null === $config['handler_id']) { // Set the handler class to be null - $container->getDefinition('session.storage.native')->replaceArgument(1, null); - $container->getDefinition('session.storage.php_bridge')->replaceArgument(0, null); + if ($container->hasDefinition('session.storage.native')) { + $container->getDefinition('session.storage.native')->replaceArgument(1, null); + $container->getDefinition('session.storage.php_bridge')->replaceArgument(0, null); + } else { + $container->getDefinition('session.storage.factory.native')->replaceArgument(1, null); + $container->getDefinition('session.storage.factory.php_bridge')->replaceArgument(0, null); + } + $container->setAlias('session.handler', 'session.handler.native_file'); } else { $container->resolveEnvPlaceholders($config['handler_id'], null, $usedEnvs); @@ -1067,7 +1136,6 @@ private function registerAssetsConfiguration(array $config, ContainerBuilder $co $defaultPackage = $this->createPackageDefinition($config['base_path'], $config['base_urls'], $defaultVersion); $container->setDefinition('assets._default_package', $defaultPackage); - $namedPackages = []; foreach ($config['packages'] as $name => $package) { if (null !== $package['version_strategy']) { $version = new Reference($package['version_strategy']); @@ -1081,15 +1149,11 @@ private function registerAssetsConfiguration(array $config, ContainerBuilder $co $version = $this->createVersion($container, $version, $format, $package['json_manifest_path'], $name); } - $container->setDefinition('assets._package_'.$name, $this->createPackageDefinition($package['base_path'], $package['base_urls'], $version)); + $packageDefinition = $this->createPackageDefinition($package['base_path'], $package['base_urls'], $version) + ->addTag('assets.package', ['package' => $name]); + $container->setDefinition('assets._package_'.$name, $packageDefinition); $container->registerAliasForArgument('assets._package_'.$name, PackageInterface::class, $name.'.package'); - $namedPackages[$name] = new Reference('assets._package_'.$name); } - - $container->getDefinition('assets.packages') - ->replaceArgument(0, new Reference('assets._default_package')) - ->replaceArgument(1, $namedPackages) - ; } /** @@ -1126,12 +1190,7 @@ private function createVersion(ContainerBuilder $container, ?string $version, ?s } if (null !== $jsonManifestPath) { - $definitionName = 'assets.json_manifest_version_strategy'; - if (0 === strpos(parse_url($jsonManifestPath, \PHP_URL_SCHEME), 'http')) { - $definitionName = 'assets.remote_json_manifest_version_strategy'; - } - - $def = new ChildDefinition($definitionName); + $def = new ChildDefinition('assets.json_manifest_version_strategy'); $def->replaceArgument(0, $jsonManifestPath); $container->setDefinition('assets._version_'.$name, $def); @@ -1171,18 +1230,18 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $dirs = []; $transPaths = []; $nonExistingDirs = []; - if (class_exists(\Symfony\Component\Validator\Validation::class)) { - $r = new \ReflectionClass(\Symfony\Component\Validator\Validation::class); + if (ContainerBuilder::willBeAvailable('symfony/validator', Validation::class, ['symfony/framework-bundle', 'symfony/translation'])) { + $r = new \ReflectionClass(Validation::class); $dirs[] = $transPaths[] = \dirname($r->getFileName()).'/Resources/translations'; } - if (class_exists(\Symfony\Component\Form\Form::class)) { - $r = new \ReflectionClass(\Symfony\Component\Form\Form::class); + if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/translation'])) { + $r = new \ReflectionClass(Form::class); $dirs[] = $transPaths[] = \dirname($r->getFileName()).'/Resources/translations'; } - if (class_exists(\Symfony\Component\Security\Core\Exception\AuthenticationException::class)) { - $r = new \ReflectionClass(\Symfony\Component\Security\Core\Exception\AuthenticationException::class); + if (ContainerBuilder::willBeAvailable('symfony/security-core', AuthenticationException::class, ['symfony/framework-bundle', 'symfony/translation'])) { + $r = new \ReflectionClass(AuthenticationException::class); $dirs[] = $transPaths[] = \dirname($r->getFileName(), 2).'/Resources/translations'; } @@ -1282,7 +1341,7 @@ private function registerValidationConfiguration(array $config, ContainerBuilder return; } - if (!class_exists(\Symfony\Component\Validator\Validation::class)) { + if (!class_exists(Validation::class)) { throw new LogicException('Validation support cannot be enabled as the Validator component is not installed. Try running "composer require symfony/validator".'); } @@ -1349,8 +1408,8 @@ private function registerValidatorMapping(ContainerBuilder $container, array $co $files['yaml' === $extension ? 'yml' : $extension][] = $path; }; - if (interface_exists(\Symfony\Component\Form\FormInterface::class)) { - $reflClass = new \ReflectionClass(\Symfony\Component\Form\FormInterface::class); + if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) { + $reflClass = new \ReflectionClass(Form::class); $fileRecorder('xml', \dirname($reflClass->getFileName()).'/Resources/config/validation.xml'); } @@ -1412,7 +1471,7 @@ private function registerAnnotationsConfiguration(array $config, ContainerBuilde } if (!class_exists(\Doctrine\Common\Annotations\Annotation::class)) { - throw new LogicException('Annotations cannot be enabled as the Doctrine Annotation library is not installed.'); + throw new LogicException('Annotations cannot be enabled as the Doctrine Annotation library is not installed. Try running "composer require doctrine/annotations".'); } $loader->load('annotations.php'); @@ -1422,13 +1481,50 @@ private function registerAnnotationsConfiguration(array $config, ContainerBuilde ->setMethodCalls([['registerLoader', ['class_exists']]]); } - if ('none' !== $config['cache']) { + if ('none' === $config['cache']) { + $container->removeDefinition('annotations.cached_reader'); + $container->removeDefinition('annotations.psr_cached_reader'); + + return; + } + + $cacheService = $config['cache']; + if (\in_array($config['cache'], ['php_array', 'file'])) { + $isPsr6Service = $container->hasDefinition('annotations.psr_cached_reader'); + } else { + $isPsr6Service = false; + trigger_deprecation('symfony/framework-bundle', '5.3', 'Using a custom service for "framework.annotation.cache" is deprecated, only values "none", "php_array" and "file" are valid in version 6.0.'); + } + + if ($isPsr6Service) { + $container->removeDefinition('annotations.cached_reader'); + $container->setDefinition('annotations.cached_reader', $container->getDefinition('annotations.psr_cached_reader')); + + if ('php_array' === $config['cache']) { + $cacheService = 'annotations.psr_cache'; + + // Enable warmer only if PHP array is used for cache + $definition = $container->findDefinition('annotations.cache_warmer'); + $definition->addTag('kernel.cache_warmer'); + } elseif ('file' === $config['cache']) { + $cacheService = 'annotations.filesystem_cache_adapter'; + $cacheDir = $container->getParameterBag()->resolveValue($config['file_cache_dir']); + + if (!is_dir($cacheDir) && false === @mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) { + throw new \RuntimeException(sprintf('Could not create cache directory "%s".', $cacheDir)); + } + + $container + ->getDefinition('annotations.filesystem_cache_adapter') + ->replaceArgument(2, $cacheDir) + ; + } + } else { + // Legacy code for doctrine/annotations:<1.13 if (!class_exists(\Doctrine\Common\Cache\CacheProvider::class)) { - throw new LogicException('Annotations cannot be enabled as the Doctrine Cache library is not installed.'); + throw new LogicException('Annotations cannot be cached as the Doctrine Cache library is not installed. Try running "composer require doctrine/cache".'); } - $cacheService = $config['cache']; - if ('php_array' === $config['cache']) { $cacheService = 'annotations.cache'; @@ -1449,25 +1545,24 @@ private function registerAnnotationsConfiguration(array $config, ContainerBuilde $cacheService = 'annotations.filesystem_cache'; } + } - $container - ->getDefinition('annotations.cached_reader') - ->replaceArgument(2, $config['debug']) - // temporary property to lazy-reference the cache provider without using it until AddAnnotationsCachedReaderPass runs - ->setProperty('cacheProviderBackup', new ServiceClosureArgument(new Reference($cacheService))) - ->addTag('annotations.cached_reader') - ; + $container + ->getDefinition('annotations.cached_reader') + ->replaceArgument(2, $config['debug']) + // temporary property to lazy-reference the cache provider without using it until AddAnnotationsCachedReaderPass runs + ->setProperty('cacheProviderBackup', new ServiceClosureArgument(new Reference($cacheService))) + ->addTag('annotations.cached_reader') + ; - $container->setAlias('annotation_reader', 'annotations.cached_reader'); - $container->setAlias(Reader::class, new Alias('annotations.cached_reader', false)); - } else { - $container->removeDefinition('annotations.cached_reader'); - } + $container->setAlias('annotation_reader', 'annotations.cached_reader'); + $container->setAlias(Reader::class, new Alias('annotations.cached_reader', false)); + $container->removeDefinition('annotations.psr_cached_reader'); } private function registerPropertyAccessConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) { - if (!class_exists(PropertyAccessor::class)) { + if (!$this->propertyAccessConfigEnabled = $this->isConfigEnabled($container, $config)) { return; } @@ -1516,7 +1611,7 @@ private function registerSecretsConfiguration(array $config, ContainerBuilder $c throw new InvalidArgumentException(sprintf('Invalid value "%s" set as "decryption_env_var": only "word" characters are allowed.', $config['decryption_env_var'])); } - if (class_exists(LazyString::class)) { + if (ContainerBuilder::willBeAvailable('symfony/string', LazyString::class, ['symfony/framework-bundle'])) { $container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']); } else { $container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%"); @@ -1556,7 +1651,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $chainLoader = $container->getDefinition('serializer.mapping.chain_loader'); - if (!class_exists(PropertyAccessor::class)) { + if (!$this->propertyAccessConfigEnabled) { $container->removeAlias('serializer.property_accessor'); $container->removeDefinition('serializer.normalizer.object'); } @@ -1565,7 +1660,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->removeDefinition('serializer.encoder.yaml'); } - if (!class_exists(UnwrappingDenormalizer::class) || !class_exists(PropertyAccessor::class)) { + if (!class_exists(UnwrappingDenormalizer::class) || !$this->propertyAccessConfigEnabled) { $container->removeDefinition('serializer.denormalizer.unwrapping'); } @@ -1649,7 +1744,7 @@ private function registerPropertyInfoConfiguration(ContainerBuilder $container, $loader->load('property_info.php'); - if (interface_exists(\phpDocumentor\Reflection\DocBlockFactoryInterface::class)) { + if (ContainerBuilder::willBeAvailable('phpdocumentor/reflection-docblock', DocBlockFactoryInterface::class, ['symfony/framework-bundle', 'symfony/property-info'])) { $definition = $container->register('property_info.php_doc_extractor', 'Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor'); $definition->addTag('property_info.description_extractor', ['priority' => -1000]); $definition->addTag('property_info.type_extractor', ['priority' => -1001]); @@ -1730,19 +1825,19 @@ private function registerMessengerConfiguration(array $config, ContainerBuilder $loader->load('messenger.php'); - if (class_exists(AmqpTransportFactory::class)) { + if (ContainerBuilder::willBeAvailable('symfony/amqp-messenger', AmqpTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { $container->getDefinition('messenger.transport.amqp.factory')->addTag('messenger.transport_factory'); } - if (class_exists(RedisTransportFactory::class)) { + if (ContainerBuilder::willBeAvailable('symfony/redis-messenger', RedisTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { $container->getDefinition('messenger.transport.redis.factory')->addTag('messenger.transport_factory'); } - if (class_exists(AmazonSqsTransportFactory::class)) { + if (ContainerBuilder::willBeAvailable('symfony/amazon-sqs-messenger', AmazonSqsTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { $container->getDefinition('messenger.transport.sqs.factory')->addTag('messenger.transport_factory'); } - if (class_exists(BeanstalkdTransportFactory::class)) { + if (ContainerBuilder::willBeAvailable('symfony/beanstalkd-messenger', BeanstalkdTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { $container->getDefinition('messenger.transport.beanstalkd.factory')->addTag('messenger.transport_factory'); } @@ -2016,12 +2111,12 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder unset($options['retry_failed']); $container->getDefinition('http_client')->setArguments([$options, $config['max_host_connections'] ?? 6]); - if (!$hasPsr18 = interface_exists(ClientInterface::class)) { + if (!$hasPsr18 = ContainerBuilder::willBeAvailable('psr/http-client', ClientInterface::class, ['symfony/framework-bundle', 'symfony/http-client'])) { $container->removeDefinition('psr18.http_client'); $container->removeAlias(ClientInterface::class); } - if (!interface_exists(HttpClient::class)) { + if (!ContainerBuilder::willBeAvailable('php-http/httplug', HttpClient::class, ['symfony/framework-bundle', 'symfony/http-client'])) { $container->removeDefinition(HttpClient::class); } @@ -2151,7 +2246,9 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co ]; foreach ($classToServices as $class => $service) { - if (!class_exists($class)) { + $package = substr($service, \strlen('mailer.transport_factory.')); + + if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-mailer', 'gmail' === $package ? 'google' : $package), $class, ['symfony/framework-bundle', 'symfony/mailer'])) { $container->removeDefinition($service); } } @@ -2224,11 +2321,14 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ MattermostTransportFactory::class => 'notifier.transport_factory.mattermost', GoogleChatTransportFactory::class => 'notifier.transport_factory.googlechat', NexmoTransportFactory::class => 'notifier.transport_factory.nexmo', + IqsmsTransportFactory::class => 'notifier.transport_factory.iqsms', RocketChatTransportFactory::class => 'notifier.transport_factory.rocketchat', InfobipTransportFactory::class => 'notifier.transport_factory.infobip', TwilioTransportFactory::class => 'notifier.transport_factory.twilio', + AllMySmsTransportFactory::class => 'notifier.transport_factory.allmysms', FirebaseTransportFactory::class => 'notifier.transport_factory.firebase', FreeMobileTransportFactory::class => 'notifier.transport_factory.freemobile', + SpotHitTransportFactory::class => 'notifier.transport_factory.spothit', OvhCloudTransportFactory::class => 'notifier.transport_factory.ovhcloud', SinchTransportFactory::class => 'notifier.transport_factory.sinch', ZulipTransportFactory::class => 'notifier.transport_factory.zulip', @@ -2238,14 +2338,36 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ SendinblueNotifierTransportFactory::class => 'notifier.transport_factory.sendinblue', DiscordTransportFactory::class => 'notifier.transport_factory.discord', LinkedInTransportFactory::class => 'notifier.transport_factory.linkedin', + GatewayApiTransportFactory::class => 'notifier.transport_factory.gatewayapi', + OctopushTransportFactory::class => 'notifier.transport_factory.octopush', + MercureTransportFactory::class => 'notifier.transport_factory.mercure', + GitterTransportFactory::class => 'notifier.transport_factory.gitter', + ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell', ]; + $parentPackages = ['symfony/framework-bundle', 'symfony/notifier']; + foreach ($classToServices as $class => $service) { - if (!class_exists($class)) { + switch ($package = substr($service, \strlen('notifier.transport_factory.'))) { + case 'freemobile': $package = 'free-mobile'; break; + case 'googlechat': $package = 'google-chat'; break; + case 'linkedin': $package = 'linked-in'; break; + case 'ovhcloud': $package = 'ovh-cloud'; break; + case 'rocketchat': $package = 'rocket-chat'; break; + } + + if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-notifier', $package), $class, $parentPackages)) { $container->removeDefinition($service); } } + if (ContainerBuilder::willBeAvailable('symfony/mercure-notifier', MercureTransportFactory::class, $parentPackages) && ContainerBuilder::willBeAvailable('symfony/mercure-bundle', MercureBundle::class, $parentPackages)) { + $container->getDefinition($classToServices[MercureTransportFactory::class]) + ->replaceArgument('$publisherLocator', new ServiceLocatorArgument(new TaggedIteratorArgument('mercure.publisher', null, null, true))); + } elseif (ContainerBuilder::willBeAvailable('symfony/mercure-notifier', MercureTransportFactory::class, $parentPackages)) { + $container->removeDefinition($classToServices[MercureTransportFactory::class]); + } + if (isset($config['admin_recipients'])) { $notifier = $container->getDefinition('notifier'); foreach ($config['admin_recipients'] as $i => $recipient) { @@ -2258,10 +2380,6 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) { - if (!$this->lockConfigEnabled) { - throw new LogicException('Rate limiter support cannot be enabled without enabling the Lock component.'); - } - $loader->load('rate_limiter.php'); foreach ($config['limiters'] as $name => $limiterConfig) { @@ -2276,7 +2394,13 @@ public static function registerRateLimiter(ContainerBuilder $container, string $ $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')); - $limiter->addArgument(new Reference($limiterConfig['lock_factory'])); + if (null !== $limiterConfig['lock_factory']) { + if (!self::$lockConfigEnabled) { + throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be installed and configured.', $name)); + } + + $limiter->replaceArgument(2, new Reference($limiterConfig['lock_factory'])); + } unset($limiterConfig['lock_factory']); $storageId = $limiterConfig['storage_service'] ?? null; @@ -2294,6 +2418,27 @@ public static function registerRateLimiter(ContainerBuilder $container, string $ $container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name.'.limiter'); } + private function registerUidConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) + { + $loader->load('uid.php'); + + $container->getDefinition('uuid.factory') + ->setArguments([ + $config['default_uuid_version'], + $config['time_based_uuid_version'], + $config['name_based_uuid_version'], + UuidV4::class, + $config['time_based_uuid_node'] ?? null, + $config['name_based_uuid_namespace'] ?? null, + ]) + ; + + if (isset($config['name_based_uuid_namespace'])) { + $container->getDefinition('name_based_uuid.factory') + ->setArguments([$config['name_based_uuid_namespace']]); + } + } + private function resolveTrustedHeaders(array $headers): int { $trustedHeaders = 0; diff --git a/src/Symfony/Bundle/FrameworkBundle/Kernel/MicroKernelTrait.php b/src/Symfony/Bundle/FrameworkBundle/Kernel/MicroKernelTrait.php index 52587cc7c756f..c77c452d99a1a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Kernel/MicroKernelTrait.php +++ b/src/Symfony/Bundle/FrameworkBundle/Kernel/MicroKernelTrait.php @@ -152,7 +152,7 @@ public function registerContainerConfiguration(LoaderInterface $loader) }; try { - $this->configureContainer(new ContainerConfigurator($container, $kernelLoader, $instanceof, $file, $file), $loader); + $this->configureContainer(new ContainerConfigurator($container, $kernelLoader, $instanceof, $file, $file, $this->getEnvironment()), $loader); } finally { $instanceof = []; $kernelLoader->registerAliasesForSinglyImplementedInterfaces(); @@ -193,7 +193,7 @@ public function loadRoutes(LoaderInterface $loader) return $routes->build(); } - $this->configureRoutes(new RoutingConfigurator($collection, $kernelLoader, $file, $file)); + $this->configureRoutes(new RoutingConfigurator($collection, $kernelLoader, $file, $file, $this->getEnvironment())); foreach ($collection as $route) { $controller = $route->getDefault('_controller'); diff --git a/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php b/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php index 40381e34aa310..cba7b829bb5cc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php +++ b/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php @@ -122,7 +122,7 @@ public function loginUser($user, string $firewallContext = 'main'): self $token = new TestBrowserToken($user->getRoles(), $user); $token->setAuthenticated(true); - $session = $this->getContainer()->get('session'); + $session = $this->getContainer()->get('test.service_container')->get('session.factory')->createSession(); $session->set('_security_'.$firewallContext, serialize($token)); $session->save(); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/annotations.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/annotations.php index 187d9da6642d0..a880b75a8b9c1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/annotations.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/annotations.php @@ -14,6 +14,7 @@ use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Annotations\AnnotationRegistry; use Doctrine\Common\Annotations\CachedReader; +use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Annotations\Reader; use Symfony\Bundle\FrameworkBundle\CacheWarmer\AnnotationsCacheWarmer; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -36,7 +37,7 @@ ->args([ service('annotations.reader'), inline_service(DoctrineProvider::class)->args([ - inline_service(ArrayAdapter::class) + inline_service(ArrayAdapter::class), ]), abstract_arg('Debug-Flag'), ]) @@ -74,4 +75,22 @@ ->alias('annotation_reader', 'annotations.reader') ->alias(Reader::class, 'annotation_reader'); + + if (class_exists(PsrCachedReader::class)) { + $container->services() + ->set('annotations.psr_cached_reader', PsrCachedReader::class) + ->args([ + service('annotations.reader'), + inline_service(ArrayAdapter::class), + abstract_arg('Debug-Flag'), + ]) + ->set('annotations.psr_cache', PhpArrayAdapter::class) + ->factory([PhpArrayAdapter::class, 'create']) + ->args([ + param('kernel.cache_dir').'/annotations.php', + service('cache.annotations'), + ]) + ->tag('container.hot_path') + ; + } }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php index 28f7bba6a45fb..a6f278743a75f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php @@ -30,8 +30,8 @@ $container->services() ->set('assets.packages', Packages::class) ->args([ - service('assets.empty_package'), - [], + service('assets._default_package'), + tagged_iterator('assets.package', 'package'), ]) ->alias(Packages::class, 'assets.packages') @@ -41,6 +41,8 @@ service('assets.empty_version_strategy'), ]) + ->alias('assets._default_package', 'assets.empty_package') + ->set('assets.context', RequestStackContext::class) ->args([ service('request_stack'), @@ -77,10 +79,12 @@ ->abstract() ->args([ abstract_arg('manifest path'), + service('http_client')->nullOnInvalid(), ]) ->set('assets.remote_json_manifest_version_strategy', RemoteJsonManifestVersionStrategy::class) ->abstract() + ->deprecate('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, use "assets.json_manifest_version_strategy" instead.') ->args([ abstract_arg('manifest url'), service('http_client'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index e9b3d2e36a855..5aea86ba7a06b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -62,76 +62,76 @@ ->tag('kernel.event_subscriber') ->set('console.command.about', AboutCommand::class) - ->tag('console.command', ['command' => 'about']) + ->tag('console.command') ->set('console.command.assets_install', AssetsInstallCommand::class) ->args([ service('filesystem'), param('kernel.project_dir'), ]) - ->tag('console.command', ['command' => 'assets:install']) + ->tag('console.command') ->set('console.command.cache_clear', CacheClearCommand::class) ->args([ service('cache_clearer'), service('filesystem'), ]) - ->tag('console.command', ['command' => 'cache:clear']) + ->tag('console.command') ->set('console.command.cache_pool_clear', CachePoolClearCommand::class) ->args([ service('cache.global_clearer'), ]) - ->tag('console.command', ['command' => 'cache:pool:clear']) + ->tag('console.command') ->set('console.command.cache_pool_prune', CachePoolPruneCommand::class) ->args([ [], ]) - ->tag('console.command', ['command' => 'cache:pool:prune']) + ->tag('console.command') ->set('console.command.cache_pool_delete', CachePoolDeleteCommand::class) ->args([ service('cache.global_clearer'), ]) - ->tag('console.command', ['command' => 'cache:pool:delete']) + ->tag('console.command') ->set('console.command.cache_pool_list', CachePoolListCommand::class) ->args([ null, ]) - ->tag('console.command', ['command' => 'cache:pool:list']) + ->tag('console.command') ->set('console.command.cache_warmup', CacheWarmupCommand::class) ->args([ service('cache_warmer'), ]) - ->tag('console.command', ['command' => 'cache:warmup']) + ->tag('console.command') ->set('console.command.config_debug', ConfigDebugCommand::class) - ->tag('console.command', ['command' => 'debug:config']) + ->tag('console.command') ->set('console.command.config_dump_reference', ConfigDumpReferenceCommand::class) - ->tag('console.command', ['command' => 'config:dump-reference']) + ->tag('console.command') ->set('console.command.container_debug', ContainerDebugCommand::class) - ->tag('console.command', ['command' => 'debug:container']) + ->tag('console.command') ->set('console.command.container_lint', ContainerLintCommand::class) - ->tag('console.command', ['command' => 'lint:container']) + ->tag('console.command') ->set('console.command.debug_autowiring', DebugAutowiringCommand::class) ->args([ null, service('debug.file_link_formatter')->nullOnInvalid(), ]) - ->tag('console.command', ['command' => 'debug:autowiring']) + ->tag('console.command') ->set('console.command.event_dispatcher_debug', EventDispatcherDebugCommand::class) ->args([ - service('event_dispatcher'), + tagged_locator('event_dispatcher.dispatcher'), ]) - ->tag('console.command', ['command' => 'debug:event-dispatcher']) + ->tag('console.command') ->set('console.command.messenger_consume_messages', ConsumeMessagesCommand::class) ->args([ @@ -141,7 +141,7 @@ service('logger')->nullOnInvalid(), [], // Receiver names ]) - ->tag('console.command', ['command' => 'messenger:consume']) + ->tag('console.command') ->tag('monolog.logger', ['channel' => 'messenger']) ->set('console.command.messenger_setup_transports', SetupTransportsCommand::class) @@ -149,19 +149,19 @@ service('messenger.receiver_locator'), [], // Receiver names ]) - ->tag('console.command', ['command' => 'messenger:setup-transports']) + ->tag('console.command') ->set('console.command.messenger_debug', DebugCommand::class) ->args([ [], // Message to handlers mapping ]) - ->tag('console.command', ['command' => 'debug:messenger']) + ->tag('console.command') ->set('console.command.messenger_stop_workers', StopWorkersCommand::class) ->args([ service('cache.messenger.restart_workers_signal'), ]) - ->tag('console.command', ['command' => 'messenger:stop-workers']) + ->tag('console.command') ->set('console.command.messenger_failed_messages_retry', FailedMessagesRetryCommand::class) ->args([ @@ -171,35 +171,35 @@ service('event_dispatcher'), service('logger'), ]) - ->tag('console.command', ['command' => 'messenger:failed:retry']) + ->tag('console.command') ->set('console.command.messenger_failed_messages_show', FailedMessagesShowCommand::class) ->args([ abstract_arg('Receiver name'), abstract_arg('Receiver'), ]) - ->tag('console.command', ['command' => 'messenger:failed:show']) + ->tag('console.command') ->set('console.command.messenger_failed_messages_remove', FailedMessagesRemoveCommand::class) ->args([ abstract_arg('Receiver name'), abstract_arg('Receiver'), ]) - ->tag('console.command', ['command' => 'messenger:failed:remove']) + ->tag('console.command') ->set('console.command.router_debug', RouterDebugCommand::class) ->args([ service('router'), service('debug.file_link_formatter')->nullOnInvalid(), ]) - ->tag('console.command', ['command' => 'debug:router']) + ->tag('console.command') ->set('console.command.router_match', RouterMatchCommand::class) ->args([ service('router'), tagged_iterator('routing.expression_language_provider'), ]) - ->tag('console.command', ['command' => 'router:match']) + ->tag('console.command') ->set('console.command.translation_debug', TranslationDebugCommand::class) ->args([ @@ -211,7 +211,7 @@ [], // Translator paths [], // Twig paths ]) - ->tag('console.command', ['command' => 'debug:translation']) + ->tag('console.command') ->set('console.command.translation_update', TranslationUpdateCommand::class) ->args([ @@ -224,22 +224,22 @@ [], // Translator paths [], // Twig paths ]) - ->tag('console.command', ['command' => 'translation:update']) + ->tag('console.command') ->set('console.command.validator_debug', ValidatorDebugCommand::class) ->args([ service('validator'), ]) - ->tag('console.command', ['command' => 'debug:validator']) + ->tag('console.command') ->set('console.command.workflow_dump', WorkflowDumpCommand::class) - ->tag('console.command', ['command' => 'workflow:dump']) + ->tag('console.command') ->set('console.command.xliff_lint', XliffLintCommand::class) - ->tag('console.command', ['command' => 'lint:xliff']) + ->tag('console.command') ->set('console.command.yaml_lint', YamlLintCommand::class) - ->tag('console.command', ['command' => 'lint:yaml']) + ->tag('console.command') ->set('console.command.form_debug', \Symfony\Component\Form\Command\DebugCommand::class) ->args([ @@ -250,48 +250,48 @@ [], // All type guessers are stored here by FormPass service('debug.file_link_formatter')->nullOnInvalid(), ]) - ->tag('console.command', ['command' => 'debug:form']) + ->tag('console.command') ->set('console.command.secrets_set', SecretsSetCommand::class) ->args([ service('secrets.vault'), service('secrets.local_vault')->nullOnInvalid(), ]) - ->tag('console.command', ['command' => 'secrets:set']) + ->tag('console.command') ->set('console.command.secrets_remove', SecretsRemoveCommand::class) ->args([ service('secrets.vault'), service('secrets.local_vault')->nullOnInvalid(), ]) - ->tag('console.command', ['command' => 'secrets:remove']) + ->tag('console.command') ->set('console.command.secrets_generate_key', SecretsGenerateKeysCommand::class) ->args([ service('secrets.vault'), service('secrets.local_vault')->ignoreOnInvalid(), ]) - ->tag('console.command', ['command' => 'secrets:generate-keys']) + ->tag('console.command') ->set('console.command.secrets_list', SecretsListCommand::class) ->args([ service('secrets.vault'), service('secrets.local_vault'), ]) - ->tag('console.command', ['command' => 'secrets:list']) + ->tag('console.command') ->set('console.command.secrets_decrypt_to_local', SecretsDecryptToLocalCommand::class) ->args([ service('secrets.vault'), service('secrets.local_vault')->ignoreOnInvalid(), ]) - ->tag('console.command', ['command' => 'secrets:decrypt-to-local']) + ->tag('console.command') ->set('console.command.secrets_encrypt_from_local', SecretsEncryptFromLocalCommand::class) ->args([ service('secrets.vault'), service('secrets.local_vault'), ]) - ->tag('console.command', ['command' => 'secrets:encrypt-from-local']) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php index ec6dd28aafbc8..9c330ab2c7333 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -34,6 +34,7 @@ service('http_client')->ignoreOnInvalid(), service('logger')->ignoreOnInvalid(), ]) + ->tag('monolog.logger', ['channel' => 'mailer']) ->set('mailer.transport_factory.amazon', SesTransportFactory::class) ->parent('mailer.transport_factory.abstract') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php index c7838ff615360..a7d993d47e316 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php @@ -26,6 +26,7 @@ use Symfony\Component\Messenger\Middleware\FailedMessageProcessingMiddleware; use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware; use Symfony\Component\Messenger\Middleware\RejectRedeliveredMessageMiddleware; +use Symfony\Component\Messenger\Middleware\RouterContextMiddleware; use Symfony\Component\Messenger\Middleware\SendMessageMiddleware; use Symfony\Component\Messenger\Middleware\TraceableMiddleware; use Symfony\Component\Messenger\Middleware\ValidationMiddleware; @@ -100,6 +101,11 @@ service('debug.stopwatch'), ]) + ->set('messenger.middleware.router_context', RouterContextMiddleware::class) + ->args([ + service('router'), + ]) + // Discovery ->set('messenger.receiver_locator') ->args([ @@ -128,6 +134,9 @@ ->tag('kernel.reset', ['method' => 'reset']) ->set('messenger.transport.sqs.factory', AmazonSqsTransportFactory::class) + ->args([ + service('logger')->ignoreOnInvalid(), + ]) ->set('messenger.transport.beanstalkd.factory', BeanstalkdTransportFactory::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index be40329cc9ebc..23a9a47936df5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -11,22 +11,30 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Notifier\Bridge\AllMySms\AllMySmsTransportFactory; +use Symfony\Component\Notifier\Bridge\Clickatell\ClickatellTransportFactory; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory; use Symfony\Component\Notifier\Bridge\Esendex\EsendexTransportFactory; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; use Symfony\Component\Notifier\Bridge\FreeMobile\FreeMobileTransportFactory; +use Symfony\Component\Notifier\Bridge\GatewayApi\GatewayApiTransportFactory; +use Symfony\Component\Notifier\Bridge\Gitter\GitterTransportFactory; use Symfony\Component\Notifier\Bridge\GoogleChat\GoogleChatTransportFactory; use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; +use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; +use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; use Symfony\Component\Notifier\Bridge\OvhCloud\OvhCloudTransportFactory; use Symfony\Component\Notifier\Bridge\RocketChat\RocketChatTransportFactory; use Symfony\Component\Notifier\Bridge\Sendinblue\SendinblueTransportFactory; use Symfony\Component\Notifier\Bridge\Sinch\SinchTransportFactory; use Symfony\Component\Notifier\Bridge\Slack\SlackTransportFactory; use Symfony\Component\Notifier\Bridge\Smsapi\SmsapiTransportFactory; +use Symfony\Component\Notifier\Bridge\SpotHit\SpotHitTransportFactory; use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransportFactory; use Symfony\Component\Notifier\Bridge\Twilio\TwilioTransportFactory; use Symfony\Component\Notifier\Bridge\Zulip\ZulipTransportFactory; @@ -71,6 +79,10 @@ ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + ->set('notifier.transport_factory.allmysms', AllMySmsTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + ->set('notifier.transport_factory.firebase', FirebaseTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') @@ -79,6 +91,10 @@ ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + ->set('notifier.transport_factory.spothit', SpotHitTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + ->set('notifier.transport_factory.ovhcloud', OvhCloudTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') @@ -89,7 +105,7 @@ ->set('notifier.transport_factory.zulip', ZulipTransportFactory::class) ->parent('notifier.transport_factory.abstract') - ->tag('texter.transport_factory') + ->tag('chatter.transport_factory') ->set('notifier.transport_factory.infobip', InfobipTransportFactory::class) ->parent('notifier.transport_factory.abstract') @@ -111,10 +127,34 @@ ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + ->set('notifier.transport_factory.iqsms', IqsmsTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + + ->set('notifier.transport_factory.octopush', OctopushTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + ->set('notifier.transport_factory.discord', DiscordTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.gatewayapi', GatewayApiTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + + ->set('notifier.transport_factory.mercure', MercureTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') + + ->set('notifier.transport_factory.gitter', GitterTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') + + ->set('notifier.transport_factory.clickatell', ClickatellTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + ->set('notifier.transport_factory.null', NullTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php index 39f92323f09a5..727a1f6364456 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php @@ -24,6 +24,7 @@ ->args([ abstract_arg('config'), abstract_arg('storage'), + null, ]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.php index bd44427bf65a1..09e340ff8aedd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.php @@ -49,36 +49,42 @@ ->set('routing.loader.xml', XmlFileLoader::class) ->args([ service('file_locator'), + '%kernel.environment%', ]) ->tag('routing.loader') ->set('routing.loader.yml', YamlFileLoader::class) ->args([ service('file_locator'), + '%kernel.environment%', ]) ->tag('routing.loader') ->set('routing.loader.php', PhpFileLoader::class) ->args([ service('file_locator'), + '%kernel.environment%', ]) ->tag('routing.loader') ->set('routing.loader.glob', GlobFileLoader::class) ->args([ service('file_locator'), + '%kernel.environment%', ]) ->tag('routing.loader') ->set('routing.loader.directory', DirectoryLoader::class) ->args([ service('file_locator'), + '%kernel.environment%', ]) ->tag('routing.loader') ->set('routing.loader.container', ContainerLoader::class) ->args([ tagged_locator('routing.route_loader'), + '%kernel.environment%', ]) ->tag('routing.loader') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 61410d874cd05..061647649a015 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -35,6 +35,7 @@ + @@ -106,6 +107,7 @@ + @@ -313,10 +315,18 @@ + + + + + + + + @@ -684,4 +694,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php index 0afc740cd89bf..9644d5b449c05 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php @@ -27,7 +27,7 @@ ->alias(TokenGeneratorInterface::class, 'security.csrf.token_generator') ->set('security.csrf.token_storage', SessionTokenStorage::class) - ->args([service('session')]) + ->args([service('request_stack')]) ->alias(TokenStorageInterface::class, 'security.csrf.token_storage') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php index 5f02a3e4a2f4f..444127374c393 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php @@ -65,6 +65,7 @@ class_exists(WorkflowEvents::class) ? WorkflowEvents::ALIASES : [] ->set('event_dispatcher', EventDispatcher::class) ->public() ->tag('container.hot_path') + ->tag('event_dispatcher.dispatcher') ->alias(EventDispatcherInterfaceComponentAlias::class, 'event_dispatcher') ->alias(EventDispatcherInterface::class, 'event_dispatcher') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php index 812ee50e7ce81..bef83c3c0162d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php @@ -11,10 +11,12 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Bundle\FrameworkBundle\Session\DeprecatedSessionFactory; use Symfony\Component\HttpFoundation\Session\Attribute\AttributeBag; use Symfony\Component\HttpFoundation\Session\Flash\FlashBag; use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\SessionFactory; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\Handler\IdentityMarshaller; @@ -24,8 +26,12 @@ use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag; use Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage; +use Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorageFactory; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; +use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorageFactory; use Symfony\Component\HttpFoundation\Session\Storage\PhpBridgeSessionStorage; +use Symfony\Component\HttpFoundation\Session\Storage\PhpBridgeSessionStorageFactory; +use Symfony\Component\HttpFoundation\Session\Storage\ServiceSessionFactory; use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface; use Symfony\Component\HttpKernel\EventListener\SessionListener; @@ -33,16 +39,58 @@ $container->parameters()->set('session.metadata.storage_key', '_sf2_meta'); $container->services() - ->set('session', Session::class) - ->public() + ->set('.session.do-not-use', Session::class) // to be removed in 6.0 + ->factory([service('session.factory'), 'createSession']) + ->set('session.factory', SessionFactory::class) ->args([ - service('session.storage'), - null, // AttributeBagInterface - null, // FlashBagInterface + service('request_stack'), + service('session.storage.factory'), [service('session_listener'), 'onSessionUsage'], ]) - ->alias(SessionInterface::class, 'session') + + ->set('session.storage.factory.native', NativeSessionStorageFactory::class) + ->args([ + param('session.storage.options'), + service('session.handler'), + inline_service(MetadataBag::class) + ->args([ + param('session.metadata.storage_key'), + param('session.metadata.update_threshold'), + ]), + false, + ]) + ->set('session.storage.factory.php_bridge', PhpBridgeSessionStorageFactory::class) + ->args([ + service('session.handler'), + inline_service(MetadataBag::class) + ->args([ + param('session.metadata.storage_key'), + param('session.metadata.update_threshold'), + ]), + false, + ]) + ->set('session.storage.factory.mock_file', MockFileSessionStorageFactory::class) + ->args([ + param('kernel.cache_dir').'/sessions', + 'MOCKSESSID', + inline_service(MetadataBag::class) + ->args([ + param('session.metadata.storage_key'), + param('session.metadata.update_threshold'), + ]), + ]) + ->set('session.storage.factory.service', ServiceSessionFactory::class) + ->args([ + service('session.storage'), + ]) + ->deprecate('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, use "session.storage.factory.native", "session.storage.factory.php_bridge" or "session.storage.factory.mock_file" instead.') + + ->set('.session.deprecated', SessionInterface::class) // to be removed in 6.0 + ->factory([inline_service(DeprecatedSessionFactory::class)->args([service('request_stack')]), 'getSession']) + ->alias(SessionInterface::class, '.session.do-not-use') + ->deprecate('symfony/framework-bundle', '5.3', 'The "%alias_id%" alias is deprecated, use "$requestStack->getSession()" instead.') ->alias(SessionStorageInterface::class, 'session.storage') + ->deprecate('symfony/framework-bundle', '5.3', 'The "%alias_id%" alias is deprecated, use "session.storage.factory" instead.') ->alias(\SessionHandlerInterface::class, 'session.handler') ->set('session.storage.metadata_bag', MetadataBag::class) @@ -50,6 +98,7 @@ param('session.metadata.storage_key'), param('session.metadata.update_threshold'), ]) + ->deprecate('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, create your own "session.storage.factory" instead.') ->set('session.storage.native', NativeSessionStorage::class) ->args([ @@ -57,20 +106,22 @@ service('session.handler'), service('session.storage.metadata_bag'), ]) + ->deprecate('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, use "session.storage.factory.native" instead.') ->set('session.storage.php_bridge', PhpBridgeSessionStorage::class) ->args([ service('session.handler'), service('session.storage.metadata_bag'), ]) + ->deprecate('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, use "session.storage.factory.php_bridge" instead.') ->set('session.flash_bag', FlashBag::class) - ->factory([service('session'), 'getFlashBag']) + ->factory([service('.session.do-not-use'), 'getFlashBag']) ->deprecate('symfony/framework-bundle', '5.1', 'The "%service_id%" service is deprecated, use "$session->getFlashBag()" instead.') ->alias(FlashBagInterface::class, 'session.flash_bag') ->set('session.attribute_bag', AttributeBag::class) - ->factory([service('session'), 'getBag']) + ->factory([service('.session.do-not-use'), 'getBag']) ->args(['attributes']) ->deprecate('symfony/framework-bundle', '5.1', 'The "%service_id%" service is deprecated, use "$session->getAttributeBag()" instead.') @@ -80,6 +131,7 @@ 'MOCKSESSID', service('session.storage.metadata_bag'), ]) + ->deprecate('symfony/framework-bundle', '5.3', 'The "%service_id%" service is deprecated, use "session.storage.factory.mock_file" instead.') ->set('session.handler.native_file', StrictSessionHandler::class) ->args([ @@ -94,8 +146,8 @@ ->set('session_listener', SessionListener::class) ->args([ service_locator([ - 'session' => service('session')->ignoreOnInvalid(), - 'initialized_session' => service('session')->ignoreOnUninitialized(), + 'session' => service('.session.do-not-use')->ignoreOnInvalid(), + 'initialized_session' => service('.session.do-not-use')->ignoreOnUninitialized(), 'logger' => service('logger')->ignoreOnInvalid(), 'session_collector' => service('data_collector.request.session_collector')->ignoreOnInvalid(), ]), @@ -105,6 +157,7 @@ // for BC ->alias('session.storage.filesystem', 'session.storage.mock_file') + ->deprecate('symfony/framework-bundle', '5.3', 'The "%alias_id%" alias is deprecated, use "session.storage.factory.mock_file" instead.') ->set('session.marshaller', IdentityMarshaller::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/test.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/test.php index af0df318a0768..61e4052521329 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/test.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/test.php @@ -38,7 +38,7 @@ ->set('test.session.listener', TestSessionListener::class) ->args([ service_locator([ - 'session' => service('session')->ignoreOnInvalid(), + 'session' => service('.session.do-not-use')->ignoreOnInvalid(), ]), ]) ->tag('kernel.event_subscriber') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/uid.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/uid.php new file mode 100644 index 0000000000000..840fb97b5f5f5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/uid.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Uid\Factory\NameBasedUuidFactory; +use Symfony\Component\Uid\Factory\RandomBasedUuidFactory; +use Symfony\Component\Uid\Factory\TimeBasedUuidFactory; +use Symfony\Component\Uid\Factory\UlidFactory; +use Symfony\Component\Uid\Factory\UuidFactory; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('ulid.factory', UlidFactory::class) + ->alias(UlidFactory::class, 'ulid.factory') + + ->set('uuid.factory', UuidFactory::class) + ->alias(UuidFactory::class, 'uuid.factory') + + ->set('name_based_uuid.factory', NameBasedUuidFactory::class) + ->factory([service('uuid.factory'), 'nameBased']) + ->args([abstract_arg('Please set the "framework.uid.name_based_uuid_namespace" configuration option to use the "name_based_uuid.factory" service')]) + ->alias(NameBasedUuidFactory::class, 'name_based_uuid.factory') + + ->set('random_based_uuid.factory', RandomBasedUuidFactory::class) + ->factory([service('uuid.factory'), 'randomBased']) + ->alias(RandomBasedUuidFactory::class, 'random_based_uuid.factory') + + ->set('time_based_uuid.factory', TimeBasedUuidFactory::class) + ->factory([service('uuid.factory'), 'timeBased']) + ->alias(TimeBasedUuidFactory::class, 'time_based_uuid.factory') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php index 5dcb427b565bc..75166bd810a9e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php @@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\CacheWarmer\ValidatorCacheWarmer; use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Validator\Constraints\EmailValidator; use Symfony\Component\Validator\Constraints\ExpressionValidator; use Symfony\Component\Validator\Constraints\NotCompromisedPasswordValidator; @@ -66,10 +67,18 @@ ]) ->set('validator.expression', ExpressionValidator::class) + ->args([service('validator.expression_language')->nullOnInvalid()]) ->tag('validator.constraint_validator', [ 'alias' => 'validator.expression', ]) + ->set('validator.expression_language', ExpressionLanguage::class) + ->args([service('cache.validator_expression_language')->nullOnInvalid()]) + + ->set('cache.validator_expression_language') + ->parent('cache.system') + ->tag('cache.pool') + ->set('validator.email', EmailValidator::class) ->args([ abstract_arg('Default mode'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Session/DeprecatedSessionFactory.php b/src/Symfony/Bundle/FrameworkBundle/Session/DeprecatedSessionFactory.php new file mode 100644 index 0000000000000..c2c1cd34aee70 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Session/DeprecatedSessionFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Session; + +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\SessionInterface; + +/** + * Provides session and trigger deprecation. + * + * Used by service that should trigger deprecation when accessed by the user. + * + * @author Jérémy Derussé + * + * @internal to be removed in 6.0 + */ +class DeprecatedSessionFactory +{ + private $requestStack; + + public function __construct(RequestStack $requestStack) + { + $this->requestStack = $requestStack; + } + + public function getSession(): ?SessionInterface + { + trigger_deprecation('symfony/framework-bundle', '5.3', 'The "session" service is deprecated, use "$requestStack->getSession()" instead.'); + + try { + return $this->requestStack->getSession(); + } catch (SessionNotFoundException $e) { + return null; + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Test/BrowserKitAssertionsTrait.php b/src/Symfony/Bundle/FrameworkBundle/Test/BrowserKitAssertionsTrait.php index 48f2b68e11e32..8caa19fa1f443 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Test/BrowserKitAssertionsTrait.php +++ b/src/Symfony/Bundle/FrameworkBundle/Test/BrowserKitAssertionsTrait.php @@ -38,6 +38,11 @@ public static function assertResponseStatusCodeSame(int $expectedCode, string $m self::assertThatForResponse(new ResponseConstraint\ResponseStatusCodeSame($expectedCode), $message); } + public static function assertResponseFormatSame(?string $expectedFormat, string $message = ''): void + { + self::assertThatForResponse(new ResponseConstraint\ResponseFormatSame(self::getRequest(), $expectedFormat), $message); + } + public static function assertResponseRedirects(string $expectedLocation = null, int $expectedCode = null, string $message = ''): void { $constraint = new ResponseConstraint\ResponseIsRedirected(); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php index fcb91ea9c8b72..4c84c2c375733 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php @@ -22,6 +22,8 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormConfigInterface; use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; use Symfony\Component\HttpFoundation\File\File; @@ -423,6 +425,52 @@ public function testStreamTwig() $this->assertInstanceOf(StreamedResponse::class, $controller->stream('foo')); } + public function testRenderFormTwig() + { + $formView = new FormView(); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('createView')->willReturn($formView); + + $twig = $this->createMock(Environment::class); + $twig->expects($this->once())->method('render')->with('foo', ['form' => $formView, 'bar' => 'bar'])->willReturn('bar'); + + $container = new Container(); + $container->set('twig', $twig); + + $controller = $this->createController(); + $controller->setContainer($container); + + $response = $controller->renderForm('foo', $form, ['bar' => 'bar']); + + $this->assertTrue($response->isSuccessful()); + $this->assertSame('bar', $response->getContent()); + } + + public function testRenderInvalidFormTwig() + { + $formView = new FormView(); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('createView')->willReturn($formView); + $form->expects($this->once())->method('isSubmitted')->willReturn(true); + $form->expects($this->once())->method('isValid')->willReturn(false); + + $twig = $this->createMock(Environment::class); + $twig->expects($this->once())->method('render')->with('foo', ['form' => $formView, 'bar' => 'bar'])->willReturn('bar'); + + $container = new Container(); + $container->set('twig', $twig); + + $controller = $this->createController(); + $controller->setContainer($container); + + $response = $controller->renderForm('foo', $form, ['bar' => 'bar']); + + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + $this->assertSame('bar', $response->getContent()); + } + public function testRedirectToRoute() { $router = $this->createMock(RouterInterface::class); @@ -449,8 +497,14 @@ public function testAddFlash() $session = $this->createMock(Session::class); $session->expects($this->once())->method('getFlashBag')->willReturn($flashBag); + $request = new Request(); + $request->setSession($session); + $requestStack = new RequestStack(); + $requestStack->push($request); + $container = new Container(); $container->set('session', $session); + $container->set('request_stack', $requestStack); $controller = $this->createController(); $controller->setContainer($container); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/SessionPassTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/SessionPassTest.php index afc6f9b4b2577..7cbb3262f131f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/SessionPassTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/SessionPassTest.php @@ -19,26 +19,77 @@ class SessionPassTest extends TestCase { public function testProcess() + { + $container = new ContainerBuilder(); + $container + ->register('session.factory'); // marker service + $container + ->register('.session.do-not-use'); + + (new SessionPass())->process($container); + + $this->assertTrue($container->hasAlias('session')); + $this->assertSame($container->findDefinition('session'), $container->getDefinition('.session.do-not-use')); + $this->assertTrue($container->getAlias('session')->isDeprecated()); + } + + public function testProcessUserDefinedSession() { $arguments = [ new Reference('session.flash_bag'), new Reference('session.attribute_bag'), ]; $container = new ContainerBuilder(); + $container + ->register('session.factory'); // marker service $container ->register('session') ->setArguments($arguments); $container ->register('session.flash_bag') - ->setFactory([new Reference('session'), 'getFlashBag']); + ->setFactory([new Reference('.session.do-not-use'), 'getFlashBag']); $container ->register('session.attribute_bag') - ->setFactory([new Reference('session'), 'getAttributeBag']); + ->setFactory([new Reference('.session.do-not-use'), 'getAttributeBag']); (new SessionPass())->process($container); $this->assertSame($arguments, $container->getDefinition('session')->getArguments()); $this->assertNull($container->getDefinition('session.flash_bag')->getFactory()); $this->assertNull($container->getDefinition('session.attribute_bag')->getFactory()); + $this->assertTrue($container->hasAlias('.session.do-not-use')); + $this->assertSame($container->getDefinition('session'), $container->findDefinition('.session.do-not-use')); + $this->assertTrue($container->getDefinition('session')->isDeprecated()); + } + + public function testProcessUserDefinedAlias() + { + $arguments = [ + new Reference('session.flash_bag'), + new Reference('session.attribute_bag'), + ]; + $container = new ContainerBuilder(); + $container + ->register('session.factory'); // marker service + $container + ->register('trueSession') + ->setArguments($arguments); + $container + ->setAlias('session', 'trueSession'); + $container + ->register('session.flash_bag') + ->setFactory([new Reference('.session.do-not-use'), 'getFlashBag']); + $container + ->register('session.attribute_bag') + ->setFactory([new Reference('.session.do-not-use'), 'getAttributeBag']); + + (new SessionPass())->process($container); + + $this->assertSame($arguments, $container->findDefinition('session')->getArguments()); + $this->assertNull($container->getDefinition('session.flash_bag')->getFactory()); + $this->assertNull($container->getDefinition('session.attribute_bag')->getFactory()); + $this->assertTrue($container->hasAlias('.session.do-not-use')); + $this->assertSame($container->findDefinition('session'), $container->findDefinition('.session.do-not-use')); + $this->assertTrue($container->getAlias('session')->isDeprecated()); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 3a4af4b800435..1430ee9851b6e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -22,6 +22,8 @@ use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Notifier\Notifier; +use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; +use Symfony\Component\Uid\Factory\UuidFactory; class ConfigurationTest extends TestCase { @@ -442,6 +444,7 @@ protected static function getBundleDefaultConfig() 'mapping' => ['paths' => []], ], 'property_access' => [ + 'enabled' => true, 'magic_call' => false, 'magic_get' => true, 'magic_set' => true, @@ -462,6 +465,7 @@ protected static function getBundleDefaultConfig() 'session' => [ 'enabled' => false, 'storage_id' => 'session.storage.native', + 'storage_factory_id' => null, 'handler_id' => 'session.handler.native_file', 'cookie_httponly' => true, 'cookie_samesite' => null, @@ -560,9 +564,15 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'private_headers' => [], ], 'rate_limiter' => [ - 'enabled' => false, + 'enabled' => !class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class), 'limiters' => [], ], + 'uid' => [ + 'enabled' => !class_exists(FullStack::class) && class_exists(UuidFactory::class), + 'default_uuid_version' => 6, + 'name_based_uuid_version' => 5, + 'time_based_uuid_version' => 6, + ], ]; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php index ef2fd77013f85..ab16a52e21e9b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php @@ -30,6 +30,15 @@ 'remote_manifest' => [ 'json_manifest_path' => 'https://cdn.example.com/manifest.json', ], + 'var_manifest' => [ + 'json_manifest_path' => '%var_json_manifest_path%', + ], + 'env_manifest' => [ + 'json_manifest_path' => '%env(env_manifest)%', + ], ], ], ]); + +$container->setParameter('var_json_manifest_path', 'https://cdn.example.com/manifest.json'); +$container->setParameter('env(env_manifest)', 'https://cdn.example.com/manifest.json'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf.php index e3f3577c1b430..41a3e2ee84ec7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf.php @@ -6,6 +6,7 @@ 'legacy_error_messages' => false, ], 'session' => [ + 'storage_factory_id' => 'session.storage.factory.native', 'handler_id' => null, ], ]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php index 32f174118c98a..7aa6c50135b80 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php @@ -27,7 +27,7 @@ 'utf8' => true, ], 'session' => [ - 'storage_id' => 'session.storage.native', + 'storage_factory_id' => 'session.storage.factory.native', 'handler_id' => 'session.handler.native_file', 'name' => '_SYMFONY', 'cookie_lifetime' => 86400, diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/php_errors_log_levels.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/php_errors_log_levels.php new file mode 100644 index 0000000000000..620a5871e098f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/php_errors_log_levels.php @@ -0,0 +1,10 @@ +loadFromExtension('framework', [ + 'php_errors' => [ + 'log' => [ + \E_NOTICE => \Psr\Log\LogLevel::ERROR, + \E_WARNING => \Psr\Log\LogLevel::ERROR, + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session.php index 375008c7db468..8b4c6e6e4c3b1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session.php @@ -2,6 +2,7 @@ $container->loadFromExtension('framework', [ 'session' => [ + 'storage_factory_id' => 'session.storage.factory.native', 'handler_id' => null, ], ]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto.php index 7259b07f92615..b52935c726a0f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto.php @@ -2,6 +2,7 @@ $container->loadFromExtension('framework', [ 'session' => [ + 'storage_factory_id' => 'session.storage.factory.native', 'handler_id' => null, 'cookie_secure' => 'auto', ], diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto_legacy.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto_legacy.php new file mode 100644 index 0000000000000..23cd73767bd88 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_cookie_secure_auto_legacy.php @@ -0,0 +1,9 @@ +loadFromExtension('framework', [ + 'session' => [ + 'handler_id' => null, + 'cookie_secure' => 'auto', + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_legacy.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_legacy.php new file mode 100644 index 0000000000000..e453305799971 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/session_legacy.php @@ -0,0 +1,8 @@ +loadFromExtension('framework', [ + 'session' => [ + 'handler_id' => null, + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml index 24bfdc6456185..ae0e0e099bc93 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml @@ -23,6 +23,13 @@ + + + + + https://cdn.example.com/manifest.json + https://cdn.example.com/manifest.json + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf.xml index 4686d9ffc046d..24acb3e32707c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf.xml @@ -9,6 +9,6 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml index 1552a3ceb6e42..30fcf6b7f3929 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml @@ -8,7 +8,7 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml index dda2e724cc664..1e89bca965ea2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml @@ -9,6 +9,6 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml index 9207066f1c183..4641e702677cb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -15,7 +15,7 @@ - + text/csv diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/php_errors_log_levels.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/php_errors_log_levels.xml new file mode 100644 index 0000000000000..1b6642a575c4c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/php_errors_log_levels.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session.xml index 599cbdee1ccc0..e91d51955e6fa 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session.xml @@ -7,6 +7,6 @@ http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto.xml index 1fff3e090e88f..3023c43fc13ad 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto.xml @@ -7,6 +7,6 @@ http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto_legacy.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto_legacy.xml new file mode 100644 index 0000000000000..6893400865a8b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_cookie_secure_auto_legacy.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_legacy.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_legacy.xml new file mode 100644 index 0000000000000..326cf268d967f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/session_legacy.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml index 4a4a57bc43a79..ab9eb1b610ce8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml @@ -21,3 +21,11 @@ framework: json_manifest_path: '/path/to/manifest.json' remote_manifest: json_manifest_path: 'https://cdn.example.com/manifest.json' + var_manifest: + json_manifest_path: '%var_json_manifest_path%' + env_manifest: + json_manifest_path: '%env(env_manifest)%' + +parameters: + var_json_manifest_path: 'https://cdn.example.com/manifest.json' + env(env_manifest): https://cdn.example.com/manifest.json diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf.yml index d29019cf48f6d..26d1d832fcf47 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf.yml @@ -3,4 +3,5 @@ framework: csrf_protection: ~ form: legacy_error_messages: false - session: ~ + session: + storage_factory_id: session.storage.factory.native diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml index 2206585863baa..67a3f1db00fef 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -19,7 +19,7 @@ framework: type: xml utf8: true session: - storage_id: session.storage.native + storage_factory_id: session.storage.factory.native handler_id: session.handler.native_file name: _SYMFONY cookie_lifetime: 86400 diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/php_errors_log_levels.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/php_errors_log_levels.yml new file mode 100644 index 0000000000000..ad9fd30667de2 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/php_errors_log_levels.yml @@ -0,0 +1,5 @@ +framework: + php_errors: + log: + !php/const \E_NOTICE: !php/const Psr\Log\LogLevel::ERROR + !php/const \E_WARNING: !php/const Psr\Log\LogLevel::ERROR diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session.yml index d91b0c3147dfd..eb0df8d01c76c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session.yml @@ -1,3 +1,4 @@ framework: session: + storage_factory_id: session.storage.factory.native handler_id: null diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto.yml index 17fe2f5a02c03..739b49b1e6ab9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto.yml @@ -1,4 +1,5 @@ framework: session: + storage_factory_id: session.storage.factory.native handler_id: ~ cookie_secure: auto diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto_legacy.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto_legacy.yml new file mode 100644 index 0000000000000..bac546c371b19 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_cookie_secure_auto_legacy.yml @@ -0,0 +1,5 @@ +# to be removed in Symfony 6.0 +framework: + session: + handler_id: ~ + cookie_secure: auto diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_legacy.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_legacy.yml new file mode 100644 index 0000000000000..171fadd07601a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/session_legacy.yml @@ -0,0 +1,4 @@ +# to be removed in Symfony 6.0 +framework: + session: + handler_id: null diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 960eda35d0b62..83087e5844550 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection; use Doctrine\Common\Annotations\Annotation; +use Doctrine\Common\Annotations\PsrCachedReader; use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerAwareInterface; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; @@ -46,6 +47,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\Messenger\Transport\TransportFactory; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -504,6 +506,18 @@ public function testPhpErrorsWithLogLevel() $this->assertSame(8, $definition->getArgument(2)); } + public function testPhpErrorsWithLogLevels() + { + $container = $this->createContainerFromFile('php_errors_log_levels'); + + $definition = $container->getDefinition('debug.debug_handlers_listener'); + $this->assertEquals(new Reference('monolog.logger.php', ContainerInterface::NULL_ON_INVALID_REFERENCE), $definition->getArgument(1)); + $this->assertSame([ + \E_NOTICE => \Psr\Log\LogLevel::ERROR, + \E_WARNING => \Psr\Log\LogLevel::ERROR, + ], $definition->getArgument(2)); + } + public function testRouter() { $container = $this->createContainerFromFile('full'); @@ -529,9 +543,12 @@ public function testSession() { $container = $this->createContainerFromFile('full'); - $this->assertTrue($container->hasDefinition('session'), '->registerSessionConfiguration() loads session.xml'); + $this->assertTrue($container->hasAlias(SessionInterface::class), '->registerSessionConfiguration() loads session.xml'); $this->assertEquals('fr', $container->getParameter('kernel.default_locale')); - $this->assertEquals('session.storage.native', (string) $container->getAlias('session.storage')); + $this->assertEquals('session.storage.factory.native', (string) $container->getAlias('session.storage.factory')); + $this->assertFalse($container->has('session.storage')); + $this->assertFalse($container->has('session.storage.native')); + $this->assertFalse($container->has('session.storage.php_bridge')); $this->assertEquals('session.handler.native_file', (string) $container->getAlias('session.handler')); $options = $container->getParameter('session.storage.options'); @@ -555,13 +572,33 @@ public function testNullSessionHandler() { $container = $this->createContainerFromFile('session'); - $this->assertTrue($container->hasDefinition('session'), '->registerSessionConfiguration() loads session.xml'); + $this->assertTrue($container->hasAlias(SessionInterface::class), '->registerSessionConfiguration() loads session.xml'); + $this->assertNull($container->getDefinition('session.storage.factory.native')->getArgument(1)); + $this->assertNull($container->getDefinition('session.storage.factory.php_bridge')->getArgument(0)); + $this->assertSame('session.handler.native_file', (string) $container->getAlias('session.handler')); + + $expected = ['session', 'initialized_session', 'logger', 'session_collector']; + $this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues())); + $this->assertFalse($container->getDefinition('session.storage.factory.native')->getArgument(3)); + } + + /** + * @group legacy + */ + public function testNullSessionHandlerLegacy() + { + $this->expectDeprecation('Since symfony/framework-bundle 5.3: Not setting the "framework.session.storage_factory_id" configuration option is deprecated, it will default to "session.storage.factory.native" and will replace the "framework.session.storage_id" configuration option in version 6.0.'); + + $container = $this->createContainerFromFile('session_legacy'); + + $this->assertTrue($container->hasAlias(SessionInterface::class), '->registerSessionConfiguration() loads session.xml'); $this->assertNull($container->getDefinition('session.storage.native')->getArgument(1)); $this->assertNull($container->getDefinition('session.storage.php_bridge')->getArgument(0)); $this->assertSame('session.handler.native_file', (string) $container->getAlias('session.handler')); $expected = ['session', 'initialized_session', 'logger', 'session_collector']; $this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues())); + $this->assertFalse($container->getDefinition('session.storage.factory.native')->getArgument(3)); } public function testRequest() @@ -590,8 +627,13 @@ public function testAssets() $this->assertUrlPackage($container, $defaultPackage, ['http://cdn.example.com'], 'SomeVersionScheme', '%%s?version=%%s'); // packages - $packages = $packages->getArgument(1); - $this->assertCount(7, $packages); + $packageTags = $container->findTaggedServiceIds('assets.package'); + $this->assertCount(9, $packageTags); + + $packages = []; + foreach ($packageTags as $serviceId => $tagAttributes) { + $packages[$tagAttributes[0]['package']] = $serviceId; + } $package = $container->getDefinition((string) $packages['images_path']); $this->assertPathPackage($container, $package, '/foo', 'SomeVersionScheme', '%%s?version=%%s'); @@ -615,8 +657,18 @@ public function testAssets() $package = $container->getDefinition($packages['remote_manifest']); $versionStrategy = $container->getDefinition($package->getArgument(1)); - $this->assertSame('assets.remote_json_manifest_version_strategy', $versionStrategy->getParent()); + $this->assertSame('assets.json_manifest_version_strategy', $versionStrategy->getParent()); $this->assertSame('https://cdn.example.com/manifest.json', $versionStrategy->getArgument(0)); + + $package = $container->getDefinition($packages['var_manifest']); + $versionStrategy = $container->getDefinition($package->getArgument(1)); + $this->assertSame('assets.json_manifest_version_strategy', $versionStrategy->getParent()); + $this->assertSame('https://cdn.example.com/manifest.json', $versionStrategy->getArgument(0)); + + $package = $container->getDefinition($packages['env_manifest']); + $versionStrategy = $container->getDefinition($package->getArgument(1)); + $this->assertSame('assets.json_manifest_version_strategy', $versionStrategy->getParent()); + $this->assertStringMatchesFormat('env_%s', $versionStrategy->getArgument(0)); } public function testAssetsDefaultVersionStrategyAsService() @@ -929,7 +981,7 @@ public function testAnnotations() $container->compile(); $this->assertEquals($container->getParameter('kernel.cache_dir').'/annotations', $container->getDefinition('annotations.filesystem_cache_adapter')->getArgument(2)); - $this->assertSame('annotations.filesystem_cache', (string) $container->getDefinition('annotation_reader')->getArgument(1)); + $this->assertSame(class_exists(PsrCachedReader::class) ? 'annotations.filesystem_cache_adapter' : 'annotations.filesystem_cache', (string) $container->getDefinition('annotation_reader')->getArgument(1)); } public function testFileLinkFormat() @@ -1456,6 +1508,19 @@ public function testSessionCookieSecureAuto() { $container = $this->createContainerFromFile('session_cookie_secure_auto'); + $expected = ['session', 'initialized_session', 'logger', 'session_collector']; + $this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues())); + } + + /** + * @group legacy + */ + public function testSessionCookieSecureAutoLegacy() + { + $this->expectDeprecation('Since symfony/framework-bundle 5.3: Not setting the "framework.session.storage_factory_id" configuration option is deprecated, it will default to "session.storage.factory.native" and will replace the "framework.session.storage_id" configuration option in version 6.0.'); + + $container = $this->createContainerFromFile('session_cookie_secure_auto_legacy'); + $expected = ['session', 'initialized_session', 'logger', 'session_collector', 'session_storage', 'request_stack']; $this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues())); } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php index 0b8b4eb8fa406..e400b95506b73 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php @@ -13,6 +13,8 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; @@ -82,4 +84,50 @@ public function testWorkflowValidationStateMachine() ]); }); } + + public function testRateLimiterWithLockFactory() + { + try { + $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'lock' => false, + 'rate_limiter' => [ + 'with_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'], + ], + ]); + }); + + $this->fail('No LogicException thrown'); + } catch (LogicException $e) { + $this->assertEquals('Rate limiter "with_lock" requires the Lock component to be installed and configured.', $e->getMessage()); + } + + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'lock' => true, + 'rate_limiter' => [ + 'with_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'], + ], + ]); + }); + + $withLock = $container->getDefinition('limiter.with_lock'); + $this->assertEquals('lock.factory', (string) $withLock->getArgument(2)); + } + + public function testRateLimiterLockFactory() + { + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'rate_limiter' => [ + 'without_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour', 'lock_factory' => null], + ], + ]); + }); + + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('The argument "2" doesn\'t exist.'); + + $container->getDefinition('limiter.without_lock')->getArgument(2); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/AutowiringTypesTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/AutowiringTypesTest.php index 1d34e54d17a09..b747b75a9af93 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/AutowiringTypesTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/AutowiringTypesTest.php @@ -13,6 +13,7 @@ use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Annotations\CachedReader; +use Doctrine\Common\Annotations\PsrCachedReader; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher; @@ -33,7 +34,7 @@ public function testCachedAnnotationReaderAutowiring() static::bootKernel(); $annotationReader = static::$container->get('test.autowiring_types.autowired_services')->getAnnotationReader(); - $this->assertInstanceOf(CachedReader::class, $annotationReader); + $this->assertInstanceOf(class_exists(PsrCachedReader::class) ? PsrCachedReader::class : CachedReader::class, $annotationReader); } public function testEventDispatcherAutowiring() diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Controller/DeprecatedSessionController.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Controller/DeprecatedSessionController.php new file mode 100644 index 0000000000000..75e9673c35a71 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Controller/DeprecatedSessionController.php @@ -0,0 +1,16 @@ +get('session'); + + return new Response('done'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml index 155871fc278ec..94eb4d7ac1293 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml @@ -22,6 +22,10 @@ injected_flashbag_session_setflash: path: injected_flashbag/session_setflash/{message} defaults: { _controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\InjectedFlashbagSessionController::setFlashAction} +deprecated_session_setflash: + path: /deprecated_session/trigger + defaults: { _controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\DeprecatedSessionController::triggerAction} + session_showflash: path: /session_showflash defaults: { _controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\SessionController::showFlashAction } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/CachePoolClearCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/CachePoolClearCommandTest.php index a3a0b23136137..04724988e2ddf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/CachePoolClearCommandTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/CachePoolClearCommandTest.php @@ -13,8 +13,10 @@ use Symfony\Bundle\FrameworkBundle\Command\CachePoolClearCommand; use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\Finder\SplFileInfo; /** * @group functional @@ -74,6 +76,35 @@ public function testClearUnexistingPool() ->execute(['pools' => ['unknown_pool']], ['decorated' => false]); } + public function testClearFailed() + { + $tester = $this->createCommandTester(); + /** @var FilesystemAdapter $pool */ + $pool = static::$container->get('cache.public_pool'); + $item = $pool->getItem('foo'); + $item->set('baz'); + $pool->save($item); + $r = new \ReflectionObject($pool); + $p = $r->getProperty('directory'); + $p->setAccessible(true); + $poolDir = $p->getValue($pool); + + /** @var SplFileInfo $entry */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($poolDir)) as $entry) { + // converts files into dir to make adapter fail + if ($entry->isFile()) { + unlink($entry->getPathname()); + mkdir($entry->getPathname()); + } + } + + $tester->execute(['pools' => ['cache.public_pool']]); + + $this->assertSame(1, $tester->getStatusCode(), 'cache:pool:clear exits with 1 in case of error'); + $this->assertStringNotContainsString('[OK] Cache was successfully cleared.', $tester->getDisplay()); + $this->assertStringContainsString('[WARNING] Cache pool "cache.public_pool" could not be cleared.', $tester->getDisplay()); + } + private function createCommandTester() { $application = new Application(static::$kernel); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SessionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SessionTest.php index 253947d02fb07..51406047931b7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SessionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SessionTest.php @@ -99,6 +99,26 @@ public function testFlashOnInjectedFlashbag($config, $insulate) $this->assertStringContainsString('No flash was set.', $crawler->text()); } + /** + * @group legacy + * @dataProvider getConfigs + */ + public function testSessionServiceTriggerDeprecation($config, $insulate) + { + $this->expectDeprecation('Since symfony/framework-bundle 5.3: The "session" service is deprecated, use "$requestStack->getSession()" instead.'); + + $client = $this->createClient(['test_case' => 'Session', 'root_config' => $config]); + if ($insulate) { + $client->insulate(); + } + + // trigger deprecation + $crawler = $client->request('GET', '/deprecated_session/trigger'); + + // check response + $this->assertStringContainsString('done', $crawler->text()); + } + /** * See if two separate insulated clients can run without * polluting each other's session data. diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ConfigDump/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ConfigDump/config.yml index 432e35bd2f24d..1ec484a7f5208 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ConfigDump/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ConfigDump/config.yml @@ -5,6 +5,7 @@ framework: secret: '%secret%' default_locale: '%env(LOCALE)%' session: + storage_factory_id: session.storage.factory.native cookie_httponly: '%env(bool:COOKIE_HTTPONLY)%' parameters: diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml index 5754ba969365b..be0eab4d5645e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml @@ -7,7 +7,8 @@ framework: fragments: true profiler: true router: true - session: true + session: + storage_factory_id: session.storage.factory.native request: true assets: true translator: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Session/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Session/config.yml index 4807c42d1ede8..03ee4fb151104 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Session/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Session/config.yml @@ -9,3 +9,7 @@ services: Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\InjectedFlashbagSessionController: autowire: true tags: ['controller.service_arguments'] + + Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\DeprecatedSessionController: + autowire: true + autoconfigure: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml index 50078d4fd59c4..bfe7e24b338d7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml @@ -9,7 +9,7 @@ framework: test: true default_locale: en session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file services: logger: { class: Psr\Log\NullLogger } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Test/WebTestCaseTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Test/WebTestCaseTest.php index 96e1d8779b31e..0e1ed19e414df 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Test/WebTestCaseTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Test/WebTestCaseTest.php @@ -23,6 +23,7 @@ use Symfony\Component\HttpFoundation\Cookie as HttpFoundationCookie; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseFormatSame; class WebTestCaseTest extends TestCase { @@ -75,6 +76,20 @@ public function testAssertResponseRedirectsWithLocationAndStatusCode() $this->getResponseTester(new Response('', 302))->assertResponseRedirects('https://example.com/', 301); } + public function testAssertResponseFormat() + { + if (!class_exists(ResponseFormatSame::class)) { + $this->markTestSkipped('Too old version of HttpFoundation.'); + } + + $this->getResponseTester(new Response('', 200, ['Content-Type' => 'application/vnd.myformat']))->assertResponseFormatSame('custom'); + $this->getResponseTester(new Response('', 200, ['Content-Type' => 'application/ld+json']))->assertResponseFormatSame('jsonld'); + $this->getResponseTester(new Response())->assertResponseFormatSame(null); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage("Failed asserting that the Response format is jsonld.\nHTTP/1.0 200 OK"); + $this->getResponseTester(new Response())->assertResponseFormatSame('jsonld'); + } + public function testAssertResponseHasHeader() { $this->getResponseTester(new Response())->assertResponseHasHeader('Date'); @@ -284,6 +299,10 @@ private function getResponseTester(Response $response): WebTestCase $client = $this->createMock(KernelBrowser::class); $client->expects($this->any())->method('getResponse')->willReturn($response); + $request = new Request(); + $request->setFormat('custom', ['application/vnd.myformat']); + $client->expects($this->any())->method('getRequest')->willReturn($request); + return $this->getTester($client); } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 09c00ea6a16d6..a313abdf891f6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -19,24 +19,24 @@ "php": ">=7.2.5", "ext-xml": "*", "symfony/cache": "^5.2", - "symfony/config": "^5.0", - "symfony/dependency-injection": "^5.2", + "symfony/config": "^5.3", + "symfony/dependency-injection": "^5.3", "symfony/deprecation-contracts": "^2.1", "symfony/event-dispatcher": "^5.1", "symfony/error-handler": "^4.4.1|^5.0.1", - "symfony/http-foundation": "^5.2.1", + "symfony/http-foundation": "^5.3", "symfony/http-kernel": "^5.2.1", "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php80": "^1.15", "symfony/filesystem": "^4.4|^5.0", "symfony/finder": "^4.4|^5.0", - "symfony/routing": "^5.2" + "symfony/routing": "^5.3" }, "require-dev": { "doctrine/annotations": "^1.10.4", "doctrine/cache": "~1.0", "doctrine/persistence": "^1.3|^2.0", - "symfony/asset": "^5.1", + "symfony/asset": "^5.3", "symfony/browser-kit": "^4.4|^5.0", "symfony/console": "^5.2", "symfony/css-selector": "^4.4|^5.0", @@ -51,10 +51,8 @@ "symfony/messenger": "^5.2", "symfony/mime": "^4.4|^5.0", "symfony/process": "^4.4|^5.0", - "symfony/security-bundle": "^5.1", - "symfony/security-core": "^4.4|^5.2", - "symfony/security-csrf": "^4.4|^5.0", - "symfony/security-http": "^4.4|^5.0", + "symfony/rate-limiter": "^5.2", + "symfony/security-bundle": "^5.3", "symfony/serializer": "^5.2", "symfony/stopwatch": "^4.4|^5.0", "symfony/string": "^5.0", @@ -74,7 +72,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "phpunit/phpunit": "<5.4.3", - "symfony/asset": "<5.1", + "symfony/asset": "<5.3", "symfony/browser-kit": "<4.4", "symfony/console": "<5.2", "symfony/dotenv": "<5.1", @@ -88,6 +86,8 @@ "symfony/property-info": "<4.4", "symfony/property-access": "<5.2", "symfony/serializer": "<5.2", + "symfony/security-csrf": "<5.3", + "symfony/security-core": "<5.3", "symfony/stopwatch": "<4.4", "symfony/translation": "<5.0", "symfony/twig-bridge": "<4.4", diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 4e1ccb8d2b9fb..fe22755dd3099 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -1,6 +1,19 @@ CHANGELOG ========= +5.3 +--- + + * [BC break] Add `login_throttling.lock_factory` setting defaulting to `null` (instead of `lock.factory`) + * Add a `login_throttling.interval` (in `security.firewalls`) option to change the default throttling interval. + * Add the `debug:firewall` command. + * Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, + use `UserPasswordHashCommand` and `user:hash-password` instead + * Deprecate the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, + use `security.password_hasher_factory` and `Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface` instead + * Deprecate the `security.user_password_encoder.generic` service, the `security.password_encoder` and the `Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface` aliases, + use `security.user_password_hasher`, `security.password_hasher` and `Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface` instead + 5.2.0 ----- diff --git a/src/Symfony/Bundle/SecurityBundle/Command/DebugFirewallCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/DebugFirewallCommand.php new file mode 100644 index 0000000000000..c97bff11b454b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/DebugFirewallCommand.php @@ -0,0 +1,276 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Command; + +use Psr\Container\ContainerInterface; +use Symfony\Bundle\SecurityBundle\Security\FirewallContext; +use Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; + +/** + * @author Timo Bakx + */ +final class DebugFirewallCommand extends Command +{ + protected static $defaultName = 'debug:firewall'; + protected static $defaultDescription = 'Displays information about your security firewall(s)'; + + private $firewallNames; + private $contexts; + private $eventDispatchers; + private $authenticators; + private $authenticatorManagerEnabled; + + /** + * @param string[] $firewallNames + * @param AuthenticatorInterface[][] $authenticators + */ + public function __construct(array $firewallNames, ContainerInterface $contexts, ContainerInterface $eventDispatchers, array $authenticators, bool $authenticatorManagerEnabled) + { + $this->firewallNames = $firewallNames; + $this->contexts = $contexts; + $this->eventDispatchers = $eventDispatchers; + $this->authenticators = $authenticators; + $this->authenticatorManagerEnabled = $authenticatorManagerEnabled; + + parent::__construct(); + } + + protected function configure(): void + { + $exampleName = $this->getExampleName(); + + $this + ->setDescription(self::$defaultDescription) + ->setHelp(<<%command.name% command displays the firewalls that are configured +in your application: + + php %command.full_name% + +You can pass a firewall name to display more detailed information about +a specific firewall: + + php %command.full_name% $exampleName + +To include all events and event listeners for a specific firewall, use the +events option: + + php %command.full_name% --events $exampleName + +EOF + ) + ->setDefinition([ + new InputArgument('name', InputArgument::OPTIONAL, sprintf('A firewall name (for example "%s")', $exampleName)), + new InputOption('events', null, InputOption::VALUE_NONE, 'Include a list of event listeners (only available in combination with the "name" argument)'), + ]); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $name = $input->getArgument('name'); + + if (null === $name) { + $this->displayFirewallList($io); + + return 0; + } + + $serviceId = sprintf('security.firewall.map.context.%s', $name); + + if (!$this->contexts->has($serviceId)) { + $io->error(sprintf('Firewall %s was not found. Available firewalls are: %s', $name, implode(', ', $this->firewallNames))); + + return 1; + } + + /** @var FirewallContext $context */ + $context = $this->contexts->get($serviceId); + + $io->title(sprintf('Firewall "%s"', $name)); + + $this->displayFirewallSummary($name, $context, $io); + + $this->displaySwitchUser($context, $io); + + if ($input->getOption('events')) { + $this->displayEventListeners($name, $context, $io); + } + + if ($this->authenticatorManagerEnabled) { + $this->displayAuthenticators($name, $io); + } + + return 0; + } + + protected function displayFirewallList(SymfonyStyle $io): void + { + $io->title('Firewalls'); + $io->text('The following firewalls are defined:'); + + $io->listing($this->firewallNames); + + $io->comment(sprintf('To view details of a specific firewall, re-run this command with a firewall name. (e.g. debug:firewall %s)', $this->getExampleName())); + } + + protected function displayFirewallSummary(string $name, FirewallContext $context, SymfonyStyle $io): void + { + if (null === $context->getConfig()) { + return; + } + + $rows = [ + ['Name', $name], + ['Context', $context->getConfig()->getContext()], + ['Lazy', $context instanceof LazyFirewallContext ? 'Yes' : 'No'], + ['Stateless', $context->getConfig()->isStateless() ? 'Yes' : 'No'], + ['User Checker', $context->getConfig()->getUserChecker()], + ['Provider', $context->getConfig()->getProvider()], + ['Entry Point', $context->getConfig()->getEntryPoint()], + ['Access Denied URL', $context->getConfig()->getAccessDeniedUrl()], + ['Access Denied Handler', $context->getConfig()->getAccessDeniedHandler()], + ]; + + $io->table( + ['Option', 'Value'], + $rows + ); + } + + private function displaySwitchUser(FirewallContext $context, SymfonyStyle $io) + { + if ((null === $config = $context->getConfig()) || (null === $switchUser = $config->getSwitchUser())) { + return; + } + + $io->section('User switching'); + + $io->table(['Option', 'Value'], [ + ['Parameter', $switchUser['parameter'] ?? ''], + ['Provider', $switchUser['provider'] ?? $config->getProvider()], + ['User Role', $switchUser['role'] ?? ''], + ]); + } + + protected function displayEventListeners(string $name, FirewallContext $context, SymfonyStyle $io): void + { + $io->title(sprintf('Event listeners for firewall "%s"', $name)); + + $dispatcherId = sprintf('security.event_dispatcher.%s', $name); + + if (!$this->eventDispatchers->has($dispatcherId)) { + $io->text('No event dispatcher has been registered for this firewall.'); + + return; + } + + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = $this->eventDispatchers->get($dispatcherId); + + foreach ($dispatcher->getListeners() as $event => $listeners) { + $io->section(sprintf('"%s" event', $event)); + + $rows = []; + foreach ($listeners as $order => $listener) { + $rows[] = [ + sprintf('#%d', $order + 1), + $this->formatCallable($listener), + $dispatcher->getListenerPriority($event, $listener), + ]; + } + + $io->table( + ['Order', 'Callable', 'Priority'], + $rows + ); + } + } + + private function displayAuthenticators(string $name, SymfonyStyle $io): void + { + $io->title(sprintf('Authenticators for firewall "%s"', $name)); + + $authenticators = $this->authenticators[$name] ?? []; + + if (0 === \count($authenticators)) { + $io->text('No authenticators have been registered for this firewall.'); + + return; + } + + $io->table( + ['Classname'], + array_map( + static function ($authenticator) { + return [ + \get_class($authenticator), + ]; + }, + $authenticators + ) + ); + } + + private function formatCallable($callable): string + { + if (\is_array($callable)) { + if (\is_object($callable[0])) { + return sprintf('%s::%s()', \get_class($callable[0]), $callable[1]); + } + + return sprintf('%s::%s()', $callable[0], $callable[1]); + } + + if (\is_string($callable)) { + return sprintf('%s()', $callable); + } + + if ($callable instanceof \Closure) { + $r = new \ReflectionFunction($callable); + if (false !== strpos($r->name, '{closure}')) { + return 'Closure()'; + } + if ($class = $r->getClosureScopeClass()) { + return sprintf('%s::%s()', $class->name, $r->name); + } + + return $r->name.'()'; + } + + if (method_exists($callable, '__invoke')) { + return sprintf('%s::__invoke()', \get_class($callable)); + } + + throw new \InvalidArgumentException('Callable is not describable.'); + } + + private function getExampleName(): string + { + $name = 'main'; + + if (!\in_array($name, $this->firewallNames, true)) { + $name = reset($this->firewallNames); + } + + return $name; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php index de23fdd618a11..4b573a061d545 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\Encoder\SelfSaltingEncoderInterface; @@ -30,10 +31,13 @@ * @author Sarah Khalil * * @final + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHashCommand} instead */ class UserPasswordEncoderCommand extends Command { protected static $defaultName = 'security:encode-password'; + protected static $defaultDescription = 'Encodes a password'; private $encoderFactory; private $userClasses; @@ -52,7 +56,7 @@ public function __construct(EncoderFactoryInterface $encoderFactory, array $user protected function configure() { $this - ->setDescription('Encodes a password') + ->setDescription(self::$defaultDescription) ->addArgument('password', InputArgument::OPTIONAL, 'The plain password to encode.') ->addArgument('user-class', InputArgument::OPTIONAL, 'The User entity class path associated with the encoder used to encode the password.') ->addOption('empty-salt', null, InputOption::VALUE_NONE, 'Do not generate a salt or let the encoder generate one.') @@ -106,6 +110,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $errorIo = $output instanceof ConsoleOutputInterface ? new SymfonyStyle($input, $output->getErrorOutput()) : $io; + $errorIo->caution('The use of the "security:encode-password" command is deprecated since version 5.3 and will be removed in 6.0. Use "security:hash-password" instead.'); + $input->isInteractive() ? $errorIo->title('Symfony Password Encoder Utility') : $errorIo->newLine(); $password = $input->getArgument('password'); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterTokenUsageTrackingPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterTokenUsageTrackingPass.php index 1cd90fe70af1a..5ba017f51e386 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterTokenUsageTrackingPass.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterTokenUsageTrackingPass.php @@ -41,7 +41,7 @@ public function process(ContainerBuilder $container) TokenStorageInterface::class => new BoundArgument(new Reference('security.untracked_token_storage'), false), ]); - if (!$container->has('session')) { + if (!$container->has('session.factory') && !$container->has('session.storage')) { $container->setAlias('security.token_storage', 'security.untracked_token_storage')->setPublic(true); $container->getDefinition('security.untracked_token_storage')->addTag('kernel.reset', ['method' => 'reset']); } elseif ($container->hasDefinition('security.context_listener')) { diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 3d3000d8cd92d..6befc9319bfef 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -65,6 +65,23 @@ public function getConfigTreeBuilder() return $v; }) ->end() + ->beforeNormalization() + ->ifTrue(function ($v) { + if ($v['encoders'] ?? false) { + trigger_deprecation('symfony/security-bundle', '5.3', 'The child node "encoders" at path "security" is deprecated, use "password_hashers" instead.'); + + return true; + } + + return $v['password_hashers'] ?? false; + }) + ->then(function ($v) { + $v['password_hashers'] = array_merge($v['password_hashers'] ?? [], $v['encoders'] ?? []); + $v['encoders'] = $v['password_hashers']; + + return $v; + }) + ->end() ->children() ->scalarNode('access_denied_url')->defaultNull()->example('/foo/error403')->end() ->enumNode('session_fixation_strategy') @@ -94,6 +111,7 @@ public function getConfigTreeBuilder() ; $this->addEncodersSection($rootNode); + $this->addPasswordHashersSection($rootNode); $this->addProvidersSection($rootNode); $this->addFirewallsSection($rootNode, $this->factories); $this->addAccessControlSection($rootNode); @@ -401,6 +419,57 @@ private function addEncodersSection(ArrayNodeDefinition $rootNode) ; } + private function addPasswordHashersSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->fixXmlConfig('password_hasher') + ->children() + ->arrayNode('password_hashers') + ->example([ + 'App\Entity\User1' => 'auto', + 'App\Entity\User2' => [ + 'algorithm' => 'auto', + 'time_cost' => 8, + 'cost' => 13, + ], + ]) + ->requiresAtLeastOneElement() + ->useAttributeAsKey('class') + ->prototype('array') + ->canBeUnset() + ->performNoDeepMerging() + ->beforeNormalization()->ifString()->then(function ($v) { return ['algorithm' => $v]; })->end() + ->children() + ->scalarNode('algorithm') + ->cannotBeEmpty() + ->validate() + ->ifTrue(function ($v) { return !\is_string($v); }) + ->thenInvalid('You must provide a string value.') + ->end() + ->end() + ->arrayNode('migrate_from') + ->prototype('scalar')->end() + ->beforeNormalization()->castToArray()->end() + ->end() + ->scalarNode('hash_algorithm')->info('Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms.')->defaultValue('sha512')->end() + ->scalarNode('key_length')->defaultValue(40)->end() + ->booleanNode('ignore_case')->defaultFalse()->end() + ->booleanNode('encode_as_base64')->defaultTrue()->end() + ->scalarNode('iterations')->defaultValue(5000)->end() + ->integerNode('cost') + ->min(4) + ->max(31) + ->defaultNull() + ->end() + ->scalarNode('memory_cost')->defaultNull()->end() + ->scalarNode('time_cost')->defaultNull()->end() + ->scalarNode('id')->end() + ->end() + ->end() + ->end() + ->end(); + } + private function getAccessDecisionStrategies() { $strategies = [ diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php index a94c988d6308e..35921dd194fa2 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AuthenticatorFactoryInterface.php @@ -16,7 +16,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface AuthenticatorFactoryInterface { diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php index 67294b3111d63..4ebd8a0344159 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/CustomAuthenticatorFactory.php @@ -19,7 +19,7 @@ * @author Wouter de Jong * * @internal - * @experimental in 5.2 + * @experimental in 5.3 */ class CustomAuthenticatorFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface { diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php index 3afddb3d35f3b..8071108714f76 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php @@ -24,7 +24,7 @@ /** * @internal - * @experimental in 5.2 + * @experimental in 5.3 */ class LoginLinkFactory extends AbstractFactory implements AuthenticatorFactoryInterface { diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php index c0aa37ec88712..d9c3efd5f0faf 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php @@ -54,6 +54,8 @@ public function addConfiguration(NodeDefinition $builder) ->children() ->scalarNode('limiter')->info(sprintf('A service id implementing "%s".', RequestRateLimiterInterface::class))->end() ->integerNode('max_attempts')->defaultValue(5)->end() + ->scalarNode('interval')->defaultValue('1 minute')->end() + ->scalarNode('lock_factory')->info('The service ID of the lock factory used by the login rate limiter (or null to disable locking)')->defaultNull()->end() ->end(); } @@ -75,7 +77,8 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal $limiterOptions = [ 'policy' => 'fixed_window', 'limit' => $config['max_attempts'], - 'interval' => '1 minute', + 'interval' => $config['interval'], + 'lock_factory' => $config['lock_factory'], ]; FrameworkExtension::registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 57a97749b47e4..23b20c1aedc45 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection; +use Symfony\Bridge\Twig\Extension\LogoutUrlExtension; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FirewallListenerFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory; @@ -31,14 +32,18 @@ use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; use Symfony\Component\Security\Core\User\ChainUserProvider; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Event\CheckPassportEvent; -use Twig\Extension\AbstractExtension; /** * SecurityExtension. @@ -105,6 +110,7 @@ public function load(array $configs, ContainerBuilder $container) $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); $loader->load('security.php'); + $loader->load('password_hasher.php'); $loader->load('security_listeners.php'); $loader->load('security_rememberme.php'); @@ -125,7 +131,7 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('security_legacy.php'); } - if (class_exists(AbstractExtension::class)) { + if ($container::willBeAvailable('symfony/twig-bridge', LogoutUrlExtension::class, ['symfony/security-bundle'])) { $loader->load('templating_twig.php'); } @@ -136,7 +142,7 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('security_debug.php'); } - if (!class_exists(\Symfony\Component\ExpressionLanguage\ExpressionLanguage::class)) { + if (!$container::willBeAvailable('symfony/expression-language', ExpressionLanguage::class, ['symfony/security-bundle'])) { $container->removeDefinition('security.expression_language'); $container->removeDefinition('security.access.expression_voter'); } @@ -159,6 +165,12 @@ public function load(array $configs, ContainerBuilder $container) $container->setParameter('security.access.always_authenticate_before_granting', $config['always_authenticate_before_granting']); $container->setParameter('security.authentication.hide_user_not_found', $config['hide_user_not_found']); + if (class_exists(Application::class)) { + $loader->load('debug_console.php'); + $debugCommand = $container->getDefinition('security.command.debug_firewall'); + $debugCommand->replaceArgument(4, $this->authenticatorManagerEnabled); + } + $this->createFirewalls($config, $container); $this->createAuthorization($config, $container); $this->createRoleHierarchy($config, $container); @@ -166,13 +178,22 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('security.authentication.guard_handler') ->replaceArgument(2, $this->statelessFirewallKeys); + // @deprecated since Symfony 5.3 if ($config['encoders']) { $this->createEncoders($config['encoders'], $container); } + if ($config['password_hashers']) { + $this->createHashers($config['password_hashers'], $container); + } + if (class_exists(Application::class)) { $loader->load('console.php'); + + // @deprecated since Symfony 5.3 $container->getDefinition('security.command.user_password_encoder')->replaceArgument(1, array_keys($config['encoders'])); + + $container->getDefinition('security.command.user_password_hash')->replaceArgument(1, array_keys($config['password_hashers'])); } $container->registerForAutoconfiguration(VoterInterface::class) @@ -284,7 +305,10 @@ private function createFirewalls(array $config, ContainerBuilder $container) $contextRefs[$contextId] = new Reference($contextId); $map[$contextId] = $matcher; } - $mapDef->replaceArgument(0, ServiceLocatorTagPass::register($container, $contextRefs)); + + $container->setAlias('security.firewall.context_locator', (string) ServiceLocatorTagPass::register($container, $contextRefs)); + + $mapDef->replaceArgument(0, new Reference('security.firewall.context_locator')); $mapDef->replaceArgument(1, new IteratorArgument($map)); if (!$this->authenticatorManagerEnabled) { @@ -352,7 +376,8 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ // Register Firewall-specific event dispatcher $firewallEventDispatcherId = 'security.event_dispatcher.'.$id; - $container->register($firewallEventDispatcherId, EventDispatcher::class); + $container->register($firewallEventDispatcherId, EventDispatcher::class) + ->addTag('event_dispatcher.dispatcher'); // Register listeners $listeners = []; @@ -488,6 +513,10 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ ->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]); $listeners[] = new Reference('security.firewall.authenticator.'.$id); + + // Add authenticators to the debug:firewall command + $debugCommand = $container->getDefinition('security.command.debug_firewall'); + $debugCommand->replaceArgument(3, array_merge($debugCommand->getArgument(3), [$id => $authenticators])); } $config->replaceArgument(7, $configuredEntryPoint ?: $defaultEntryPoint); @@ -688,20 +717,20 @@ private function createEncoder(array $config) // Argon2i encoder if ('argon2i' === $config['algorithm']) { - if (SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { $config['algorithm'] = 'sodium'; } elseif (\defined('PASSWORD_ARGON2I')) { $config['algorithm'] = 'native'; $config['native_algorithm'] = \PASSWORD_ARGON2I; } else { - throw new InvalidConfigurationException(sprintf('Algorithm "argon2i" is not available. Either use "%s" or upgrade to PHP 7.2+ instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto')); + throw new InvalidConfigurationException(sprintf('Algorithm "argon2i" is not available. Use "%s" instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto')); } return $this->createEncoder($config); } if ('argon2id' === $config['algorithm']) { - if (($hasSodium = SodiumPasswordEncoder::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { $config['algorithm'] = 'sodium'; } elseif (\defined('PASSWORD_ARGON2ID')) { $config['algorithm'] = 'native'; @@ -716,6 +745,117 @@ private function createEncoder(array $config) if ('native' === $config['algorithm']) { return [ 'class' => NativePasswordEncoder::class, + 'arguments' => [ + $config['time_cost'], + (($config['memory_cost'] ?? 0) << 10) ?: null, + $config['cost'], + ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []), + ]; + } + + if ('sodium' === $config['algorithm']) { + if (!SodiumPasswordHasher::isSupported()) { + throw new InvalidConfigurationException('Libsodium is not available. Install the sodium extension or use "auto" instead.'); + } + + return [ + 'class' => SodiumPasswordEncoder::class, + 'arguments' => [ + $config['time_cost'], + (($config['memory_cost'] ?? 0) << 10) ?: null, + ], + ]; + } + + // run-time configured encoder + return $config; + } + + private function createHashers(array $hashers, ContainerBuilder $container) + { + $hasherMap = []; + foreach ($hashers as $class => $hasher) { + $hasherMap[$class] = $this->createHasher($hasher); + } + + $container + ->getDefinition('security.password_hasher_factory') + ->setArguments([$hasherMap]) + ; + } + + private function createHasher(array $config) + { + // a custom hasher service + if (isset($config['id'])) { + return new Reference($config['id']); + } + + if ($config['migrate_from'] ?? false) { + return $config; + } + + // plaintext hasher + if ('plaintext' === $config['algorithm']) { + $arguments = [$config['ignore_case']]; + + return [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => $arguments, + ]; + } + + // pbkdf2 hasher + if ('pbkdf2' === $config['algorithm']) { + return [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => [ + $config['hash_algorithm'], + $config['encode_as_base64'], + $config['iterations'], + $config['key_length'], + ], + ]; + } + + // bcrypt hasher + if ('bcrypt' === $config['algorithm']) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_BCRYPT; + + return $this->createHasher($config); + } + + // Argon2i hasher + if ('argon2i' === $config['algorithm']) { + if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2I')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2I; + } else { + throw new InvalidConfigurationException(sprintf('Algorithm "argon2i" is not available. Either use "%s" or upgrade to PHP 7.2+ instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto')); + } + + return $this->createHasher($config); + } + + if ('argon2id' === $config['algorithm']) { + if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2ID')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2ID; + } else { + throw new InvalidConfigurationException(sprintf('Algorithm "argon2id" is not available. Either use "%s", upgrade to PHP 7.3+ or use libsodium 1.0.15+ instead.', \defined('PASSWORD_ARGON2I') || $hasSodium ? 'argon2i", "auto' : 'auto')); + } + + return $this->createHasher($config); + } + + if ('native' === $config['algorithm']) { + return [ + 'class' => NativePasswordHasher::class, 'arguments' => [ $config['time_cost'], (($config['memory_cost'] ?? 0) << 10) ?: null, @@ -725,12 +865,12 @@ private function createEncoder(array $config) } if ('sodium' === $config['algorithm']) { - if (!SodiumPasswordEncoder::isSupported()) { + if (!SodiumPasswordHasher::isSupported()) { throw new InvalidConfigurationException('Libsodium is not available. Install the sodium extension or use "auto" instead.'); } return [ - 'class' => SodiumPasswordEncoder::class, + 'class' => SodiumPasswordHasher::class, 'arguments' => [ $config['time_cost'], (($config['memory_cost'] ?? 0) << 10) ?: null, @@ -738,7 +878,7 @@ private function createEncoder(array $config) ]; } - // run-time configured encoder + // run-time configured hasher return $config; } @@ -843,8 +983,8 @@ private function createExpression(ContainerBuilder $container, string $expressio return $this->expressions[$id]; } - if (!class_exists(\Symfony\Component\ExpressionLanguage\ExpressionLanguage::class)) { - throw new \RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + if (!$container::willBeAvailable('symfony/expression-language', ExpressionLanguage::class, ['symfony/security-bundle'])) { + throw new \RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); } $container diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php index a5ea6868a8bb6..5bfe8a2c3a2cf 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Bundle\SecurityBundle\Command\UserPasswordEncoderCommand; +use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand; return static function (ContainerConfigurator $container) { $container->services() @@ -21,5 +22,15 @@ abstract_arg('encoders user classes'), ]) ->tag('console.command', ['command' => 'security:encode-password']) + ->deprecate('symfony/security-bundle', '5.3', 'The "%service_id%" service is deprecated, use "security.command.user_password_hash" instead.') + ; + + $container->services() + ->set('security.command.user_password_hash', UserPasswordHashCommand::class) + ->args([ + service('security.password_hasher_factory'), + abstract_arg('list of user classes'), + ]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php new file mode 100644 index 0000000000000..242722f72452a --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/debug_console.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bundle\SecurityBundle\Command\DebugFirewallCommand; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.command.debug_firewall', DebugFirewallCommand::class) + ->args([ + param('security.firewalls'), + service('security.firewall.context_locator'), + tagged_locator('event_dispatcher.dispatcher'), + [], + false, + ]) + ->tag('console.command', ['command' => 'debug:firewall']) + ; +}; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php index f8b79cb3569d2..60677a94dec73 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php @@ -34,7 +34,7 @@ abstract_arg('User Provider'), abstract_arg('Provider-shared Key'), abstract_arg('User Checker'), - service('security.password_encoder'), + service('security.password_hasher'), ]) ->set('security.authentication.listener.guard', GuardAuthenticationListener::class) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php new file mode 100644 index 0000000000000..50e1be8d981cd --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.password_hasher_factory', PasswordHasherFactory::class) + ->args([[]]) + ->alias(PasswordHasherFactoryInterface::class, 'security.password_hasher_factory') + + ->set('security.user_password_hasher', UserPasswordHasher::class) + ->args([service('security.password_hasher_factory')]) + ->alias('security.password_hasher', 'security.user_password_hasher') + ->alias(UserPasswordHasherInterface::class, 'security.password_hasher') + ; +}; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd index 8ff0d5e46da0d..01e1e9eba0141 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd @@ -11,6 +11,8 @@ + + @@ -31,6 +33,12 @@ + + + + + + @@ -84,6 +92,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 380ef56b202b6..e4d3c49f881fe 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -18,6 +18,8 @@ use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext; use Symfony\Component\Ldap\Security\LdapUserProvider; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; @@ -73,7 +75,7 @@ ->args([ service('security.untracked_token_storage'), service_locator([ - 'session' => service('session'), + 'request_stack' => service('request_stack'), ]), ]) ->tag('kernel.reset', ['method' => 'disableUsageTracking']) @@ -109,13 +111,20 @@ ->args([ [], ]) + ->deprecate('symfony/security-bundle', '5.3', 'The "%service_id%" service is deprecated, use "security.password_hasher_factory" instead.') ->alias('security.encoder_factory', 'security.encoder_factory.generic') + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "security.password_hasher_factory" instead.') ->alias(EncoderFactoryInterface::class, 'security.encoder_factory') + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "'.PasswordHasherFactoryInterface::class.'" instead.') ->set('security.user_password_encoder.generic', UserPasswordEncoder::class) ->args([service('security.encoder_factory')]) - ->alias('security.password_encoder', 'security.user_password_encoder.generic')->public() + ->deprecate('symfony/security-bundle', '5.3', 'The "%service_id%" service is deprecated, use "security.user_password_hasher" instead.') + ->alias('security.password_encoder', 'security.user_password_encoder.generic') + ->public() + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "security.password_hasher"" instead.') ->alias(UserPasswordEncoderInterface::class, 'security.password_encoder') + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "'.UserPasswordHasherInterface::class.'" instead.') ->set('security.user_checker', UserChecker::class) @@ -260,7 +269,7 @@ ->set('security.validator.user_password', UserPasswordValidator::class) ->args([ service('security.token_storage'), - service('security.encoder_factory'), + service('security.password_hasher_factory'), ]) ->tag('validator.constraint_validator', ['alias' => 'security.validator.user_password']) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php index 3d0c6ddcb4f9e..1bd7723634f38 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php @@ -72,7 +72,7 @@ // Listeners ->set('security.listener.check_authenticator_credentials', CheckCredentialsListener::class) ->args([ - service('security.encoder_factory'), + service('security.password_hasher_factory'), ]) ->tag('kernel.event_subscriber') @@ -90,7 +90,7 @@ ->set('security.listener.password_migrating', PasswordMigratingListener::class) ->args([ - service('security.encoder_factory'), + service('security.password_hasher_factory'), ]) ->tag('kernel.event_subscriber') diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php index 7683ea2484031..aa6a522de1890 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php @@ -221,7 +221,7 @@ abstract_arg('User Provider'), abstract_arg('User Checker'), abstract_arg('Provider-shared Key'), - service('security.encoder_factory'), + service('security.password_hasher_factory'), param('security.authentication.hide_user_not_found'), ]) diff --git a/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php index ba4af81acd603..70ac67a865d15 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Security/UserAuthenticator.php @@ -27,7 +27,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class UserAuthenticator implements UserAuthenticatorInterface { diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Compiler/RegisterTokenUsageTrackingPassTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Compiler/RegisterTokenUsageTrackingPassTest.php index afdbf9afaf60f..993601ee8e43e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Compiler/RegisterTokenUsageTrackingPassTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Compiler/RegisterTokenUsageTrackingPassTest.php @@ -18,6 +18,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\SessionFactory; +use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; +use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorageFactory; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\UsageTrackingTokenStorage; use Symfony\Component\Security\Http\Firewall\ContextListener; @@ -65,7 +68,7 @@ public function testContextListenerEnablesUsageTrackingIfSupportedByTokenStorage $container = new ContainerBuilder(); $container->setParameter('security.token_storage.class', UsageTrackingTokenStorage::class); - $container->register('session', Session::class); + $container->register('session.factory', SessionFactory::class); $container->register('security.context_listener', ContextListener::class) ->setArguments([ new Reference('security.untracked_token_storage'), diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php index 072e33aca6f4d..7068821286339 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -18,6 +18,10 @@ use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; @@ -275,9 +279,12 @@ public function testMerge() ], $container->getParameter('security.role_hierarchy.roles')); } + /** + * @group legacy + */ public function testEncoders() { - $container = $this->getContainer('container1'); + $container = $this->getContainer('legacy_encoders'); $this->assertEquals([[ 'JMS\FooBundle\Entity\User1' => [ @@ -332,6 +339,9 @@ public function testEncoders() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithLibsodium() { if (!SodiumPasswordEncoder::isSupported()) { @@ -385,6 +395,9 @@ public function testEncodersWithLibsodium() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithArgon2i() { if (!($sodium = SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { @@ -438,6 +451,9 @@ public function testEncodersWithArgon2i() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testMigratingEncoder() { if (!($sodium = SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { @@ -499,6 +515,9 @@ public function testMigratingEncoder() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithBCrypt() { $container = $this->getContainer('bcrypt_encoder'); @@ -548,6 +567,279 @@ public function testEncodersWithBCrypt() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + public function testHashers() + { + $container = $this->getContainer('container1'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [false], + ], + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'auto', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testHashersWithLibsodium() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + + $container = $this->getContainer('sodium_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [false], + ], + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'class' => SodiumPasswordHasher::class, + 'arguments' => [8, 128 * 1024 * 1024], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testHashersWithArgon2i() + { + if (!($sodium = SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm is not supported.'); + } + + $container = $this->getContainer('argon2i_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [false], + ], + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'class' => $sodium ? SodiumPasswordHasher::class : NativePasswordHasher::class, + 'arguments' => $sodium ? [256, 1] : [1, 262144, null, \PASSWORD_ARGON2I], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testMigratingHasher() + { + if (!($sodium = SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm is not supported.'); + } + + $container = $this->getContainer('migrating_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [false], + ], + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'argon2i', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => 256, + 'time_cost' => 1, + 'migrate_from' => ['bcrypt'], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testHashersWithBCrypt() + { + $container = $this->getContainer('bcrypt_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [false], + ], + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [null, null, 15, \PASSWORD_BCRYPT], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + public function testRememberMeThrowExceptionsDefault() { $container = $this->getContainer('container1'); @@ -577,9 +869,9 @@ public function testUserCheckerConfigWithNoCheckers() $this->assertEquals('security.user_checker', $this->getContainer('container1')->getAlias('security.user_checker.secure')); } - public function testUserPasswordEncoderCommandIsRegistered() + public function testUserPasswordHasherCommandIsRegistered() { - $this->assertTrue($this->getContainer('remember_me_options')->has('security.command.user_password_encoder')); + $this->assertTrue($this->getContainer('remember_me_options')->has('security.command.user_password_hash')); } public function testDefaultAccessDecisionManagerStrategyIsAffirmative() diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php index ddac043692cf1..ba1e1328b069d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php new file mode 100644 index 0000000000000..341f772e87523 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php @@ -0,0 +1,13 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'argon2i', + 'memory_cost' => 256, + 'time_cost' => 1, + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php index d4511aeb554c7..0a0a69b6dec0d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php new file mode 100644 index 0000000000000..a416b3440d426 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php @@ -0,0 +1,12 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'bcrypt', + 'cost' => 15, + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php index 3c9e6104eecc3..f551131f00639 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php @@ -1,7 +1,7 @@ loadFromExtension('security', [ - 'encoders' => [ + 'password_hashers' => [ 'JMS\FooBundle\Entity\User1' => 'plaintext', 'JMS\FooBundle\Entity\User2' => [ 'algorithm' => 'sha1', @@ -12,7 +12,7 @@ 'algorithm' => 'md5', ], 'JMS\FooBundle\Entity\User4' => [ - 'id' => 'security.encoder.foo', + 'id' => 'security.hasher.foo', ], 'JMS\FooBundle\Entity\User5' => [ 'algorithm' => 'pbkdf2', diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php new file mode 100644 index 0000000000000..3c9e6104eecc3 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php @@ -0,0 +1,108 @@ +loadFromExtension('security', [ + 'encoders' => [ + 'JMS\FooBundle\Entity\User1' => 'plaintext', + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + ], + 'JMS\FooBundle\Entity\User4' => [ + 'id' => 'security.encoder.foo', + ], + 'JMS\FooBundle\Entity\User5' => [ + 'algorithm' => 'pbkdf2', + 'hash_algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'key_length' => 30, + ], + 'JMS\FooBundle\Entity\User6' => [ + 'algorithm' => 'native', + 'time_cost' => 8, + 'memory_cost' => 100, + 'cost' => 15, + ], + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'auto', + ], + ], + 'providers' => [ + 'default' => [ + 'memory' => [ + 'users' => [ + 'foo' => ['password' => 'foo', 'roles' => 'ROLE_USER'], + ], + ], + ], + 'digest' => [ + 'memory' => [ + 'users' => [ + 'foo' => ['password' => 'foo', 'roles' => 'ROLE_USER, ROLE_ADMIN'], + ], + ], + ], + 'basic' => [ + 'memory' => [ + 'users' => [ + 'foo' => ['password' => '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', 'roles' => 'ROLE_SUPER_ADMIN'], + 'bar' => ['password' => '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', 'roles' => ['ROLE_USER', 'ROLE_ADMIN']], + ], + ], + ], + 'service' => [ + 'id' => 'user.manager', + ], + 'chain' => [ + 'chain' => [ + 'providers' => ['service', 'basic'], + ], + ], + ], + + 'firewalls' => [ + 'simple' => ['provider' => 'default', 'pattern' => '/login', 'security' => false], + 'secure' => ['stateless' => true, + 'provider' => 'default', + 'http_basic' => true, + 'form_login' => true, + 'anonymous' => true, + 'switch_user' => true, + 'x509' => true, + 'remote_user' => true, + 'logout' => true, + 'remember_me' => ['secret' => 'TheSecret'], + 'user_checker' => null, + ], + 'host' => [ + 'provider' => 'default', + 'pattern' => '/test', + 'host' => 'foo\\.example\\.org', + 'methods' => ['GET', 'POST'], + 'anonymous' => true, + 'http_basic' => true, + ], + 'with_user_checker' => [ + 'provider' => 'default', + 'user_checker' => 'app.user_checker', + 'anonymous' => true, + 'http_basic' => true, + ], + ], + + 'access_control' => [ + ['path' => '/blog/524', 'role' => 'ROLE_USER', 'requires_channel' => 'https', 'methods' => ['get', 'POST'], 'port' => 8000], + ['path' => '/blog/.*', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY'], + ['path' => '/blog/524', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY', 'allow_if' => "token.getUsername() matches '/^admin/'"], + ], + + 'role_hierarchy' => [ + 'ROLE_ADMIN' => 'ROLE_USER', + 'ROLE_SUPER_ADMIN' => ['ROLE_USER', 'ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'], + 'ROLE_REMOTE' => 'ROLE_USER,ROLE_ADMIN', + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php index c7ad9f02ab4f5..04a800a218c59 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php new file mode 100644 index 0000000000000..342ea64805eff --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php @@ -0,0 +1,14 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'argon2i', + 'memory_cost' => 256, + 'time_cost' => 1, + 'migrate_from' => 'bcrypt', + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php index ec0851bdfaa34..3239ed027422b 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php new file mode 100644 index 0000000000000..3ec569ae9a6e2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php @@ -0,0 +1,13 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'sodium', + 'time_cost' => 8, + 'memory_cost' => 128 * 1024, + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml index a4346f824ed14..d18ecd939cbb3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml new file mode 100644 index 0000000000000..3dc2c685be321 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml index d81f3aa73af26..2ac6f38dd476c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml new file mode 100644 index 0000000000000..d4c5d3ded1a11 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml index 84d68cc4fd59b..097a726db58d2 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml @@ -9,19 +9,19 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + - + - + - + - + - + - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml new file mode 100644 index 0000000000000..84d68cc4fd59b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + app.user_checker + + + ROLE_USER + ROLE_USER,ROLE_ADMIN,ROLE_ALLOWED_TO_SWITCH + ROLE_USER,ROLE_ADMIN + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml index db0ca61b60017..a4bd11688e288 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml new file mode 100644 index 0000000000000..a4a9d2010dd71 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + bcrypt + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml index 09e6cacef323f..80ccadf4511cb 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml new file mode 100644 index 0000000000000..fd5cacef7b8a4 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml index cadf8eb1e98d2..f4571e678db08 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml new file mode 100644 index 0000000000000..1079d6e5f8efc --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml @@ -0,0 +1,9 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: argon2i + memory_cost: 256 + time_cost: 1 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml index 3f1a526215204..a5bd7d9b3bbce 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml new file mode 100644 index 0000000000000..8e8397486d68e --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml @@ -0,0 +1,8 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: bcrypt + cost: 15 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml index 03b9aaf6ef5b9..0ac2c94b0680b 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml @@ -1,5 +1,5 @@ security: - encoders: + password_hashers: JMS\FooBundle\Entity\User1: plaintext JMS\FooBundle\Entity\User2: algorithm: sha1 @@ -8,7 +8,7 @@ security: JMS\FooBundle\Entity\User3: algorithm: md5 JMS\FooBundle\Entity\User4: - id: security.encoder.foo + id: security.hasher.foo JMS\FooBundle\Entity\User5: algorithm: pbkdf2 hash_algorithm: sha1 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml new file mode 100644 index 0000000000000..03b9aaf6ef5b9 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml @@ -0,0 +1,87 @@ +security: + encoders: + JMS\FooBundle\Entity\User1: plaintext + JMS\FooBundle\Entity\User2: + algorithm: sha1 + encode_as_base64: false + iterations: 5 + JMS\FooBundle\Entity\User3: + algorithm: md5 + JMS\FooBundle\Entity\User4: + id: security.encoder.foo + JMS\FooBundle\Entity\User5: + algorithm: pbkdf2 + hash_algorithm: sha1 + encode_as_base64: false + iterations: 5 + key_length: 30 + JMS\FooBundle\Entity\User6: + algorithm: native + time_cost: 8 + memory_cost: 100 + cost: 15 + JMS\FooBundle\Entity\User7: + algorithm: auto + + providers: + default: + memory: + users: + foo: { password: foo, roles: ROLE_USER } + digest: + memory: + users: + foo: { password: foo, roles: 'ROLE_USER, ROLE_ADMIN' } + basic: + memory: + users: + foo: { password: 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33, roles: ROLE_SUPER_ADMIN } + bar: { password: 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33, roles: [ROLE_USER, ROLE_ADMIN] } + service: + id: user.manager + chain: + chain: + providers: [service, basic] + + + firewalls: + simple: { pattern: /login, security: false } + secure: + provider: default + stateless: true + http_basic: true + form_login: true + anonymous: true + switch_user: + x509: true + remote_user: true + logout: true + remember_me: + secret: TheSecret + user_checker: ~ + + host: + provider: default + pattern: /test + host: foo\.example\.org + methods: [GET,POST] + anonymous: true + http_basic: true + + with_user_checker: + provider: default + anonymous: ~ + http_basic: ~ + user_checker: app.user_checker + + role_hierarchy: + ROLE_ADMIN: ROLE_USER + ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH] + ROLE_REMOTE: ROLE_USER,ROLE_ADMIN + + access_control: + - { path: /blog/524, role: ROLE_USER, requires_channel: https, methods: [get, POST], port: 8000} + - + path: /blog/.* + role: IS_AUTHENTICATED_ANONYMOUSLY + - { path: /blog/524, role: IS_AUTHENTICATED_ANONYMOUSLY, allow_if: "token.getUsername() matches '/^admin/'" } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml index 9eda61c18866f..87943cac128ff 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml new file mode 100644 index 0000000000000..8657b1ee744ad --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml @@ -0,0 +1,10 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: argon2i + memory_cost: 256 + time_cost: 1 + migrate_from: bcrypt diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml index 2d70ef0d9b42a..70b4455ce2ebe 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml new file mode 100644 index 0000000000000..955a0b2a2059c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml @@ -0,0 +1,9 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: sodium + time_cost: 8 + memory_cost: 131072 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php index 3ffa6cb015bc7..03db66cc98a6d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php @@ -398,6 +398,7 @@ public function sessionConfigurationProvider() ], [ [ + 'storage_factory_id' => 'session.storage.factory.native', 'cookie_secure' => true, 'cookie_samesite' => 'lax', 'save_path' => null, diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php index f79028cb20719..05b27e82acf47 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php @@ -109,10 +109,9 @@ public function testFormLoginRedirectsToProtectedResourceAfterLogin(array $optio } /** - * @dataProvider provideInvalidCredentials * @group time-sensitive */ - public function testLoginThrottling($username, $password) + public function testLoginThrottling() { if (!class_exists(LoginThrottlingListener::class)) { $this->markTestSkipped('Login throttling requires symfony/security-http:^5.2'); @@ -120,24 +119,38 @@ public function testLoginThrottling($username, $password) $client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'login_throttling.yml', 'enable_authenticator_manager' => true]); - $form = $client->request('GET', '/login')->selectButton('login')->form(); - $form['_username'] = $username; - $form['_password'] = $password; - $client->submit($form); - - $client->followRedirect()->selectButton('login')->form(); - $form['_username'] = $username; - $form['_password'] = $password; - $client->submit($form); - - $text = $client->followRedirect()->text(null, true); - $this->assertStringMatchesFormat('%sToo many failed login attempts, please try again in %d minute%s', $text); - } - - public function provideInvalidCredentials() - { - yield 'invalid_password' => ['johannes', 'wrong']; - yield 'invalid_username' => ['wrong', 'wrong']; + $attempts = [ + ['johannes', 'wrong'], + ['johannes', 'also_wrong'], + ['wrong', 'wrong'], + ['johannes', 'wrong_again'], + ]; + foreach ($attempts as $i => $attempt) { + $form = $client->request('GET', '/login')->selectButton('login')->form(); + $form['_username'] = $attempt[0]; + $form['_password'] = $attempt[1]; + $client->submit($form); + + $text = $client->followRedirect()->text(null, true); + switch ($i) { + case 0: // First attempt : Invalid credentials (OK) + $this->assertStringContainsString('Invalid credentials', $text, 'Invalid response on 1st attempt'); + + break; + case 1: // Second attempt : login throttling ! + $this->assertStringContainsString('Too many failed login attempts, please try again in 8 minutes.', $text, 'Invalid response on 2nd attempt'); + + break; + case 2: // Third attempt with unexisting username + $this->assertStringContainsString('Username could not be found.', $text, 'Invalid response on 3rd attempt'); + + break; + case 3: // Fourth attempt : still login throttling ! + $this->assertStringContainsString('Too many failed login attempts, please try again in 8 minutes.', $text, 'Invalid response on 4th attempt'); + + break; + } + } } public function provideClientOptions() diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php index b5e2b48487895..1c032522edf7f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php @@ -11,7 +11,12 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Functional; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Component\BrowserKit\Cookie; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; class LogoutTest extends AbstractWebTestCase { @@ -44,19 +49,26 @@ public function testSessionLessRememberMeLogout(array $options) public function testCsrfTokensAreClearedOnLogout(array $options) { $client = $this->createClient($options + ['test_case' => 'LogoutWithoutSessionInvalidation', 'root_config' => 'config.yml']); - static::$container->get('security.csrf.token_storage')->setToken('foo', 'bar'); + $client->disableReboot(); + $this->callInRequestContext($client, function () { + static::$container->get('security.csrf.token_storage')->setToken('foo', 'bar'); + }); $client->request('POST', '/login', [ '_username' => 'johannes', '_password' => 'test', ]); - $this->assertTrue(static::$container->get('security.csrf.token_storage')->hasToken('foo')); - $this->assertSame('bar', static::$container->get('security.csrf.token_storage')->getToken('foo')); + $this->callInRequestContext($client, function () { + $this->assertTrue(static::$container->get('security.csrf.token_storage')->hasToken('foo')); + $this->assertSame('bar', static::$container->get('security.csrf.token_storage')->getToken('foo')); + }); $client->request('GET', '/logout'); - $this->assertFalse(static::$container->get('security.csrf.token_storage')->hasToken('foo')); + $this->callInRequestContext($client, function () { + $this->assertFalse(static::$container->get('security.csrf.token_storage')->hasToken('foo')); + }); } /** @@ -85,4 +97,22 @@ public function testCookieClearingOnLogout() $this->assertRedirect($client->getResponse(), '/'); $this->assertNull($cookieJar->get('flavor')); } + + private function callInRequestContext(KernelBrowser $client, callable $callable): void + { + /** @var EventDispatcherInterface $eventDispatcher */ + $eventDispatcher = static::$container->get(EventDispatcherInterface::class); + $wrappedCallable = function (RequestEvent $event) use (&$callable) { + $callable(); + $event->setResponse(new Response('')); + $event->stopPropagation(); + }; + + $eventDispatcher->addListener(KernelEvents::REQUEST, $wrappedCallable); + try { + $client->request('GET', '/'.uniqid('', true)); + } finally { + $eventDispatcher->removeListener(KernelEvents::REQUEST, $wrappedCallable); + } + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php index 5846f386b7fca..cab763489393c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php @@ -24,6 +24,7 @@ * Tests UserPasswordEncoderCommand. * * @author Sarah Khalil + * @group legacy */ class UserPasswordEncoderCommandTest extends AbstractWebTestCase { @@ -40,7 +41,7 @@ public function testEncodePasswordEmptySalt() ], ['decorated' => false]); $expected = str_replace("\n", \PHP_EOL, file_get_contents(__DIR__.'/app/PasswordEncode/emptysalt.txt')); - $this->assertEquals($expected, $this->passwordEncoderCommandTester->getDisplay()); + $this->assertStringContainsString($expected, $this->passwordEncoderCommandTester->getDisplay()); } public function testEncodeNoPasswordNoInteraction() diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml index 5c86da6252789..2fc91cbcbfd72 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml @@ -9,7 +9,7 @@ services: security: - encoders: + password_hashers: \Symfony\Component\Security\Core\User\UserInterface: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Anonymous/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Anonymous/config.yml index c0f9a7c19115f..9d804818d8885 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Anonymous/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Anonymous/config.yml @@ -7,7 +7,7 @@ framework: test: ~ default_locale: en session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file profiler: { only_exceptions: false } services: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/config.yml index 45bde5bda3f22..1beade7dbe6f4 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/config.yml @@ -5,7 +5,7 @@ framework: default_locale: en profiler: false session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file services: logger: { class: Psr\Log\NullLogger } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml index a364148198d31..cb14f50bc2715 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml @@ -1,7 +1,7 @@ security: enable_authenticator_manager: true - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml index 274ef33204130..65419d2d460a3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml index 6b82dea8de8ec..201e0b8fd1d4f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml @@ -15,7 +15,7 @@ services: - { name: container.service_subscriber } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml index 25ef98650e419..b862b04a63bc3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml @@ -9,7 +9,7 @@ framework: test: ~ default_locale: en session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file profiler: { only_exceptions: false } services: @@ -28,5 +28,5 @@ security: memory: users: john: { password: doe, roles: [ROLE_SECURE] } - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml index 101d0c5b1b52c..81ef3399a97ba 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml @@ -5,7 +5,7 @@ framework: default_locale: en profiler: false session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file services: logger: { class: Psr\Log\NullLogger } @@ -14,7 +14,7 @@ services: tags: [controller.service_arguments] security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml index 055fcee19bd94..5bb3de09a9850 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml @@ -5,7 +5,7 @@ framework: serializer: ~ security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml index c5076cce6fc27..a725338eceec5 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml index f49d2f292b770..433e059fe3aa1 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml index f62cc616557a5..a97b1a3a9a9a6 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml index 9d92ac82c3c63..21933f99d74c0 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml index 7f334ffcaee2f..caadeeb7a86e2 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml @@ -3,11 +3,12 @@ imports: framework: session: + storage_factory_id: session.storage.factory.mock_file cookie_secure: auto cookie_samesite: lax security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml index b35ad3f4c91d2..4f3affbf24ea3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml @@ -6,7 +6,7 @@ parameters: env(APP_IPS): '127.0.0.1, ::1' security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml index c9fe56e56c739..8254631e51228 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/default.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml index ced854a6819c9..1a6df70790cde 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/default.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml index b07be914d45f2..5daa020a6a1cc 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/default.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml index 4848567cf3360..c445ce6963841 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/login_throttling.yml @@ -10,3 +10,4 @@ security: default: login_throttling: max_attempts: 1 + interval: '8 minutes' diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml index e145253080d71..94a00c01fc367 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml @@ -10,7 +10,7 @@ framework: test: ~ default_locale: en session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file profiler: { only_exceptions: false } services: diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 1d1e8a490bca0..05688dd016c2b 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -19,15 +19,16 @@ "php": ">=7.2.5", "ext-xml": "*", "symfony/config": "^4.4|^5.0", - "symfony/dependency-injection": "^5.2", + "symfony/dependency-injection": "^5.3", "symfony/deprecation-contracts": "^2.1", "symfony/event-dispatcher": "^5.1", "symfony/http-kernel": "^5.0", + "symfony/password-hasher": "^5.3", "symfony/polyfill-php80": "^1.15", - "symfony/security-core": "^5.2", + "symfony/security-core": "^5.3", "symfony/security-csrf": "^4.4|^5.0", - "symfony/security-guard": "^5.2", - "symfony/security-http": "^5.2" + "symfony/security-guard": "^5.3", + "symfony/security-http": "^5.3" }, "require-dev": { "doctrine/doctrine-bundle": "^2.0", @@ -38,7 +39,7 @@ "symfony/dom-crawler": "^4.4|^5.0", "symfony/expression-language": "^4.4|^5.0", "symfony/form": "^4.4|^5.0", - "symfony/framework-bundle": "^5.2", + "symfony/framework-bundle": "^5.3", "symfony/process": "^4.4|^5.0", "symfony/rate-limiter": "^5.2", "symfony/serializer": "^4.4|^5.0", diff --git a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md index be47f246de147..b1642ea1af00b 100644 --- a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3.0 +----- + +* Added support for the new `serialize` filter (from Twig Bridge) + 5.2.0 ----- @@ -33,7 +38,7 @@ CHANGELOG 4.1.0 ----- - * added priority to Twig extensions + * added priority to Twig extensions * deprecated relying on the default value (`false`) of the `twig.strict_variables` configuration option. The `%kernel.debug%` parameter will be the new default in 5.0 4.0.0 diff --git a/src/Symfony/Bundle/TwigBundle/Command/LintCommand.php b/src/Symfony/Bundle/TwigBundle/Command/LintCommand.php index f6e6b054faf56..9982876010b90 100644 --- a/src/Symfony/Bundle/TwigBundle/Command/LintCommand.php +++ b/src/Symfony/Bundle/TwigBundle/Command/LintCommand.php @@ -22,6 +22,9 @@ */ final class LintCommand extends BaseLintCommand { + protected static $defaultName = 'lint:twig'; + protected static $defaultDescription = 'Lints a Twig template and outputs encountered errors'; + /** * {@inheritdoc} */ diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php index eb6eecc95fac8..a18de86e7b02d 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php @@ -11,10 +11,14 @@ namespace Symfony\Bundle\TwigBundle\DependencyInjection\Compiler; +use Symfony\Component\Asset\Packages; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Workflow\Workflow; +use Symfony\Component\Yaml\Yaml; /** * @author Jean-François Simon @@ -23,19 +27,19 @@ class ExtensionPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { - if (!class_exists(\Symfony\Component\Asset\Packages::class)) { + if (!$container::willBeAvailable('symfony/asset', Packages::class, ['symfony/twig-bundle'])) { $container->removeDefinition('twig.extension.assets'); } - if (!class_exists(\Symfony\Component\ExpressionLanguage\Expression::class)) { + if (!$container::willBeAvailable('symfony/expression-language', Expression::class, ['symfony/twig-bundle'])) { $container->removeDefinition('twig.extension.expression'); } - if (!interface_exists(\Symfony\Component\Routing\Generator\UrlGeneratorInterface::class)) { + if (!$container::willBeAvailable('symfony/routing', UrlGeneratorInterface::class, ['symfony/twig-bundle'])) { $container->removeDefinition('twig.extension.routing'); } - if (!class_exists(\Symfony\Component\Yaml\Yaml::class)) { + if (!$container::willBeAvailable('symfony/yaml', Yaml::class, ['symfony/twig-bundle'])) { $container->removeDefinition('twig.extension.yaml'); } @@ -111,10 +115,15 @@ public function process(ContainerBuilder $container) $container->getDefinition('twig.extension.expression')->addTag('twig.extension'); } - if (!class_exists(Workflow::class) || !$container->has('workflow.registry')) { + if (!$container::willBeAvailable('symfony/workflow', Workflow::class, ['symfony/twig-bundle']) || !$container->has('workflow.registry')) { $container->removeDefinition('workflow.twig_extension'); } else { $container->getDefinition('workflow.twig_extension')->addTag('twig.extension'); } + + if ($container->has('serializer')) { + $container->getDefinition('twig.runtime.serializer')->addTag('twig.runtime'); + $container->getDefinition('twig.extension.serializer')->addTag('twig.extension'); + } } } diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index 5ccc9a1a04c3a..20095eb45a79d 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Form\Form; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Translation\Translator; @@ -37,19 +38,19 @@ public function load(array $configs, ContainerBuilder $container) $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('twig.php'); - if (class_exists(\Symfony\Component\Form\Form::class)) { + if ($container::willBeAvailable('symfony/form', Form::class, ['symfony/twig-bundle'])) { $loader->load('form.php'); } - if (class_exists(Application::class)) { + if ($container::willBeAvailable('symfony/console', Application::class, ['symfony/twig-bundle'])) { $loader->load('console.php'); } - if (class_exists(Mailer::class)) { + if ($container::willBeAvailable('symfony/mailer', Mailer::class, ['symfony/twig-bundle'])) { $loader->load('mailer.php'); } - if (!class_exists(Translator::class)) { + if (!$container::willBeAvailable('symfony/translation', Translator::class, ['symfony/twig-bundle'])) { $container->removeDefinition('twig.translation.extractor'); } diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/console.php b/src/Symfony/Bundle/TwigBundle/Resources/config/console.php index 9abd75da19ffc..0dc7ebdb7a5ad 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/console.php @@ -24,10 +24,10 @@ param('twig.default_path'), service('debug.file_link_formatter')->nullOnInvalid(), ]) - ->tag('console.command', ['command' => 'debug:twig']) + ->tag('console.command') ->set('twig.command.lint', LintCommand::class) ->args([service('twig')]) - ->tag('console.command', ['command' => 'lint:twig']) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php index a7124a30c20aa..aa71e0de86b15 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php @@ -23,6 +23,8 @@ use Symfony\Bridge\Twig\Extension\HttpKernelRuntime; use Symfony\Bridge\Twig\Extension\ProfilerExtension; use Symfony\Bridge\Twig\Extension\RoutingExtension; +use Symfony\Bridge\Twig\Extension\SerializerExtension; +use Symfony\Bridge\Twig\Extension\SerializerRuntime; use Symfony\Bridge\Twig\Extension\StopwatchExtension; use Symfony\Bridge\Twig\Extension\TranslationExtension; use Symfony\Bridge\Twig\Extension\WebLinkExtension; @@ -160,5 +162,10 @@ ->factory([TwigErrorRenderer::class, 'isDebug']) ->args([service('request_stack'), param('kernel.debug')]), ]) + + ->set('twig.runtime.serializer', SerializerRuntime::class) + ->args([service('serializer')]) + + ->set('twig.extension.serializer', SerializerExtension::class) ; }; diff --git a/src/Symfony/Bundle/TwigBundle/composer.json b/src/Symfony/Bundle/TwigBundle/composer.json index 7c5c15fbce4d5..6bc5bf61794bd 100644 --- a/src/Symfony/Bundle/TwigBundle/composer.json +++ b/src/Symfony/Bundle/TwigBundle/composer.json @@ -27,7 +27,7 @@ "require-dev": { "symfony/asset": "^4.4|^5.0", "symfony/stopwatch": "^4.4|^5.0", - "symfony/dependency-injection": "^5.2", + "symfony/dependency-injection": "^5.3", "symfony/expression-language": "^4.4|^5.0", "symfony/finder": "^4.4|^5.0", "symfony/form": "^4.4|^5.0", @@ -40,7 +40,7 @@ "doctrine/cache": "~1.0" }, "conflict": { - "symfony/dependency-injection": "<5.2", + "symfony/dependency-injection": "<5.3", "symfony/framework-bundle": "<5.0", "symfony/translation": "<5.0" }, diff --git a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php index e583b70c2ea1a..64e0ad9fc386d 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php +++ b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php @@ -60,6 +60,15 @@ public function isEnabled(): bool return self::DISABLED !== $this->mode; } + public function setMode(int $mode): void + { + if (self::DISABLED !== $mode && self::ENABLED !== $mode) { + throw new \InvalidArgumentException(sprintf('Invalid value provided for mode, use one of "%s::DISABLED" or "%s::ENABLED".', self::class, self::class)); + } + + $this->mode = $mode; + } + public function onKernelResponse(ResponseEvent $event) { $response = $event->getResponse(); diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php index 1d3bcf33cdcf1..32e9d936fb8a0 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php @@ -43,7 +43,7 @@ protected function configureContainer(ContainerBuilder $containerBuilder, Loader $containerBuilder->loadFromExtension('framework', [ 'secret' => 'foo-secret', 'profiler' => ['only_exceptions' => false], - 'session' => ['storage_id' => 'session.storage.mock_file'], + 'session' => ['storage_factory_id' => 'session.storage.factory.mock_file'], 'router' => ['utf8' => true], ]); diff --git a/src/Symfony/Bundle/WebProfilerBundle/composer.json b/src/Symfony/Bundle/WebProfilerBundle/composer.json index 46b7e78567865..277f8095c0686 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/composer.json +++ b/src/Symfony/Bundle/WebProfilerBundle/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/config": "^4.4|^5.0", - "symfony/framework-bundle": "^5.1", + "symfony/framework-bundle": "^5.3", "symfony/http-kernel": "^5.2", "symfony/routing": "^4.4|^5.0", "symfony/twig-bundle": "^4.4|^5.0", diff --git a/src/Symfony/Component/Asset/CHANGELOG.md b/src/Symfony/Component/Asset/CHANGELOG.md index 9df5fc14d0697..330be1f7c70c7 100644 --- a/src/Symfony/Component/Asset/CHANGELOG.md +++ b/src/Symfony/Component/Asset/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * deprecated `RemoteJsonManifestVersionStrategy`, use `JsonManifestVersionStrategy` instead. + 5.1.0 ----- diff --git a/src/Symfony/Component/Asset/Packages.php b/src/Symfony/Component/Asset/Packages.php index 12aba726da62a..2afee853c5231 100644 --- a/src/Symfony/Component/Asset/Packages.php +++ b/src/Symfony/Component/Asset/Packages.php @@ -28,7 +28,7 @@ class Packages /** * @param PackageInterface[] $packages Additional packages indexed by name */ - public function __construct(PackageInterface $defaultPackage = null, array $packages = []) + public function __construct(PackageInterface $defaultPackage = null, iterable $packages = []) { $this->defaultPackage = $defaultPackage; diff --git a/src/Symfony/Component/Asset/Tests/PackagesTest.php b/src/Symfony/Component/Asset/Tests/PackagesTest.php index 38044a93654eb..54ded7d4c1420 100644 --- a/src/Symfony/Component/Asset/Tests/PackagesTest.php +++ b/src/Symfony/Component/Asset/Tests/PackagesTest.php @@ -51,7 +51,7 @@ public function testGetUrl() { $packages = new Packages( new Package(new StaticVersionStrategy('default')), - ['a' => new Package(new StaticVersionStrategy('a'))] + new \ArrayIterator(['a' => new Package(new StaticVersionStrategy('a'))]) ); $this->assertSame('/foo?default', $packages->getUrl('/foo')); diff --git a/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php b/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php index a9ca035fb997e..57f1618dda30c 100644 --- a/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php +++ b/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php @@ -13,47 +13,91 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Asset\VersionStrategy\JsonManifestVersionStrategy; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; class JsonManifestVersionStrategyTest extends TestCase { - public function testGetVersion() + /** + * @dataProvider ProvideValidStrategies + */ + public function testGetVersion(JsonManifestVersionStrategy $strategy) { - $strategy = $this->createStrategy('manifest-valid.json'); - $this->assertSame('main.123abc.js', $strategy->getVersion('main.js')); } - public function testApplyVersion() + /** + * @dataProvider ProvideValidStrategies + */ + public function testApplyVersion(JsonManifestVersionStrategy $strategy) { - $strategy = $this->createStrategy('manifest-valid.json'); - $this->assertSame('css/styles.555def.css', $strategy->applyVersion('css/styles.css')); } - public function testApplyVersionWhenKeyDoesNotExistInManifest() + /** + * @dataProvider ProvideValidStrategies + */ + public function testApplyVersionWhenKeyDoesNotExistInManifest(JsonManifestVersionStrategy $strategy) { - $strategy = $this->createStrategy('manifest-valid.json'); - $this->assertSame('css/other.css', $strategy->applyVersion('css/other.css')); } - public function testMissingManifestFileThrowsException() + /** + * @dataProvider ProvideMissingStrategies + */ + public function testMissingManifestFileThrowsException(JsonManifestVersionStrategy $strategy) { $this->expectException(\RuntimeException::class); - $strategy = $this->createStrategy('non-existent-file.json'); $strategy->getVersion('main.js'); } - public function testManifestFileWithBadJSONThrowsException() + /** + * @dataProvider ProvideInvalidStrategies + */ + public function testManifestFileWithBadJSONThrowsException(JsonManifestVersionStrategy $strategy) { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Error parsing JSON'); - $strategy = $this->createStrategy('manifest-invalid.json'); $strategy->getVersion('main.js'); } - private function createStrategy($manifestFilename) + public function testRemoteManifestFileWithoutHttpClient() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(sprintf('The "%s" class needs an HTTP client to use a remote manifest. Try running "composer require symfony/http-client".', JsonManifestVersionStrategy::class)); + + new JsonManifestVersionStrategy('https://cdn.example.com/manifest.json'); + } + + public function provideValidStrategies() + { + yield from $this->provideStrategies('manifest-valid.json'); + } + + public function provideInvalidStrategies() { - return new JsonManifestVersionStrategy(__DIR__.'/../fixtures/'.$manifestFilename); + yield from $this->provideStrategies('manifest-invalid.json'); + } + + public function provideMissingStrategies() + { + yield from $this->provideStrategies('non-existent-file.json'); + } + + public function provideStrategies(string $manifestPath) + { + $httpClient = new MockHttpClient(function ($method, $url, $options) { + $filename = __DIR__.'/../fixtures/'.basename($url); + + if (file_exists($filename)) { + return new MockResponse(file_get_contents($filename), ['http_headers' => ['content-type' => 'application/json']]); + } + + return new MockResponse('{}', ['http_code' => 404]); + }); + + yield [new JsonManifestVersionStrategy('https://cdn.example.com/'.$manifestPath, $httpClient)]; + + yield [new JsonManifestVersionStrategy(__DIR__.'/../fixtures/'.$manifestPath)]; } } diff --git a/src/Symfony/Component/Asset/Tests/VersionStrategy/RemoteJsonManifestVersionStrategyTest.php b/src/Symfony/Component/Asset/Tests/VersionStrategy/RemoteJsonManifestVersionStrategyTest.php index e1b323795f6e0..7382cffddeb28 100644 --- a/src/Symfony/Component/Asset/Tests/VersionStrategy/RemoteJsonManifestVersionStrategyTest.php +++ b/src/Symfony/Component/Asset/Tests/VersionStrategy/RemoteJsonManifestVersionStrategyTest.php @@ -17,6 +17,9 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +/** + * @group legacy + */ class RemoteJsonManifestVersionStrategyTest extends TestCase { public function testGetVersion() diff --git a/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php b/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php index 5059eb3fbb63c..e72cdc1f174a6 100644 --- a/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php +++ b/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php @@ -11,6 +11,9 @@ namespace Symfony\Component\Asset\VersionStrategy; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + /** * Reads the versioned path of an asset from a JSON manifest file. * @@ -26,13 +29,19 @@ class JsonManifestVersionStrategy implements VersionStrategyInterface { private $manifestPath; private $manifestData; + private $httpClient; /** * @param string $manifestPath Absolute path to the manifest file */ - public function __construct(string $manifestPath) + public function __construct(string $manifestPath, HttpClientInterface $httpClient = null) { $this->manifestPath = $manifestPath; + $this->httpClient = $httpClient; + + if (null === $this->httpClient && 0 === strpos(parse_url($this->manifestPath, \PHP_URL_SCHEME), 'http')) { + throw new \LogicException(sprintf('The "%s" class needs an HTTP client to use a remote manifest. Try running "composer require symfony/http-client".', self::class)); + } } /** @@ -53,13 +62,23 @@ public function applyVersion(string $path) private function getManifestPath(string $path): ?string { if (null === $this->manifestData) { - if (!is_file($this->manifestPath)) { - throw new \RuntimeException(sprintf('Asset manifest file "%s" does not exist.', $this->manifestPath)); - } + if (null !== $this->httpClient && 0 === strpos(parse_url($this->manifestPath, \PHP_URL_SCHEME), 'http')) { + try { + $this->manifestData = $this->httpClient->request('GET', $this->manifestPath, [ + 'headers' => ['accept' => 'application/json'], + ])->toArray(); + } catch (DecodingExceptionInterface $e) { + throw new \RuntimeException(sprintf('Error parsing JSON from asset manifest URL "%s".', $this->manifestPath), 0, $e); + } + } else { + if (!is_file($this->manifestPath)) { + throw new \RuntimeException(sprintf('Asset manifest file "%s" does not exist.', $this->manifestPath)); + } - $this->manifestData = json_decode(file_get_contents($this->manifestPath), true); - if (0 < json_last_error()) { - throw new \RuntimeException(sprintf('Error parsing JSON from asset manifest file "%s": ', $this->manifestPath).json_last_error_msg()); + $this->manifestData = json_decode(file_get_contents($this->manifestPath), true); + if (0 < json_last_error()) { + throw new \RuntimeException(sprintf('Error parsing JSON from asset manifest file "%s": ', $this->manifestPath).json_last_error_msg()); + } } } diff --git a/src/Symfony/Component/Asset/VersionStrategy/RemoteJsonManifestVersionStrategy.php b/src/Symfony/Component/Asset/VersionStrategy/RemoteJsonManifestVersionStrategy.php index db45b3b7ec177..cc6170a27e4c2 100644 --- a/src/Symfony/Component/Asset/VersionStrategy/RemoteJsonManifestVersionStrategy.php +++ b/src/Symfony/Component/Asset/VersionStrategy/RemoteJsonManifestVersionStrategy.php @@ -13,6 +13,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; +trigger_deprecation('symfony/asset', '5.3', 'The "%s" class is deprecated, use "%s" instead.', RemoteJsonManifestVersionStrategy::class, JsonManifestVersionStrategy::class); + /** * Reads the versioned path of an asset from a remote JSON manifest file. * @@ -23,6 +25,8 @@ * } * * You could then ask for the version of "main.js" or "css/styles.css". + * + * @deprecated since Symfony 5.3, use JsonManifestVersionStrategy instead. */ class RemoteJsonManifestVersionStrategy implements VersionStrategyInterface { diff --git a/src/Symfony/Component/Asset/composer.json b/src/Symfony/Component/Asset/composer.json index 60ebe4a8e6dda..20487e99c8538 100644 --- a/src/Symfony/Component/Asset/composer.json +++ b/src/Symfony/Component/Asset/composer.json @@ -16,7 +16,8 @@ } ], "require": { - "php": ">=7.2.5" + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1" }, "suggest": { "symfony/http-foundation": "" diff --git a/src/Symfony/Component/BrowserKit/AbstractBrowser.php b/src/Symfony/Component/BrowserKit/AbstractBrowser.php index 43e0602acacb5..1253f16a1f87f 100644 --- a/src/Symfony/Component/BrowserKit/AbstractBrowser.php +++ b/src/Symfony/Component/BrowserKit/AbstractBrowser.php @@ -161,6 +161,24 @@ public function xmlHttpRequest(string $method, string $uri, array $parameters = } } + /** + * Converts the request parameters into a JSON string and uses it as request content. + */ + public function jsonRequest(string $method, string $uri, array $parameters = [], array $server = [], bool $changeHistory = true): Crawler + { + $content = json_encode($parameters); + + $this->setServerParameter('CONTENT_TYPE', 'application/json'); + $this->setServerParameter('HTTP_ACCEPT', 'application/json'); + + try { + return $this->request($method, $uri, [], [], $server, $content, $changeHistory); + } finally { + unset($this->server['CONTENT_TYPE']); + unset($this->server['HTTP_ACCEPT']); + } + } + /** * Returns the History instance. * diff --git a/src/Symfony/Component/BrowserKit/CHANGELOG.md b/src/Symfony/Component/BrowserKit/CHANGELOG.md index 8506ad8efe73c..41301b9258ad7 100644 --- a/src/Symfony/Component/BrowserKit/CHANGELOG.md +++ b/src/Symfony/Component/BrowserKit/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * Added `jsonRequest` method to `AbstractBrowser` + * Allowed sending a body with GET requests when a content-type is defined + 5.2.0 ----- @@ -19,7 +25,7 @@ CHANGELOG 4.2.0 ----- - * The method `Client::submit()` will have a new `$serverParameters` argument + * The method `Client::submit()` will have a new `$serverParameters` argument in version 5.0, not defining it is deprecated * Added ability to read the "samesite" attribute of cookies using `Cookie::getSameSite()` diff --git a/src/Symfony/Component/BrowserKit/HttpBrowser.php b/src/Symfony/Component/BrowserKit/HttpBrowser.php index 0ad87b5c33a62..eba038ec6e734 100644 --- a/src/Symfony/Component/BrowserKit/HttpBrowser.php +++ b/src/Symfony/Component/BrowserKit/HttpBrowser.php @@ -61,7 +61,7 @@ protected function doRequest($request): Response */ private function getBodyAndExtraHeaders(Request $request, array $headers): array { - if (\in_array($request->getMethod(), ['GET', 'HEAD'])) { + if (\in_array($request->getMethod(), ['GET', 'HEAD']) && !isset($headers['content-type'])) { return ['', []]; } diff --git a/src/Symfony/Component/BrowserKit/Tests/AbstractBrowserTest.php b/src/Symfony/Component/BrowserKit/Tests/AbstractBrowserTest.php index b71f5454d37e5..c714a2560d224 100644 --- a/src/Symfony/Component/BrowserKit/Tests/AbstractBrowserTest.php +++ b/src/Symfony/Component/BrowserKit/Tests/AbstractBrowserTest.php @@ -62,6 +62,17 @@ public function testXmlHttpRequest() $this->assertFalse($client->getServerParameter('HTTP_X_REQUESTED_WITH', false)); } + public function testJsonRequest() + { + $client = $this->getBrowser(); + $client->jsonRequest('GET', 'http://example.com/', ['param' => 1], [], true); + $this->assertSame('application/json', $client->getRequest()->getServer()['CONTENT_TYPE']); + $this->assertSame('application/json', $client->getRequest()->getServer()['HTTP_ACCEPT']); + $this->assertFalse($client->getServerParameter('CONTENT_TYPE', false)); + $this->assertFalse($client->getServerParameter('HTTP_ACCEPT', false)); + $this->assertSame('{"param":1}', $client->getRequest()->getContent()); + } + public function testGetRequestWithIpAsHttpHost() { $client = $this->getBrowser(); diff --git a/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php b/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php index 8125d1a77c919..f41fccfd3d445 100644 --- a/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php +++ b/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php @@ -75,6 +75,14 @@ public function validContentTypes() ['PUT', 'http://example.com/', [], [], ['Content-Type' => 'application/json'], '["content"]'], ['PUT', 'http://example.com/', ['headers' => $defaultHeaders + ['content-type' => 'application/json'], 'body' => '["content"]', 'max_redirects' => 0]], ]; + yield 'GET JSON' => [ + ['GET', 'http://example.com/jsonrpc', [], [], ['CONTENT_TYPE' => 'application/json'], '["content"]'], + ['GET', 'http://example.com/jsonrpc', ['headers' => $defaultHeaders + ['content-type' => 'application/json'], 'body' => '["content"]', 'max_redirects' => 0]], + ]; + yield 'HEAD JSON' => [ + ['HEAD', 'http://example.com/jsonrpc', [], [], ['CONTENT_TYPE' => 'application/json'], '["content"]'], + ['HEAD', 'http://example.com/jsonrpc', ['headers' => $defaultHeaders + ['content-type' => 'application/json'], 'body' => '["content"]', 'max_redirects' => 0]], + ]; } public function testMultiPartRequestWithSingleFile() diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 5ccb212eb1e15..1c36be8a08a62 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -138,7 +138,7 @@ public function createTable() // - trailing space removal // - case-insensitivity // - language processing like é == e - $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(255) NOT NULL PRIMARY KEY, $this->dataCol MEDIUMBLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; + $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(255) NOT NULL PRIMARY KEY, $this->dataCol MEDIUMBLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB"; break; case 'sqlite': $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER, $this->timeCol INTEGER NOT NULL)"; diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 73965c2058903..5712ebf92a50b 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * added support for connecting to Redis Sentinel clusters when using the Redis PHP extension + 5.2.0 ----- diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PredisAdapterSentinelTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PredisAdapterSentinelTest.php new file mode 100644 index 0000000000000..e6de9b3ee6bbe --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/PredisAdapterSentinelTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Symfony\Component\Cache\Adapter\AbstractAdapter; + +/** + * @group integration + */ +class PredisAdapterSentinelTest extends AbstractRedisAdapterTest +{ + public static function setUpBeforeClass(): void + { + if (!class_exists(\Predis\Client::class)) { + self::markTestSkipped('The Predis\Client class is required.'); + } + if (!$hosts = getenv('REDIS_SENTINEL_HOSTS')) { + self::markTestSkipped('REDIS_SENTINEL_HOSTS env var is not defined.'); + } + if (!$service = getenv('REDIS_SENTINEL_SERVICE')) { + self::markTestSkipped('REDIS_SENTINEL_SERVICE env var is not defined.'); + } + + self::$redis = AbstractAdapter::createConnection('redis:?host['.str_replace(' ', ']&host[', $hosts).']', ['redis_sentinel' => $service, 'class' => \Predis\Client::class]); + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterSentinelTest.php b/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterSentinelTest.php index 0eb407bafa5b9..b28936ee6814f 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterSentinelTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterSentinelTest.php @@ -22,8 +22,8 @@ class RedisAdapterSentinelTest extends AbstractRedisAdapterTest { public static function setUpBeforeClass(): void { - if (!class_exists(\Predis\Client::class)) { - self::markTestSkipped('The Predis\Client class is required.'); + if (!class_exists(\RedisSentinel::class)) { + self::markTestSkipped('The RedisSentinel class is required.'); } if (!$hosts = getenv('REDIS_SENTINEL_HOSTS')) { self::markTestSkipped('REDIS_SENTINEL_HOSTS env var is not defined.'); @@ -42,4 +42,13 @@ public function testInvalidDSNHasBothClusterAndSentinel() $dsn = 'redis:?host[redis1]&host[redis2]&host[redis3]&redis_cluster=1&redis_sentinel=mymaster'; RedisAdapter::createConnection($dsn); } + + public function testExceptionMessageWhenFailingToRetrieveMasterInformation() + { + $hosts = getenv('REDIS_SENTINEL_HOSTS'); + $firstHost = explode(' ', $hosts)[0]; + $this->expectException(\Symfony\Component\Cache\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Failed to retrieve master information from master name "invalid-masterset-name" and address "'.$firstHost.'".'); + AbstractAdapter::createConnection('redis:?host['.str_replace(' ', ']&host[', $hosts).']', ['redis_sentinel' => 'invalid-masterset-name']); + } } diff --git a/src/Symfony/Component/Cache/Traits/RedisTrait.php b/src/Symfony/Component/Cache/Traits/RedisTrait.php index 67f1042d05109..f133c737d49a4 100644 --- a/src/Symfony/Component/Cache/Traits/RedisTrait.php +++ b/src/Symfony/Component/Cache/Traits/RedisTrait.php @@ -165,11 +165,15 @@ public static function createConnection($dsn, array $options = []) $params += $query + $options + self::$defaultConnectionOptions; - if (isset($params['redis_sentinel']) && !class_exists(\Predis\Client::class)) { - throw new CacheException(sprintf('Redis Sentinel support requires the "predis/predis" package: "%s".', $dsn)); + if (isset($params['redis_sentinel']) && !class_exists(\Predis\Client::class) && !class_exists(\RedisSentinel::class)) { + throw new CacheException(sprintf('Redis Sentinel support requires the "predis/predis" package or the "redis" extension v5.2 or higher: "%s".', $dsn)); } - if (null === $params['class'] && !isset($params['redis_sentinel']) && \extension_loaded('redis')) { + if ($params['redis_cluster'] && isset($params['redis_sentinel'])) { + throw new InvalidArgumentException(sprintf('Cannot use both "redis_cluster" and "redis_sentinel" at the same time: "%s".', $dsn)); + } + + if (null === $params['class'] && \extension_loaded('redis')) { $class = $params['redis_cluster'] ? \RedisCluster::class : (1 < \count($hosts) ? \RedisArray::class : \Redis::class); } else { $class = null === $params['class'] ? \Predis\Client::class : $params['class']; @@ -187,6 +191,16 @@ public static function createConnection($dsn, array $options = []) $host = 'tls://'.$host; } + if (isset($params['redis_sentinel'])) { + $sentinel = new \RedisSentinel($host, $port, $params['timeout'], (string) $params['persistent_id'], $params['retry_interval'], $params['read_timeout']); + + if (!$address = $sentinel->getMasterAddrByName($params['redis_sentinel'])) { + throw new InvalidArgumentException(sprintf('Failed to retrieve master information from master name "%s" and address "%s:%d".', $params['redis_sentinel'], $host, $port)); + } + + [$host, $port] = $address; + } + try { @$redis->{$connect}($host, $port, $params['timeout'], (string) $params['persistent_id'], $params['retry_interval'], $params['read_timeout']); @@ -272,9 +286,6 @@ public static function createConnection($dsn, array $options = []) } elseif (is_a($class, \Predis\ClientInterface::class, true)) { if ($params['redis_cluster']) { $params['cluster'] = 'redis'; - if (isset($params['redis_sentinel'])) { - throw new InvalidArgumentException(sprintf('Cannot use both "redis_cluster" and "redis_sentinel" at the same time: "%s".', $dsn)); - } } elseif (isset($params['redis_sentinel'])) { $params['replication'] = 'sentinel'; $params['service'] = $params['redis_sentinel']; diff --git a/src/Symfony/Component/Config/Exception/FileLoaderImportCircularReferenceException.php b/src/Symfony/Component/Config/Exception/FileLoaderImportCircularReferenceException.php index aa27f9869b7e2..e235ea04956a6 100644 --- a/src/Symfony/Component/Config/Exception/FileLoaderImportCircularReferenceException.php +++ b/src/Symfony/Component/Config/Exception/FileLoaderImportCircularReferenceException.php @@ -20,6 +20,12 @@ class FileLoaderImportCircularReferenceException extends LoaderLoadException { public function __construct(array $resources, ?int $code = 0, \Throwable $previous = null) { + if (null === $code) { + trigger_deprecation('symfony/config', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + $message = sprintf('Circular reference detected in "%s" ("%s" > "%s").', $this->varToString($resources[0]), implode('" > "', $resources), $resources[0]); \Exception::__construct($message, $code, $previous); diff --git a/src/Symfony/Component/Config/Exception/LoaderLoadException.php b/src/Symfony/Component/Config/Exception/LoaderLoadException.php index 86886058668a6..24302b7cba02a 100644 --- a/src/Symfony/Component/Config/Exception/LoaderLoadException.php +++ b/src/Symfony/Component/Config/Exception/LoaderLoadException.php @@ -27,6 +27,12 @@ class LoaderLoadException extends \Exception */ public function __construct(string $resource, string $sourceResource = null, ?int $code = 0, \Throwable $previous = null, string $type = null) { + if (null === $code) { + trigger_deprecation('symfony/config', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + $message = ''; if ($previous) { // Include the previous exception, to help the user see what might be the underlying cause diff --git a/src/Symfony/Component/Config/Loader/FileLoader.php b/src/Symfony/Component/Config/Loader/FileLoader.php index 5979f4c4fb967..b57e553fd2cd9 100644 --- a/src/Symfony/Component/Config/Loader/FileLoader.php +++ b/src/Symfony/Component/Config/Loader/FileLoader.php @@ -31,9 +31,10 @@ abstract class FileLoader extends Loader private $currentDir; - public function __construct(FileLocatorInterface $locator) + public function __construct(FileLocatorInterface $locator, string $env = null) { $this->locator = $locator; + parent::__construct($env); } /** diff --git a/src/Symfony/Component/Config/Loader/Loader.php b/src/Symfony/Component/Config/Loader/Loader.php index 3969d9fa1ee00..3c0fe0846cff6 100644 --- a/src/Symfony/Component/Config/Loader/Loader.php +++ b/src/Symfony/Component/Config/Loader/Loader.php @@ -21,6 +21,12 @@ abstract class Loader implements LoaderInterface { protected $resolver; + protected $env; + + public function __construct(string $env = null) + { + $this->env = $env; + } /** * {@inheritdoc} diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 4fba363b46702..440b93dc5c6fe 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\HelpCommand; +use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\Command\ListCommand; use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; @@ -489,8 +490,10 @@ public function add(Command $command) return null; } - // Will throw if the command is not correctly initialized. - $command->getDefinition(); + if (!$command instanceof LazyCommand) { + // Will throw if the command is not correctly initialized. + $command->getDefinition(); + } if (!$command->getName()) { throw new LogicException(sprintf('The command defined in "%s" cannot have an empty name.', get_debug_type($command))); @@ -1033,8 +1036,7 @@ protected function getDefaultInputDefinition() new InputOption('--quiet', '-q', InputOption::VALUE_NONE, 'Do not output any message'), new InputOption('--verbose', '-v|vv|vvv', InputOption::VALUE_NONE, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'), new InputOption('--version', '-V', InputOption::VALUE_NONE, 'Display this application version'), - new InputOption('--ansi', '', InputOption::VALUE_NONE, 'Force ANSI output'), - new InputOption('--no-ansi', '', InputOption::VALUE_NONE, 'Disable ANSI output'), + new InputOption('--ansi', '', InputOption::VALUE_NEGATABLE, 'Force (or disable --no-ansi) ANSI output', null), new InputOption('--no-interaction', '-n', InputOption::VALUE_NONE, 'Do not ask any interactive question'), ]); } diff --git a/src/Symfony/Component/Console/Attribute/ConsoleCommand.php b/src/Symfony/Component/Console/Attribute/ConsoleCommand.php new file mode 100644 index 0000000000000..90e8c2cb137ef --- /dev/null +++ b/src/Symfony/Component/Console/Attribute/ConsoleCommand.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Attribute; + +#[\Attribute(\Attribute::TARGET_CLASS)] +class ConsoleCommand +{ + public function __construct( + public string $name, + public ?string $description = null, + array $aliases = [], + bool $hidden = false, + ) { + if (!$hidden && !$aliases) { + return; + } + + $name = explode('|', $name); + $name = array_merge($name, $aliases); + + if ($hidden && '' !== $name[0]) { + array_unshift($name, ''); + } + + $this->name = implode('|', $name); + } +} diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 3b8525624cb3e..bdcd6f2959658 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -1,6 +1,17 @@ CHANGELOG ========= +5.3 +--- + + * Add `GithubActionReporter` to render annotations in a Github Action + * Add `InputOption::VALUE_NEGATABLE` flag to handle `--foo`/`--no-foo` options + * Add the `Command::$defaultDescription` static property and the `description` attribute + on the `console.command` tag to allow the `list` command to instantiate commands lazily + * Add option `--short` to the `list` command + * Add support for bright colors + * Add `ConsoleCommand` attribute for declaring commands on PHP 8 + 5.2.0 ----- diff --git a/src/Symfony/Component/Console/CI/GithubActionReporter.php b/src/Symfony/Component/Console/CI/GithubActionReporter.php new file mode 100644 index 0000000000000..0ae18ca15e8a0 --- /dev/null +++ b/src/Symfony/Component/Console/CI/GithubActionReporter.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\CI; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Utility class for Github actions. + * + * @author Maxime Steinhausser + */ +class GithubActionReporter +{ + private $output; + + /** + * @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L80-L85 + */ + private const ESCAPED_DATA = [ + '%' => '%25', + "\r" => '%0D', + "\n" => '%0A', + ]; + + /** + * @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L87-L94 + */ + private const ESCAPED_PROPERTIES = [ + '%' => '%25', + "\r" => '%0D', + "\n" => '%0A', + ':' => '%3A', + ',' => '%2C', + ]; + + public function __construct(OutputInterface $output) + { + $this->output = $output; + } + + public static function isGithubActionEnvironment(): bool + { + return false !== getenv('GITHUB_ACTIONS'); + } + + /** + * Output an error using the Github annotations format. + * + * @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + */ + public function error(string $message, string $file = null, int $line = null, int $col = null): void + { + $this->log('error', $message, $file, $line, $col); + } + + /** + * Output a warning using the Github annotations format. + * + * @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message + */ + public function warning(string $message, string $file = null, int $line = null, int $col = null): void + { + $this->log('warning', $message, $file, $line, $col); + } + + /** + * Output a debug log using the Github annotations format. + * + * @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-debug-message + */ + public function debug(string $message, string $file = null, int $line = null, int $col = null): void + { + $this->log('debug', $message, $file, $line, $col); + } + + private function log(string $type, string $message, string $file = null, int $line = null, int $col = null): void + { + // Some values must be encoded. + $message = strtr($message, self::ESCAPED_DATA); + + if (!$file) { + // No file provided, output the message solely: + $this->output->writeln(sprintf('::%s::%s', $type, $message)); + + return; + } + + $this->output->writeln(sprintf('::%s file=%s, line=%s, col=%s::%s', $type, strtr($file, self::ESCAPED_PROPERTIES), strtr($line ?? 1, self::ESCAPED_PROPERTIES), strtr($col ?? 0, self::ESCAPED_PROPERTIES), $message)); + } +} diff --git a/src/Symfony/Component/Console/Color.php b/src/Symfony/Component/Console/Color.php index b45f4523b9d25..22a4ce9ffbbb9 100644 --- a/src/Symfony/Component/Console/Color.php +++ b/src/Symfony/Component/Console/Color.php @@ -30,6 +30,17 @@ final class Color 'default' => 9, ]; + private const BRIGHT_COLORS = [ + 'gray' => 0, + 'bright-red' => 1, + 'bright-green' => 2, + 'bright-yellow' => 3, + 'bright-blue' => 4, + 'bright-magenta' => 5, + 'bright-cyan' => 6, + 'bright-white' => 7, + ]; + private const AVAILABLE_OPTIONS = [ 'bold' => ['set' => 1, 'unset' => 22], 'underscore' => ['set' => 4, 'unset' => 24], @@ -45,7 +56,7 @@ final class Color public function __construct(string $foreground = '', string $background = '', array $options = []) { $this->foreground = $this->parseColor($foreground); - $this->background = $this->parseColor($background); + $this->background = $this->parseColor($background, true); foreach ($options as $option) { if (!isset(self::AVAILABLE_OPTIONS[$option])) { @@ -65,10 +76,10 @@ public function set(): string { $setCodes = []; if ('' !== $this->foreground) { - $setCodes[] = '3'.$this->foreground; + $setCodes[] = $this->foreground; } if ('' !== $this->background) { - $setCodes[] = '4'.$this->background; + $setCodes[] = $this->background; } foreach ($this->options as $option) { $setCodes[] = $option['set']; @@ -99,7 +110,7 @@ public function unset(): string return sprintf("\033[%sm", implode(';', $unsetCodes)); } - private function parseColor(string $color): string + private function parseColor(string $color, bool $background = false): string { if ('' === $color) { return ''; @@ -116,14 +127,18 @@ private function parseColor(string $color): string throw new InvalidArgumentException(sprintf('Invalid "%s" color.', $color)); } - return $this->convertHexColorToAnsi(hexdec($color)); + return ($background ? '4' : '3').$this->convertHexColorToAnsi(hexdec($color)); + } + + if (isset(self::COLORS[$color])) { + return ($background ? '4' : '3').self::COLORS[$color]; } - if (!isset(self::COLORS[$color])) { - throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_keys(self::COLORS)))); + if (isset(self::BRIGHT_COLORS[$color])) { + return ($background ? '10' : '9').self::BRIGHT_COLORS[$color]; } - return (string) self::COLORS[$color]; + throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_merge(array_keys(self::COLORS), array_keys(self::BRIGHT_COLORS))))); } private function convertHexColorToAnsi(int $color): string diff --git a/src/Symfony/Component/Console/Command/Command.php b/src/Symfony/Component/Console/Command/Command.php index 65f54f8013dd1..30f3796e6d202 100644 --- a/src/Symfony/Component/Console/Command/Command.php +++ b/src/Symfony/Component/Console/Command/Command.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Console\Command; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\ConsoleCommand; use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; @@ -32,12 +33,18 @@ class Command // see https://tldp.org/LDP/abs/html/exitcodes.html public const SUCCESS = 0; public const FAILURE = 1; + public const INVALID = 2; /** * @var string|null The default command name */ protected static $defaultName; + /** + * @var string|null The default command description + */ + protected static $defaultDescription; + private $application; private $name; private $processTitle; @@ -59,11 +66,32 @@ class Command public static function getDefaultName() { $class = static::class; + + if (\PHP_VERSION_ID >= 80000 && $attribute = (new \ReflectionClass($class))->getAttributes(ConsoleCommand::class)) { + return $attribute[0]->newInstance()->name; + } + $r = new \ReflectionProperty($class, 'defaultName'); return $class === $r->class ? static::$defaultName : null; } + /** + * @return string|null The default command description or null when no default description is set + */ + public static function getDefaultDescription(): ?string + { + $class = static::class; + + if (\PHP_VERSION_ID >= 80000 && $attribute = (new \ReflectionClass($class))->getAttributes(ConsoleCommand::class)) { + return $attribute[0]->newInstance()->description; + } + + $r = new \ReflectionProperty($class, 'defaultDescription'); + + return $class === $r->class ? static::$defaultDescription : null; + } + /** * @param string|null $name The name of the command; passing null means it must be set in configure() * @@ -77,6 +105,10 @@ public function __construct(string $name = null) $this->setName($name); } + if ('' === $this->description) { + $this->setDescription(static::getDefaultDescription() ?? ''); + } + $this->configure(); } @@ -304,6 +336,8 @@ public function setCode(callable $code) * This method is not part of public API and should not be used directly. * * @param bool $mergeArgs Whether to merge or not the Application definition arguments to Command definition arguments + * + * @internal */ public function mergeApplicationDefinition(bool $mergeArgs = true) { @@ -560,11 +594,14 @@ public function getProcessedHelp() */ public function setAliases(iterable $aliases) { + $list = []; + foreach ($aliases as $alias) { $this->validateName($alias); + $list[] = $alias; } - $this->aliases = $aliases; + $this->aliases = \is_array($aliases) ? $aliases : $list; return $this; } diff --git a/src/Symfony/Component/Console/Command/LazyCommand.php b/src/Symfony/Component/Console/Command/LazyCommand.php new file mode 100644 index 0000000000000..763133e81e12c --- /dev/null +++ b/src/Symfony/Component/Console/Command/LazyCommand.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Command; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @author Nicolas Grekas + */ +final class LazyCommand extends Command +{ + private $command; + private $isEnabled; + + public function __construct(string $name, array $aliases, string $description, bool $isHidden, \Closure $commandFactory, ?bool $isEnabled = true) + { + $this->setName($name) + ->setAliases($aliases) + ->setHidden($isHidden) + ->setDescription($description); + + $this->command = $commandFactory; + $this->isEnabled = $isEnabled; + } + + public function ignoreValidationErrors(): void + { + $this->getCommand()->ignoreValidationErrors(); + } + + public function setApplication(Application $application = null): void + { + if ($this->command instanceof parent) { + $this->command->setApplication($application); + } + + parent::setApplication($application); + } + + public function setHelperSet(HelperSet $helperSet): void + { + if ($this->command instanceof parent) { + $this->command->setHelperSet($helperSet); + } + + parent::setHelperSet($helperSet); + } + + public function isEnabled(): bool + { + return $this->isEnabled ?? $this->getCommand()->isEnabled(); + } + + public function run(InputInterface $input, OutputInterface $output): int + { + return $this->getCommand()->run($input, $output); + } + + /** + * @return $this + */ + public function setCode(callable $code): self + { + $this->getCommand()->setCode($code); + + return $this; + } + + /** + * @internal + */ + public function mergeApplicationDefinition(bool $mergeArgs = true): void + { + $this->getCommand()->mergeApplicationDefinition($mergeArgs); + } + + /** + * @return $this + */ + public function setDefinition($definition): self + { + $this->getCommand()->setDefinition($definition); + + return $this; + } + + public function getDefinition(): InputDefinition + { + return $this->getCommand()->getDefinition(); + } + + public function getNativeDefinition(): InputDefinition + { + return $this->getCommand()->getNativeDefinition(); + } + + /** + * @return $this + */ + public function addArgument(string $name, int $mode = null, string $description = '', $default = null): self + { + $this->getCommand()->addArgument($name, $mode, $description, $default); + + return $this; + } + + /** + * @return $this + */ + public function addOption(string $name, $shortcut = null, int $mode = null, string $description = '', $default = null): self + { + $this->getCommand()->addOption($name, $shortcut, $mode, $description, $default); + + return $this; + } + + /** + * @return $this + */ + public function setProcessTitle(string $title): self + { + $this->getCommand()->setProcessTitle($title); + + return $this; + } + + /** + * @return $this + */ + public function setHelp(string $help): self + { + $this->getCommand()->setHelp($help); + + return $this; + } + + public function getHelp(): string + { + return $this->getCommand()->getHelp(); + } + + public function getProcessedHelp(): string + { + return $this->getCommand()->getProcessedHelp(); + } + + public function getSynopsis(bool $short = false): string + { + return $this->getCommand()->getSynopsis($short); + } + + /** + * @return $this + */ + public function addUsage(string $usage): self + { + $this->getCommand()->addUsage($usage); + + return $this; + } + + public function getUsages(): array + { + return $this->getCommand()->getUsages(); + } + + /** + * @return mixed + */ + public function getHelper(string $name) + { + return $this->getCommand()->getHelper($name); + } + + public function getCommand(): parent + { + if (!$this->command instanceof \Closure) { + return $this->command; + } + + $command = $this->command = ($this->command)(); + $command->setApplication($this->getApplication()); + + if (null !== $this->getHelperSet()) { + $command->setHelperSet($this->getHelperSet()); + } + + $command->setName($this->getName()) + ->setAliases($this->getAliases()) + ->setHidden($this->isHidden()) + ->setDescription($this->getDescription()); + + // Will throw if the command is not correctly initialized. + $command->getDefinition(); + + return $command; + } +} diff --git a/src/Symfony/Component/Console/Command/ListCommand.php b/src/Symfony/Component/Console/Command/ListCommand.php index 284ddb5fea53a..6759dd305d725 100644 --- a/src/Symfony/Component/Console/Command/ListCommand.php +++ b/src/Symfony/Component/Console/Command/ListCommand.php @@ -35,6 +35,7 @@ protected function configure() new InputArgument('namespace', InputArgument::OPTIONAL, 'The namespace name'), new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command list'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt'), + new InputOption('short', null, InputOption::VALUE_NONE, 'To skip describing commands\' arguments'), ]) ->setDescription('Lists commands') ->setHelp(<<<'EOF' @@ -68,6 +69,7 @@ protected function execute(InputInterface $input, OutputInterface $output) 'format' => $input->getOption('format'), 'raw_text' => $input->getOption('raw'), 'namespace' => $input->getArgument('namespace'), + 'short' => $input->getOption('short'), ]); return 0; diff --git a/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php b/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php index 77ae6f9d47869..42ec2eabad472 100644 --- a/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php +++ b/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php @@ -12,11 +12,14 @@ namespace Symfony\Component\Console\DependencyInjection; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\TypedReference; /** @@ -52,7 +55,7 @@ public function process(ContainerBuilder $container) $class = $container->getParameterBag()->resolveValue($definition->getClass()); if (isset($tags[0]['command'])) { - $commandName = $tags[0]['command']; + $aliases = $tags[0]['command']; } else { if (!$r = $container->getReflectionClass($class)) { throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); @@ -60,7 +63,14 @@ public function process(ContainerBuilder $container) if (!$r->isSubclassOf(Command::class)) { throw new InvalidArgumentException(sprintf('The service "%s" tagged "%s" must be a subclass of "%s".', $id, $this->commandTag, Command::class)); } - $commandName = $class::getDefaultName(); + $aliases = $class::getDefaultName(); + } + + $aliases = explode('|', $aliases ?? ''); + $commandName = array_shift($aliases); + + if ($isHidden = '' === $commandName) { + $commandName = array_shift($aliases); } if (null === $commandName) { @@ -74,16 +84,19 @@ public function process(ContainerBuilder $container) continue; } + $description = $tags[0]['description'] ?? null; + unset($tags[0]); $lazyCommandMap[$commandName] = $id; $lazyCommandRefs[$id] = new TypedReference($id, $class); - $aliases = []; foreach ($tags as $tag) { if (isset($tag['command'])) { $aliases[] = $tag['command']; $lazyCommandMap[$tag['command']] = $id; } + + $description = $description ?? $tag['description'] ?? null; } $definition->addMethodCall('setName', [$commandName]); @@ -91,6 +104,29 @@ public function process(ContainerBuilder $container) if ($aliases) { $definition->addMethodCall('setAliases', [$aliases]); } + + if ($isHidden) { + $definition->addMethodCall('setHidden', [true]); + } + + if (!$description) { + if (!$r = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + if (!$r->isSubclassOf(Command::class)) { + throw new InvalidArgumentException(sprintf('The service "%s" tagged "%s" must be a subclass of "%s".', $id, $this->commandTag, Command::class)); + } + $description = $class::getDefaultDescription(); + } + + if ($description) { + $definition->addMethodCall('setDescription', [$description]); + + $container->register('.'.$id.'.lazy', LazyCommand::class) + ->setArguments([$commandName, $aliases, $description, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]); + + $lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy'); + } } $container diff --git a/src/Symfony/Component/Console/Descriptor/JsonDescriptor.php b/src/Symfony/Component/Console/Descriptor/JsonDescriptor.php index ec6ade3864df1..1d2865941a0db 100644 --- a/src/Symfony/Component/Console/Descriptor/JsonDescriptor.php +++ b/src/Symfony/Component/Console/Descriptor/JsonDescriptor.php @@ -40,6 +40,9 @@ protected function describeInputArgument(InputArgument $argument, array $options protected function describeInputOption(InputOption $option, array $options = []) { $this->writeData($this->getInputOptionData($option), $options); + if ($option->isNegatable()) { + $this->writeData($this->getInputOptionData($option, true), $options); + } } /** @@ -55,7 +58,7 @@ protected function describeInputDefinition(InputDefinition $definition, array $o */ protected function describeCommand(Command $command, array $options = []) { - $this->writeData($this->getCommandData($command), $options); + $this->writeData($this->getCommandData($command, $options['short'] ?? false), $options); } /** @@ -68,7 +71,7 @@ protected function describeApplication(Application $application, array $options $commands = []; foreach ($description->getCommands() as $command) { - $commands[] = $this->getCommandData($command); + $commands[] = $this->getCommandData($command, $options['short'] ?? false); } $data = []; @@ -111,9 +114,17 @@ private function getInputArgumentData(InputArgument $argument): array ]; } - private function getInputOptionData(InputOption $option): array + private function getInputOptionData(InputOption $option, bool $negated = false): array { - return [ + return $negated ? [ + 'name' => '--no-'.$option->getName(), + 'shortcut' => '', + 'accept_value' => false, + 'is_value_required' => false, + 'is_multiple' => false, + 'description' => 'Negate the "--'.$option->getName().'" option', + 'default' => false, + ] : [ 'name' => '--'.$option->getName(), 'shortcut' => $option->getShortcut() ? '-'.str_replace('|', '|-', $option->getShortcut()) : '', 'accept_value' => $option->acceptValue(), @@ -134,22 +145,37 @@ private function getInputDefinitionData(InputDefinition $definition): array $inputOptions = []; foreach ($definition->getOptions() as $name => $option) { $inputOptions[$name] = $this->getInputOptionData($option); + if ($option->isNegatable()) { + $inputOptions['no-'.$name] = $this->getInputOptionData($option, true); + } } return ['arguments' => $inputArguments, 'options' => $inputOptions]; } - private function getCommandData(Command $command): array + private function getCommandData(Command $command, bool $short = false): array { - $command->mergeApplicationDefinition(false); - - return [ + $data = [ 'name' => $command->getName(), - 'usage' => array_merge([$command->getSynopsis()], $command->getUsages(), $command->getAliases()), 'description' => $command->getDescription(), - 'help' => $command->getProcessedHelp(), - 'definition' => $this->getInputDefinitionData($command->getDefinition()), - 'hidden' => $command->isHidden(), ]; + + if ($short) { + $data += [ + 'usage' => $command->getAliases(), + ]; + } else { + $command->mergeApplicationDefinition(false); + + $data += [ + 'usage' => array_merge([$command->getSynopsis()], $command->getUsages(), $command->getAliases()), + 'help' => $command->getProcessedHelp(), + 'definition' => $this->getInputDefinitionData($command->getDefinition()), + ]; + } + + $data['hidden'] = $command->isHidden(); + + return $data; } } diff --git a/src/Symfony/Component/Console/Descriptor/MarkdownDescriptor.php b/src/Symfony/Component/Console/Descriptor/MarkdownDescriptor.php index 3748335ea388e..db1fe20763ab4 100644 --- a/src/Symfony/Component/Console/Descriptor/MarkdownDescriptor.php +++ b/src/Symfony/Component/Console/Descriptor/MarkdownDescriptor.php @@ -69,6 +69,9 @@ protected function describeInputArgument(InputArgument $argument, array $options protected function describeInputOption(InputOption $option, array $options = []) { $name = '--'.$option->getName(); + if ($option->isNegatable()) { + $name .= '|--no-'.$option->getName(); + } if ($option->getShortcut()) { $name .= '|-'.str_replace('|', '|-', $option->getShortcut()).''; } @@ -79,6 +82,7 @@ protected function describeInputOption(InputOption $option, array $options = []) .'* Accept value: '.($option->acceptValue() ? 'yes' : 'no')."\n" .'* Is value required: '.($option->isValueRequired() ? 'yes' : 'no')."\n" .'* Is multiple: '.($option->isArray() ? 'yes' : 'no')."\n" + .'* Is negatable: '.($option->isNegatable() ? 'yes' : 'no')."\n" .'* Default: `'.str_replace("\n", '', var_export($option->getDefault(), true)).'`' ); } @@ -118,6 +122,20 @@ protected function describeInputDefinition(InputDefinition $definition, array $o */ protected function describeCommand(Command $command, array $options = []) { + if ($options['short'] ?? false) { + $this->write( + '`'.$command->getName()."`\n" + .str_repeat('-', Helper::strlen($command->getName()) + 2)."\n\n" + .($command->getDescription() ? $command->getDescription()."\n\n" : '') + .'### Usage'."\n\n" + .array_reduce($command->getAliases(), function ($carry, $usage) { + return $carry.'* `'.$usage.'`'."\n"; + }) + ); + + return; + } + $command->mergeApplicationDefinition(false); $this->write( @@ -167,7 +185,7 @@ protected function describeApplication(Application $application, array $options foreach ($description->getCommands() as $command) { $this->write("\n\n"); - if (null !== $describeCommand = $this->describeCommand($command)) { + if (null !== $describeCommand = $this->describeCommand($command, $options)) { $this->write($describeCommand); } } diff --git a/src/Symfony/Component/Console/Descriptor/TextDescriptor.php b/src/Symfony/Component/Console/Descriptor/TextDescriptor.php index 07aef2a31a56f..b33dbb52f2f1a 100644 --- a/src/Symfony/Component/Console/Descriptor/TextDescriptor.php +++ b/src/Symfony/Component/Console/Descriptor/TextDescriptor.php @@ -74,7 +74,7 @@ protected function describeInputOption(InputOption $option, array $options = []) $totalWidth = $options['total_width'] ?? $this->calculateTotalWidthForOptions([$option]); $synopsis = sprintf('%s%s', $option->getShortcut() ? sprintf('-%s, ', $option->getShortcut()) : ' ', - sprintf('--%s%s', $option->getName(), $value) + sprintf($option->isNegatable() ? '--%1$s|--no-%1$s' : '--%1$s%2$s', $option->getName(), $value) ); $spacingWidth = $totalWidth - Helper::strlen($synopsis); @@ -325,8 +325,9 @@ private function calculateTotalWidthForOptions(array $options): int foreach ($options as $option) { // "-" + shortcut + ", --" + name $nameLength = 1 + max(Helper::strlen($option->getShortcut()), 1) + 4 + Helper::strlen($option->getName()); - - if ($option->acceptValue()) { + if ($option->isNegatable()) { + $nameLength += 6 + Helper::strlen($option->getName()); // |--no- + name + } elseif ($option->acceptValue()) { $valueLength = 1 + Helper::strlen($option->getName()); // = + value $valueLength += $option->isValueOptional() ? 2 : 0; // [ + ] diff --git a/src/Symfony/Component/Console/Descriptor/XmlDescriptor.php b/src/Symfony/Component/Console/Descriptor/XmlDescriptor.php index 4931fba625ee1..9bf9ea2b14120 100644 --- a/src/Symfony/Component/Console/Descriptor/XmlDescriptor.php +++ b/src/Symfony/Component/Console/Descriptor/XmlDescriptor.php @@ -44,36 +44,42 @@ public function getInputDefinitionDocument(InputDefinition $definition): \DOMDoc return $dom; } - public function getCommandDocument(Command $command): \DOMDocument + public function getCommandDocument(Command $command, bool $short = false): \DOMDocument { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->appendChild($commandXML = $dom->createElement('command')); - $command->mergeApplicationDefinition(false); - $commandXML->setAttribute('id', $command->getName()); $commandXML->setAttribute('name', $command->getName()); $commandXML->setAttribute('hidden', $command->isHidden() ? 1 : 0); $commandXML->appendChild($usagesXML = $dom->createElement('usages')); - foreach (array_merge([$command->getSynopsis()], $command->getAliases(), $command->getUsages()) as $usage) { - $usagesXML->appendChild($dom->createElement('usage', $usage)); - } - $commandXML->appendChild($descriptionXML = $dom->createElement('description')); $descriptionXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getDescription()))); - $commandXML->appendChild($helpXML = $dom->createElement('help')); - $helpXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getProcessedHelp()))); + if ($short) { + foreach ($command->getAliases() as $usage) { + $usagesXML->appendChild($dom->createElement('usage', $usage)); + } + } else { + $command->mergeApplicationDefinition(false); - $definitionXML = $this->getInputDefinitionDocument($command->getDefinition()); - $this->appendDocument($commandXML, $definitionXML->getElementsByTagName('definition')->item(0)); + foreach (array_merge([$command->getSynopsis()], $command->getAliases(), $command->getUsages()) as $usage) { + $usagesXML->appendChild($dom->createElement('usage', $usage)); + } + + $commandXML->appendChild($helpXML = $dom->createElement('help')); + $helpXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getProcessedHelp()))); + + $definitionXML = $this->getInputDefinitionDocument($command->getDefinition()); + $this->appendDocument($commandXML, $definitionXML->getElementsByTagName('definition')->item(0)); + } return $dom; } - public function getApplicationDocument(Application $application, string $namespace = null): \DOMDocument + public function getApplicationDocument(Application $application, string $namespace = null, bool $short = false): \DOMDocument { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->appendChild($rootXml = $dom->createElement('symfony')); @@ -94,7 +100,7 @@ public function getApplicationDocument(Application $application, string $namespa } foreach ($description->getCommands() as $command) { - $this->appendDocument($commandsXML, $this->getCommandDocument($command)); + $this->appendDocument($commandsXML, $this->getCommandDocument($command, $short)); } if (!$namespace) { @@ -143,7 +149,7 @@ protected function describeInputDefinition(InputDefinition $definition, array $o */ protected function describeCommand(Command $command, array $options = []) { - $this->writeDocument($this->getCommandDocument($command)); + $this->writeDocument($this->getCommandDocument($command, $options['short'] ?? false)); } /** @@ -151,7 +157,7 @@ protected function describeCommand(Command $command, array $options = []) */ protected function describeApplication(Application $application, array $options = []) { - $this->writeDocument($this->getApplicationDocument($application, $options['namespace'] ?? null)); + $this->writeDocument($this->getApplicationDocument($application, $options['namespace'] ?? null, $options['short'] ?? false)); } /** @@ -225,6 +231,17 @@ private function getInputOptionDocument(InputOption $option): \DOMDocument } } + if ($option->isNegatable()) { + $dom->appendChild($objectXML = $dom->createElement('option')); + $objectXML->setAttribute('name', '--no-'.$option->getName()); + $objectXML->setAttribute('shortcut', ''); + $objectXML->setAttribute('accept_value', 0); + $objectXML->setAttribute('is_value_required', 0); + $objectXML->setAttribute('is_multiple', 0); + $objectXML->appendChild($descriptionXML = $dom->createElement('description')); + $descriptionXML->appendChild($dom->createTextNode('Negate the "--'.$option->getName().'" option')); + } + return $dom; } } diff --git a/src/Symfony/Component/Console/Helper/ProgressBar.php b/src/Symfony/Component/Console/Helper/ProgressBar.php index a3aa4809779f4..0af615feef77d 100644 --- a/src/Symfony/Component/Console/Helper/ProgressBar.php +++ b/src/Symfony/Component/Console/Helper/ProgressBar.php @@ -26,6 +26,16 @@ */ final class ProgressBar { + public const FORMAT_VERBOSE = 'verbose'; + public const FORMAT_VERY_VERBOSE = 'very_verbose'; + public const FORMAT_DEBUG = 'debug'; + public const FORMAT_NORMAL = 'normal'; + + private const FORMAT_VERBOSE_NOMAX = 'verbose_nomax'; + private const FORMAT_VERY_VERBOSE_NOMAX = 'very_verbose_nomax'; + private const FORMAT_DEBUG_NOMAX = 'debug_nomax'; + private const FORMAT_NORMAL_NOMAX = 'normal_nomax'; + private $barWidth = 28; private $barChar; private $emptyBarChar = '-'; @@ -489,13 +499,13 @@ private function determineBestFormat(): string switch ($this->output->getVerbosity()) { // OutputInterface::VERBOSITY_QUIET: display is disabled anyway case OutputInterface::VERBOSITY_VERBOSE: - return $this->max ? 'verbose' : 'verbose_nomax'; + return $this->max ? self::FORMAT_VERBOSE : self::FORMAT_VERBOSE_NOMAX; case OutputInterface::VERBOSITY_VERY_VERBOSE: - return $this->max ? 'very_verbose' : 'very_verbose_nomax'; + return $this->max ? self::FORMAT_VERY_VERBOSE : self::FORMAT_VERY_VERBOSE_NOMAX; case OutputInterface::VERBOSITY_DEBUG: - return $this->max ? 'debug' : 'debug_nomax'; + return $this->max ? self::FORMAT_DEBUG : self::FORMAT_DEBUG_NOMAX; default: - return $this->max ? 'normal' : 'normal_nomax'; + return $this->max ? self::FORMAT_NORMAL : self::FORMAT_NORMAL_NOMAX; } } @@ -547,17 +557,17 @@ private static function initPlaceholderFormatters(): array private static function initFormats(): array { return [ - 'normal' => ' %current%/%max% [%bar%] %percent:3s%%', - 'normal_nomax' => ' %current% [%bar%]', + self::FORMAT_NORMAL => ' %current%/%max% [%bar%] %percent:3s%%', + self::FORMAT_NORMAL_NOMAX => ' %current% [%bar%]', - 'verbose' => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%', - 'verbose_nomax' => ' %current% [%bar%] %elapsed:6s%', + self::FORMAT_VERBOSE => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%', + self::FORMAT_VERBOSE_NOMAX => ' %current% [%bar%] %elapsed:6s%', - 'very_verbose' => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%', - 'very_verbose_nomax' => ' %current% [%bar%] %elapsed:6s%', + self::FORMAT_VERY_VERBOSE => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%', + self::FORMAT_VERY_VERBOSE_NOMAX => ' %current% [%bar%] %elapsed:6s%', - 'debug' => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%', - 'debug_nomax' => ' %current% [%bar%] %elapsed:6s% %memory:6s%', + self::FORMAT_DEBUG => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%', + self::FORMAT_DEBUG_NOMAX => ' %current% [%bar%] %elapsed:6s% %memory:6s%', ]; } diff --git a/src/Symfony/Component/Console/Input/ArgvInput.php b/src/Symfony/Component/Console/Input/ArgvInput.php index 2171bdc968519..9dd4de780362a 100644 --- a/src/Symfony/Component/Console/Input/ArgvInput.php +++ b/src/Symfony/Component/Console/Input/ArgvInput.php @@ -209,7 +209,17 @@ private function addShortOption(string $shortcut, $value) private function addLongOption(string $name, $value) { if (!$this->definition->hasOption($name)) { - throw new RuntimeException(sprintf('The "--%s" option does not exist.', $name)); + if (!$this->definition->hasNegation($name)) { + throw new RuntimeException(sprintf('The "--%s" option does not exist.', $name)); + } + + $optionName = $this->definition->negationToName($name); + if (null !== $value) { + throw new RuntimeException(sprintf('The "--%s" option does not accept a value.', $name)); + } + $this->options[$optionName] = false; + + return; } $option = $this->definition->getOption($name); diff --git a/src/Symfony/Component/Console/Input/ArrayInput.php b/src/Symfony/Component/Console/Input/ArrayInput.php index 66f0bedc3626b..2473f806e5647 100644 --- a/src/Symfony/Component/Console/Input/ArrayInput.php +++ b/src/Symfony/Component/Console/Input/ArrayInput.php @@ -165,7 +165,14 @@ private function addShortOption(string $shortcut, $value) private function addLongOption(string $name, $value) { if (!$this->definition->hasOption($name)) { - throw new InvalidOptionException(sprintf('The "--%s" option does not exist.', $name)); + if (!$this->definition->hasNegation($name)) { + throw new InvalidOptionException(sprintf('The "--%s" option does not exist.', $name)); + } + + $optionName = $this->definition->negationToName($name); + $this->options[$optionName] = false; + + return; } $option = $this->definition->getOption($name); diff --git a/src/Symfony/Component/Console/Input/InputDefinition.php b/src/Symfony/Component/Console/Input/InputDefinition.php index a32e913b7d5f9..2fcf7771271ee 100644 --- a/src/Symfony/Component/Console/Input/InputDefinition.php +++ b/src/Symfony/Component/Console/Input/InputDefinition.php @@ -33,6 +33,7 @@ class InputDefinition private $hasAnArrayArgument = false; private $hasOptional; private $options; + private $negations; private $shortcuts; /** @@ -208,6 +209,7 @@ public function setOptions(array $options = []) { $this->options = []; $this->shortcuts = []; + $this->negations = []; $this->addOptions($options); } @@ -231,6 +233,9 @@ public function addOption(InputOption $option) if (isset($this->options[$option->getName()]) && !$option->equals($this->options[$option->getName()])) { throw new LogicException(sprintf('An option named "%s" already exists.', $option->getName())); } + if (isset($this->negations[$option->getName()])) { + throw new LogicException(sprintf('An option named "%s" already exists.', $option->getName())); + } if ($option->getShortcut()) { foreach (explode('|', $option->getShortcut()) as $shortcut) { @@ -246,6 +251,14 @@ public function addOption(InputOption $option) $this->shortcuts[$shortcut] = $option->getName(); } } + + if ($option->isNegatable()) { + $negatedName = 'no-'.$option->getName(); + if (isset($this->options[$negatedName])) { + throw new LogicException(sprintf('An option named "%s" already exists.', $negatedName)); + } + $this->negations[$negatedName] = $option->getName(); + } } /** @@ -297,6 +310,14 @@ public function hasShortcut(string $name) return isset($this->shortcuts[$name]); } + /** + * Returns true if an InputOption object exists by negated name. + */ + public function hasNegation(string $name): bool + { + return isset($this->negations[$name]); + } + /** * Gets an InputOption by shortcut. * @@ -338,6 +359,22 @@ public function shortcutToName(string $shortcut): string return $this->shortcuts[$shortcut]; } + /** + * Returns the InputOption name given a negation. + * + * @throws InvalidArgumentException When option given does not exist + * + * @internal + */ + public function negationToName(string $negation): string + { + if (!isset($this->negations[$negation])) { + throw new InvalidArgumentException(sprintf('The "--%s" option does not exist.', $negation)); + } + + return $this->negations[$negation]; + } + /** * Gets the synopsis. * @@ -362,7 +399,8 @@ public function getSynopsis(bool $short = false) } $shortcut = $option->getShortcut() ? sprintf('-%s|', $option->getShortcut()) : ''; - $elements[] = sprintf('[%s--%s%s]', $shortcut, $option->getName(), $value); + $negation = $option->isNegatable() ? sprintf('|--no-%s', $option->getName()) : ''; + $elements[] = sprintf('[%s--%s%s%s]', $shortcut, $option->getName(), $value, $negation); } } diff --git a/src/Symfony/Component/Console/Input/InputOption.php b/src/Symfony/Component/Console/Input/InputOption.php index 66f857a6c0d3b..08be6eac9477b 100644 --- a/src/Symfony/Component/Console/Input/InputOption.php +++ b/src/Symfony/Component/Console/Input/InputOption.php @@ -25,6 +25,7 @@ class InputOption public const VALUE_REQUIRED = 2; public const VALUE_OPTIONAL = 4; public const VALUE_IS_ARRAY = 8; + public const VALUE_NEGATABLE = 16; private $name; private $shortcut; @@ -70,7 +71,7 @@ public function __construct(string $name, $shortcut = null, int $mode = null, st if (null === $mode) { $mode = self::VALUE_NONE; - } elseif ($mode > 15 || $mode < 1) { + } elseif ($mode >= (self::VALUE_NEGATABLE << 1) || $mode < 1) { throw new InvalidArgumentException(sprintf('Option mode "%s" is not valid.', $mode)); } @@ -82,6 +83,9 @@ public function __construct(string $name, $shortcut = null, int $mode = null, st if ($this->isArray() && !$this->acceptValue()) { throw new InvalidArgumentException('Impossible to have an option mode VALUE_IS_ARRAY if the option does not accept a value.'); } + if ($this->isNegatable() && $this->acceptValue()) { + throw new InvalidArgumentException('Impossible to have an option mode VALUE_NEGATABLE if the option also accepts a value.'); + } $this->setDefault($default); } @@ -146,6 +150,11 @@ public function isArray() return self::VALUE_IS_ARRAY === (self::VALUE_IS_ARRAY & $this->mode); } + public function isNegatable(): bool + { + return self::VALUE_NEGATABLE === (self::VALUE_NEGATABLE & $this->mode); + } + /** * Sets the default value. * @@ -158,6 +167,9 @@ public function setDefault($default = null) if (self::VALUE_NONE === (self::VALUE_NONE & $this->mode) && null !== $default) { throw new LogicException('Cannot set a default value when using InputOption::VALUE_NONE mode.'); } + if (self::VALUE_NEGATABLE === (self::VALUE_NEGATABLE & $this->mode) && null !== $default) { + throw new LogicException('Cannot set a default value when using InputOption::VALUE_NEGATABLE mode.'); + } if ($this->isArray()) { if (null === $default) { @@ -200,6 +212,7 @@ public function equals(self $option) return $option->getName() === $this->getName() && $option->getShortcut() === $this->getShortcut() && $option->getDefault() === $this->getDefault() + && $option->isNegatable() === $this->isNegatable() && $option->isArray() === $this->isArray() && $option->isValueRequired() === $this->isValueRequired() && $option->isValueOptional() === $this->isValueOptional() diff --git a/src/Symfony/Component/Console/Tests/ApplicationTest.php b/src/Symfony/Component/Console/Tests/ApplicationTest.php index 4751ba1a20eac..7bf1e570051f5 100644 --- a/src/Symfony/Component/Console/Tests/ApplicationTest.php +++ b/src/Symfony/Component/Console/Tests/ApplicationTest.php @@ -1259,7 +1259,8 @@ public function testGetDefaultInputDefinitionReturnsDefaultValues() $this->assertTrue($inputDefinition->hasOption('verbose')); $this->assertTrue($inputDefinition->hasOption('version')); $this->assertTrue($inputDefinition->hasOption('ansi')); - $this->assertTrue($inputDefinition->hasOption('no-ansi')); + $this->assertTrue($inputDefinition->hasNegation('no-ansi')); + $this->assertFalse($inputDefinition->hasOption('no-ansi')); $this->assertTrue($inputDefinition->hasOption('no-interaction')); } @@ -1279,7 +1280,7 @@ public function testOverwritingDefaultInputDefinitionOverwritesDefaultValues() $this->assertFalse($inputDefinition->hasOption('verbose')); $this->assertFalse($inputDefinition->hasOption('version')); $this->assertFalse($inputDefinition->hasOption('ansi')); - $this->assertFalse($inputDefinition->hasOption('no-ansi')); + $this->assertFalse($inputDefinition->hasNegation('no-ansi')); $this->assertFalse($inputDefinition->hasOption('no-interaction')); $this->assertTrue($inputDefinition->hasOption('custom')); @@ -1303,7 +1304,7 @@ public function testSettingCustomInputDefinitionOverwritesDefaultValues() $this->assertFalse($inputDefinition->hasOption('verbose')); $this->assertFalse($inputDefinition->hasOption('version')); $this->assertFalse($inputDefinition->hasOption('ansi')); - $this->assertFalse($inputDefinition->hasOption('no-ansi')); + $this->assertFalse($inputDefinition->hasNegation('no-ansi')); $this->assertFalse($inputDefinition->hasOption('no-interaction')); $this->assertTrue($inputDefinition->hasOption('custom')); diff --git a/src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php b/src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php new file mode 100644 index 0000000000000..4325508399113 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/CI/GithubActionReporterTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\CI; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\CI\GithubActionReporter; +use Symfony\Component\Console\Output\BufferedOutput; + +class GithubActionReporterTest extends TestCase +{ + public function testIsGithubActionEnvironment() + { + $prev = getenv('GITHUB_ACTIONS'); + putenv('GITHUB_ACTIONS'); + + try { + self::assertFalse(GithubActionReporter::isGithubActionEnvironment()); + putenv('GITHUB_ACTIONS=1'); + self::assertTrue(GithubActionReporter::isGithubActionEnvironment()); + } finally { + putenv('GITHUB_ACTIONS'.($prev ? "=$prev" : '')); + } + } + + /** + * @dataProvider annotationsFormatProvider + */ + public function testAnnotationsFormat(string $type, string $message, string $file = null, int $line = null, int $col = null, string $expected) + { + $reporter = new GithubActionReporter($buffer = new BufferedOutput()); + + $reporter->{$type}($message, $file, $line, $col); + + self::assertSame($expected.\PHP_EOL, $buffer->fetch()); + } + + public function annotationsFormatProvider(): iterable + { + yield 'warning' => ['warning', 'A warning', null, null, null, '::warning::A warning']; + yield 'error' => ['error', 'An error', null, null, null, '::error::An error']; + yield 'debug' => ['debug', 'A debug log', null, null, null, '::debug::A debug log']; + + yield 'with message to escape' => [ + 'debug', + "There are 100% chances\nfor this to be escaped properly\rRight?", + null, + null, + null, + '::debug::There are 100%25 chances%0Afor this to be escaped properly%0DRight?', + ]; + + yield 'with meta' => [ + 'warning', + 'A warning', + 'foo/bar.php', + 2, + 4, + '::warning file=foo/bar.php, line=2, col=4::A warning', + ]; + + yield 'with file property to escape' => [ + 'warning', + 'A warning', + 'foo,bar:baz%quz.php', + 2, + 4, + '::warning file=foo%2Cbar%3Abaz%25quz.php, line=2, col=4::A warning', + ]; + + yield 'without file ignores col & line' => ['warning', 'A warning', null, 2, 4, '::warning::A warning']; + } +} diff --git a/src/Symfony/Component/Console/Tests/ColorTest.php b/src/Symfony/Component/Console/Tests/ColorTest.php index 571963cfce788..c9615aa8d6133 100644 --- a/src/Symfony/Component/Console/Tests/ColorTest.php +++ b/src/Symfony/Component/Console/Tests/ColorTest.php @@ -24,6 +24,9 @@ public function testAnsiColors() $color = new Color('red', 'yellow'); $this->assertSame("\033[31;43m \033[39;49m", $color->apply(' ')); + $color = new Color('bright-red', 'bright-yellow'); + $this->assertSame("\033[91;103m \033[39;49m", $color->apply(' ')); + $color = new Color('red', 'yellow', ['underscore']); $this->assertSame("\033[31;43;4m \033[39;49;24m", $color->apply(' ')); } diff --git a/src/Symfony/Component/Console/Tests/Command/CommandTest.php b/src/Symfony/Component/Console/Tests/Command/CommandTest.php index ead75ebd3fb6a..fd6c4ba06e493 100644 --- a/src/Symfony/Component/Console/Tests/Command/CommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/CommandTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\ConsoleCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Helper\FormatterHelper; @@ -404,6 +405,15 @@ public function testSetCodeWithStaticAnonymousFunction() $this->assertEquals('interact called'.\PHP_EOL.'not bound'.\PHP_EOL, $tester->getDisplay()); } + + /** + * @requires PHP 8 + */ + public function testConsoleCommandAttribute() + { + $this->assertSame('|foo|f', Php8Command::getDefaultName()); + $this->assertSame('desc', Php8Command::getDefaultDescription()); + } } // In order to get an unbound closure, we should create it outside a class @@ -414,3 +424,8 @@ function createClosure() $output->writeln($this instanceof Command ? 'bound to the command' : 'not bound to the command'); }; } + +#[ConsoleCommand(name: 'foo', description: 'desc', hidden: true, aliases: ['f'])] +class Php8Command extends Command +{ +} diff --git a/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php b/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php index 5b25550a6d8ec..4576170a980c6 100644 --- a/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php @@ -65,7 +65,7 @@ public function testExecuteForApplicationCommandWithXmlOption() $application = new Application(); $commandTester = new CommandTester($application->get('help')); $commandTester->execute(['command_name' => 'list', '--format' => 'xml']); - $this->assertStringContainsString('list [--raw] [--format FORMAT] [--] [<namespace>]', $commandTester->getDisplay(), '->execute() returns a text help for the given command'); + $this->assertStringContainsString('list [--raw] [--format FORMAT] [--short] [--] [<namespace>]', $commandTester->getDisplay(), '->execute() returns a text help for the given command'); $this->assertStringContainsString('getDisplay(), '->execute() returns an XML help text if --format=xml is passed'); } } diff --git a/src/Symfony/Component/Console/Tests/Command/ListCommandTest.php b/src/Symfony/Component/Console/Tests/Command/ListCommandTest.php index a5d6653ad8186..a7c113565e311 100644 --- a/src/Symfony/Component/Console/Tests/Command/ListCommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/ListCommandTest.php @@ -80,8 +80,7 @@ public function testExecuteListsCommandsOrder() -h, --help Display help for the given command. When no command is given display help for the list command -q, --quiet Do not output any message -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug diff --git a/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 5e59f8fab3746..c0ecacd451e1d 100644 --- a/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; @@ -20,6 +21,7 @@ use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\TypedReference; class AddConsoleCommandPassTest extends TestCase @@ -118,6 +120,39 @@ public function visibilityProvider() ]; } + public function testProcessFallsBackToDefaultDescription() + { + $container = new ContainerBuilder(); + $container + ->register('with-defaults', DescribedCommand::class) + ->addTag('console.command') + ; + + $pass = new AddConsoleCommandPass(); + $pass->process($container); + + $commandLoader = $container->getDefinition('console.command_loader'); + $commandLocator = $container->getDefinition((string) $commandLoader->getArgument(0)); + + $this->assertSame(ContainerCommandLoader::class, $commandLoader->getClass()); + $this->assertSame(['cmdname' => 'with-defaults'], $commandLoader->getArgument(1)); + $this->assertEquals([['with-defaults' => new ServiceClosureArgument(new Reference('.with-defaults.lazy'))]], $commandLocator->getArguments()); + $this->assertSame([], $container->getParameter('console.command.ids')); + + $initCounter = DescribedCommand::$initCounter; + $command = $container->get('console.command_loader')->get('cmdname'); + + $this->assertInstanceOf(LazyCommand::class, $command); + $this->assertSame(['cmdalias'], $command->getAliases()); + $this->assertSame('Just testing', $command->getDescription()); + $this->assertTrue($command->isHidden()); + $this->assertTrue($command->isEnabled()); + $this->assertSame($initCounter, DescribedCommand::$initCounter); + + $this->assertSame('', $command->getHelp()); + $this->assertSame(1 + $initCounter, DescribedCommand::$initCounter); + } + public function testProcessThrowAnExceptionIfTheServiceIsAbstract() { $this->expectException(\InvalidArgumentException::class); @@ -250,3 +285,18 @@ class NamedCommand extends Command { protected static $defaultName = 'default'; } + +class DescribedCommand extends Command +{ + public static $initCounter = 0; + + protected static $defaultName = '|cmdname|cmdalias'; + protected static $defaultDescription = 'Just testing'; + + public function __construct() + { + ++self::$initCounter; + + parent::__construct(); + } +} diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_1.json b/src/Symfony/Component/Console/Tests/Fixtures/application_1.json index bb1eab28ed4c1..b94cf05d2e1b7 100644 --- a/src/Symfony/Component/Console/Tests/Fixtures/application_1.json +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_1.json @@ -79,7 +79,7 @@ "accept_value": false, "is_value_required": false, "is_multiple": false, - "description": "Force ANSI output", + "description": "Force (or disable --no-ansi) ANSI output", "default": false }, "no-ansi": { @@ -88,7 +88,7 @@ "accept_value": false, "is_value_required": false, "is_multiple": false, - "description": "Disable ANSI output", + "description": "Negate the \"--ansi\" option", "default": false }, "no-interaction": { @@ -107,7 +107,7 @@ "name": "list", "hidden": false, "usage": [ - "list [--raw] [--format FORMAT] [--] []" + "list [--raw] [--format FORMAT] [--short] [--] []" ], "description": "Lists commands", "help": "The list<\/info> command lists all commands:\n\n app\/console list<\/info>\n\nYou can also display the commands for a specific namespace:\n\n app\/console list test<\/info>\n\nYou can also output the information in other formats by using the --format<\/comment> option:\n\n app\/console list --format=xml<\/info>\n\nIt's also possible to get raw list of commands (useful for embedding command runner):\n\n app\/console list --raw<\/info>", @@ -182,7 +182,7 @@ "accept_value": false, "is_value_required": false, "is_multiple": false, - "description": "Force ANSI output", + "description": "Force (or disable --no-ansi) ANSI output", "default": false }, "no-ansi": { @@ -191,7 +191,7 @@ "accept_value": false, "is_value_required": false, "is_multiple": false, - "description": "Disable ANSI output", + "description": "Negate the \"--ansi\" option", "default": false }, "no-interaction": { @@ -202,6 +202,15 @@ "is_multiple": false, "description": "Do not ask any interactive question", "default": false + }, + "short": { + "name": "--short", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "To skip describing commands' arguments", + "default": false } } } diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_1.md b/src/Symfony/Component/Console/Tests/Fixtures/application_1.md index 38c84e4e232b9..163d68ff66fbf 100644 --- a/src/Symfony/Component/Console/Tests/Fixtures/application_1.md +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_1.md @@ -42,6 +42,7 @@ The output format (txt, xml, json, or md) * Accept value: yes * Is value required: yes * Is multiple: no +* Is negatable: no * Default: `'txt'` #### `--raw` @@ -51,6 +52,7 @@ To output raw command help * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--help|-h` @@ -60,6 +62,7 @@ Display help for the given command. When no command is given display help for th * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--quiet|-q` @@ -69,6 +72,7 @@ Do not output any message * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--verbose|-v|-vv|-vvv` @@ -78,6 +82,7 @@ Increase the verbosity of messages: 1 for normal output, 2 for more verbose outp * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--version|-V` @@ -87,24 +92,17 @@ Display this application version * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` -#### `--ansi` +#### `--ansi|--no-ansi` -Force ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Default: `false` - -#### `--no-ansi` - -Disable ANSI output +Force (or disable --no-ansi) ANSI output * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: yes * Default: `false` #### `--no-interaction|-n` @@ -114,6 +112,7 @@ Do not ask any interactive question * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` `list` @@ -123,7 +122,7 @@ Lists commands ### Usage -* `list [--raw] [--format FORMAT] [--] []` +* `list [--raw] [--format FORMAT] [--short] [--] []` The list command lists all commands: @@ -160,6 +159,7 @@ To output raw command list * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--format` @@ -169,8 +169,19 @@ The output format (txt, xml, json, or md) * Accept value: yes * Is value required: yes * Is multiple: no +* Is negatable: no * Default: `'txt'` +#### `--short` + +To skip describing commands' arguments + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + #### `--help|-h` Display help for the given command. When no command is given display help for the list command @@ -178,6 +189,7 @@ Display help for the given command. When no command is given display help for th * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--quiet|-q` @@ -187,6 +199,7 @@ Do not output any message * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--verbose|-v|-vv|-vvv` @@ -196,6 +209,7 @@ Increase the verbosity of messages: 1 for normal output, 2 for more verbose outp * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` #### `--version|-V` @@ -205,24 +219,17 @@ Display this application version * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` -#### `--ansi` - -Force ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Default: `false` - -#### `--no-ansi` +#### `--ansi|--no-ansi` -Disable ANSI output +Force (or disable --no-ansi) ANSI output * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: yes * Default: `false` #### `--no-interaction|-n` @@ -232,4 +239,5 @@ Do not ask any interactive question * Accept value: no * Is value required: no * Is multiple: no +* Is negatable: no * Default: `false` diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_1.txt b/src/Symfony/Component/Console/Tests/Fixtures/application_1.txt index cc55447241368..47b6524904031 100644 --- a/src/Symfony/Component/Console/Tests/Fixtures/application_1.txt +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_1.txt @@ -7,8 +7,7 @@ Console Tool -h, --help Display help for the given command. When no command is given display help for the list command -q, --quiet Do not output any message -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml b/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml index 5b6a906c5a2a2..5aa8e5a876a7d 100644 --- a/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml @@ -46,10 +46,10 @@ Display this application version + @@ -105,10 +108,10 @@ Display this application version + @@ -105,10 +108,10 @@ Display this application version + + @@ -38,6 +59,7 @@ + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php index 9f059a80d9891..6ab2cce23885c 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php @@ -16,14 +16,22 @@ use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration; use Symfony\Component\DependencyInjection\Tests\Fixtures\BarTagClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedForDefaultPriorityClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooTagClass; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService1; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService2; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService3; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService3Configurator; use Symfony\Contracts\Service\ServiceProviderInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -506,6 +514,109 @@ public function testTaggedServiceLocatorWithDefaultIndex() ]; $this->assertSame($expected, ['baz' => $serviceLocator->get('baz')]); } + + /** + * @requires PHP 8 + */ + public function testTagsViaAttribute() + { + $container = new ContainerBuilder(); + $container->registerAttributeForAutoconfiguration( + CustomAutoconfiguration::class, + static function (ChildDefinition $definition, CustomAutoconfiguration $attribute, \ReflectionClass $reflector) { + $definition->addTag('app.custom_tag', get_object_vars($attribute) + ['class' => $reflector->getName()]); + } + ); + + $container->register('one', TaggedService1::class) + ->setPublic(true) + ->setAutoconfigured(true); + $container->register('two', TaggedService2::class) + ->addTag('app.custom_tag', ['info' => 'This tag is not autoconfigured']) + ->setPublic(true) + ->setAutoconfigured(true); + + $collector = new TagCollector(); + $container->addCompilerPass($collector); + + $container->compile(); + + self::assertSame([ + 'one' => [ + ['someAttribute' => 'one', 'priority' => 0, 'class' => TaggedService1::class], + ['someAttribute' => 'two', 'priority' => 0, 'class' => TaggedService1::class], + ], + 'two' => [ + ['info' => 'This tag is not autoconfigured'], + ['someAttribute' => 'prio 100', 'priority' => 100, 'class' => TaggedService2::class], + ], + ], $collector->collectedTags); + } + + /** + * @requires PHP 8 + */ + public function testAttributesAreIgnored() + { + $container = new ContainerBuilder(); + $container->registerAttributeForAutoconfiguration( + CustomAutoconfiguration::class, + static function (Definition $definition, CustomAutoconfiguration $attribute) { + $definition->addTag('app.custom_tag', get_object_vars($attribute)); + } + ); + + $container->register('one', TaggedService1::class) + ->setPublic(true) + ->addTag('container.ignore_attributes') + ->setAutoconfigured(true); + $container->register('two', TaggedService2::class) + ->setPublic(true) + ->setAutoconfigured(true); + + $collector = new TagCollector(); + $container->addCompilerPass($collector); + + $container->compile(); + + self::assertSame([ + 'two' => [ + ['someAttribute' => 'prio 100', 'priority' => 100], + ], + ], $collector->collectedTags); + } + + /** + * @requires PHP 8 + */ + public function testAutoconfigureViaAttribute() + { + $container = new ContainerBuilder(); + $container->registerAttributeForAutoconfiguration( + CustomAutoconfiguration::class, + static function (ChildDefinition $definition) { + $definition + ->addMethodCall('doSomething', [1, 2, 3]) + ->setBindings(['string $foo' => 'bar']) + ->setConfigurator(new Reference('my_configurator')) + ; + } + ); + + $container->register('my_configurator', TaggedService3Configurator::class); + $container->register('three', TaggedService3::class) + ->setPublic(true) + ->setAutoconfigured(true); + + $container->compile(); + + /** @var TaggedService3 $service */ + $service = $container->get('three'); + + self::assertSame('bar', $service->foo); + self::assertSame(6, $service->sum); + self::assertTrue($service->hasBeenConfigured); + } } class ServiceSubscriberStub implements ServiceSubscriberInterface @@ -566,3 +677,13 @@ public function setSunshine($type) { } } + +final class TagCollector implements CompilerPassInterface +{ + public $collectedTags; + + public function process(ContainerBuilder $container): void + { + $this->collectedTags = $container->findTaggedServiceIds('app.custom_tag'); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php new file mode 100644 index 0000000000000..274cde655ed15 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Compiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Argument\BoundArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\RegisterAutoconfigureAttributesPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfigureAttributed; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredInterface; + +/** + * @requires PHP 8 + */ +class RegisterAutoconfigureAttributesPassTest extends TestCase +{ + public function testProcess() + { + $container = new ContainerBuilder(); + $container->register('foo', AutoconfigureAttributed::class) + ->setAutoconfigured(true); + + (new RegisterAutoconfigureAttributesPass())->process($container); + + $argument = new BoundArgument(1, true, BoundArgument::INSTANCEOF_BINDING, realpath(__DIR__.'/../Fixtures/AutoconfigureAttributed.php')); + $values = $argument->getValues(); + --$values[1]; + $argument->setValues($values); + + $expected = (new ChildDefinition('')) + ->setLazy(true) + ->setPublic(true) + ->setAutowired(true) + ->setShared(true) + ->setProperties(['bar' => 'baz']) + ->setConfigurator(new Reference('bla')) + ->addTag('a_tag') + ->addTag('another_tag', ['attr' => 234]) + ->addMethodCall('setBar', [2, 3]) + ->setBindings(['$bar' => $argument]) + ; + $this->assertEquals([AutoconfigureAttributed::class => $expected], $container->getAutoconfiguredInstanceof()); + } + + public function testIgnoreAttribute() + { + $container = new ContainerBuilder(); + $container->register('foo', AutoconfigureAttributed::class) + ->addTag('container.ignore_attributes') + ->setAutoconfigured(true); + + (new RegisterAutoconfigureAttributesPass())->process($container); + + $this->assertSame([], $container->getAutoconfiguredInstanceof()); + } + + public function testAutoconfiguredTag() + { + $container = new ContainerBuilder(); + $container->register('foo', AutoconfiguredInterface::class) + ->setAutoconfigured(true); + + (new RegisterAutoconfigureAttributesPass())->process($container); + + $expected = (new ChildDefinition('')) + ->addTag(AutoconfiguredInterface::class, ['foo' => 123]) + ; + $this->assertEquals([AutoconfiguredInterface::class => $expected], $container->getAutoconfiguredInstanceof()); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php index 86c270ebcc521..4c09243a40a84 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php @@ -33,6 +33,7 @@ public function testSimpleProcessor() 'foo' => ['string'], 'base64' => ['string'], 'bool' => ['bool'], + 'not' => ['bool'], 'const' => ['bool', 'int', 'float', 'string', 'array'], 'csv' => ['array'], 'file' => ['string'], diff --git a/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php b/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php index ab5f24b2ecba2..6a2460c9f296b 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php @@ -64,6 +64,22 @@ public function testGetEnvBool($value, $processed) $this->assertSame($processed, $result); } + /** + * @dataProvider validBools + */ + public function testGetEnvNot($value, $processed) + { + $processor = new EnvVarProcessor(new Container()); + + $result = $processor->getEnv('not', 'foo', function ($name) use ($value) { + $this->assertSame('foo', $name); + + return $value; + }); + + $this->assertSame(!$processed, $result); + } + public function validBools() { return [ diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Attribute/CustomAutoconfiguration.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Attribute/CustomAutoconfiguration.php new file mode 100644 index 0000000000000..e668834debbee --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Attribute/CustomAutoconfiguration.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class CustomAutoconfiguration +{ + public function __construct( + public string $someAttribute, + public int $priority = 0, + ) { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php new file mode 100644 index 0000000000000..7761e7134bb22 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php @@ -0,0 +1,29 @@ + 'baz', + ], + configurator: '@bla', + tags: [ + 'a_tag', + ['another_tag' => ['attr' => 234]], + ], + calls: [ + ['setBar' => [2, 3]] + ], + bind: [ + '$bar' => 1, + ], +)] +class AutoconfigureAttributed +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php new file mode 100644 index 0000000000000..413a0630cf2ee --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php @@ -0,0 +1,10 @@ + 123])] +interface AutoconfiguredInterface +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php index 1855dcfc59e0e..c6fc34fb68731 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php @@ -2,6 +2,9 @@ namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; + +#[Autoconfigure(tags: ['foo'])] interface FooInterface { } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService1.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService1.php new file mode 100644 index 0000000000000..ce05326022274 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService1.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration; + +#[CustomAutoconfiguration(someAttribute: 'one')] +#[CustomAutoconfiguration(someAttribute: 'two')] +final class TaggedService1 +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService2.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService2.php new file mode 100644 index 0000000000000..c282a88541508 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService2.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration; + +#[CustomAutoconfiguration(someAttribute: 'prio 100', priority: 100)] +final class TaggedService2 +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService3.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService3.php new file mode 100644 index 0000000000000..d13341aa9b4d9 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService3.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration; + +#[CustomAutoconfiguration(someAttribute: 'three')] +final class TaggedService3 +{ + public int $sum = 0; + public bool $hasBeenConfigured = false; + + public function __construct( + public string $foo, + ) { + } + + public function doSomething(int $a, int $b, int $c): void + { + $this->sum = $a + $b + $c; + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService3Configurator.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService3Configurator.php new file mode 100644 index 0000000000000..ae5c1307f2b5f --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedService3Configurator.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +final class TaggedService3Configurator +{ + public function __invoke(TaggedService3 $service) + { + $service->hasBeenConfigured = true; + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/remove.expected.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/remove.expected.yml new file mode 100644 index 0000000000000..176c61d5b80cb --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/remove.expected.yml @@ -0,0 +1,9 @@ + +services: + service_container: + class: Symfony\Component\DependencyInjection\ContainerInterface + public: true + synthetic: true + baz: + class: Symfony\Component\DependencyInjection\Loader\Configurator\BazService + public: true diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/remove.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/remove.php new file mode 100644 index 0000000000000..3781be5a2045c --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/remove.php @@ -0,0 +1,17 @@ +services()->defaults()->public(); + + $services + ->set('foo', FooService::class) + ->remove('foo') + + ->set('baz', BazService::class) + ->alias('baz-alias', 'baz') + ->remove('baz-alias') + + ->remove('bat'); // noop +}; diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/when-env.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/when-env.php new file mode 100644 index 0000000000000..5170261ad88d6 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/when-env.php @@ -0,0 +1,20 @@ +parameters() + ->set('foo', 123); + + $c->when('some-env') + ->parameters() + ->set('foo', 234) + ->set('bar', 345); + + $c->when('some-other-env') + ->parameters() + ->set('foo', 456) + ->set('baz', 567); +}; diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/ini/when-env.ini b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/ini/when-env.ini new file mode 100644 index 0000000000000..053fe86f86fbf --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/ini/when-env.ini @@ -0,0 +1,10 @@ +[parameters@some-env] + foo = 234 + bar = 345 + +[parameters@some-other-env] + foo = 456 + baz = 567 + +[parameters] + foo = 123 diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/when-env.xml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/when-env.xml new file mode 100644 index 0000000000000..1d12221a2fb50 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/when-env.xml @@ -0,0 +1,18 @@ + + + + 123 + + + + 234 + 345 + + + + + 456 + 567 + + + diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/when-env.yaml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/when-env.yaml new file mode 100644 index 0000000000000..22590d9b80a7d --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/when-env.yaml @@ -0,0 +1,12 @@ +when@some-env: + parameters: + foo: 234 + bar: 345 + +when@some-other-env: + parameters: + foo: 456 + baz: 567 + +parameters: + foo: 123 diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/ClosureLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/ClosureLoaderTest.php index 125e09b6cf8f0..a42ecf5a81b33 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/ClosureLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/ClosureLoaderTest.php @@ -27,12 +27,13 @@ public function testSupports() public function testLoad() { - $loader = new ClosureLoader($container = new ContainerBuilder()); + $loader = new ClosureLoader($container = new ContainerBuilder(), 'some-env'); - $loader->load(function ($container) { + $loader->load(function ($container, $env) { $container->setParameter('foo', 'foo'); + $container->setParameter('env', $env); }); - $this->assertEquals('foo', $container->getParameter('foo'), '->load() loads a \Closure resource'); + $this->assertSame(['foo' => 'foo', 'env' => 'some-env'], $container->getParameterBag()->all()); } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php index 2d9fcef4e051e..fbf825e299f22 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php @@ -15,6 +15,7 @@ use Psr\Container\ContainerInterface as PsrContainerInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderResolver; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; @@ -171,7 +172,7 @@ public function testNestedRegisterClasses() $container = new ContainerBuilder(); $loader = new TestFileLoader($container, new FileLocator(self::$fixturesPath.'/Fixtures')); - $prototype = new Definition(); + $prototype = (new Definition())->setAutoconfigured(true); $loader->registerClasses($prototype, 'Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\\', 'Prototype/*'); $this->assertTrue($container->has(Bar::class)); @@ -191,6 +192,10 @@ public function testNestedRegisterClasses() $this->assertSame(Foo::class, (string) $alias); $this->assertFalse($alias->isPublic()); $this->assertTrue($alias->isPrivate()); + + if (\PHP_VERSION_ID >= 80000) { + $this->assertEquals([FooInterface::class => (new ChildDefinition(''))->addTag('foo')], $container->getAutoconfiguredInstanceof()); + } } public function testMissingParentClass() diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/GlobFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/GlobFileLoaderTest.php index 29346742bd893..2f45c844c568e 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/GlobFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/GlobFileLoaderTest.php @@ -38,7 +38,7 @@ public function testLoadAddsTheGlobResourceToTheContainer() class GlobFileLoaderWithoutImport extends GlobFileLoader { - public function import($resource, $type = null, $ignoreErrors = false, $sourceResource = null, $exclude = null) + public function import($resource, string $type = null, $ignoreErrors = false, string $sourceResource = null, $exclude = null) { return null; } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/IniFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/IniFileLoaderTest.php index b4cf503237bc8..07f23f3137af5 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/IniFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/IniFileLoaderTest.php @@ -120,4 +120,13 @@ public function testSupports() $this->assertFalse($loader->supports('foo.foo'), '->supports() returns false if the resource is not loadable'); $this->assertTrue($loader->supports('with_wrong_ext.yml', 'ini'), '->supports() returns true if the resource with forced type is loadable'); } + + public function testWhenEnv() + { + $container = new ContainerBuilder(); + $loader = new IniFileLoader($container, new FileLocator(realpath(__DIR__.'/../Fixtures/').'/ini'), 'some-env'); + $loader->load('when-env.ini'); + + $this->assertSame(['foo' => 234, 'bar' => 345], $container->getParameterBag()->all()); + } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php index 992031b856c05..50f3c58dd89e3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php @@ -81,6 +81,7 @@ public function provideConfig() yield ['php7']; yield ['anonymous']; yield ['lazy_fqcn']; + yield ['remove']; } public function testAutoConfigureAndChildDefinition() @@ -137,6 +138,15 @@ public function testStack() $this->assertEquals($expected, $container->get('stack_d')); } + public function testWhenEnv() + { + $container = new ContainerBuilder(); + $loader = new PhpFileLoader($container, new FileLocator(realpath(__DIR__.'/../Fixtures').'/config'), 'some-env'); + $loader->load('when-env.php'); + + $this->assertSame(['foo' => 234, 'bar' => 345], $container->getParameterBag()->all()); + } + /** * @group legacy */ diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php index 8714905f71a8d..1e446d0ada71f 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/XmlFileLoaderTest.php @@ -1083,4 +1083,13 @@ public function testStack() $expected->label = 'Z'; $this->assertEquals($expected, $container->get('stack_d')); } + + public function testWhenEnv() + { + $container = new ContainerBuilder(); + $loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml'), 'some-env'); + $loader->load('when-env.xml'); + + $this->assertSame(['foo' => 234, 'bar' => 345], $container->getParameterBag()->all()); + } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php index 1a1dc9444a5f7..ecb7ac13b0203 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php @@ -1003,4 +1003,13 @@ public function testStack() ]; $this->assertEquals($expected, $container->get('stack_e')); } + + public function testWhenEnv() + { + $container = new ContainerBuilder(); + $loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml'), 'some-env'); + $loader->load('when-env.yaml'); + + $this->assertSame(['foo' => 234, 'bar' => 345], $container->getParameterBag()->all()); + } } diff --git a/src/Symfony/Component/DependencyInjection/composer.json b/src/Symfony/Component/DependencyInjection/composer.json index c5752e46d0214..efec2a3bc3882 100644 --- a/src/Symfony/Component/DependencyInjection/composer.json +++ b/src/Symfony/Component/DependencyInjection/composer.json @@ -24,7 +24,7 @@ }, "require-dev": { "symfony/yaml": "^4.4|^5.0", - "symfony/config": "^5.1", + "symfony/config": "^5.3", "symfony/expression-language": "^4.4|^5.0" }, "suggest": { @@ -35,7 +35,7 @@ "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them" }, "conflict": { - "symfony/config": "<5.1", + "symfony/config": "<5.3", "symfony/finder": "<4.4", "symfony/proxy-manager-bridge": "<4.4", "symfony/yaml": "<4.4" diff --git a/src/Symfony/Component/DomCrawler/CHANGELOG.md b/src/Symfony/Component/DomCrawler/CHANGELOG.md index 1d16a305a5d6b..3262b4a562d3d 100644 --- a/src/Symfony/Component/DomCrawler/CHANGELOG.md +++ b/src/Symfony/Component/DomCrawler/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +5.3 +--- + + * The `parents()` method is deprecated. Use `ancestors()` instead. + * Marked the `containsOption()`, `availableOptionValues()`, and `disableValidation()` methods of the + `ChoiceFormField` class as internal + 5.1.0 ----- diff --git a/src/Symfony/Component/DomCrawler/Crawler.php b/src/Symfony/Component/DomCrawler/Crawler.php index 79fae3eac8e63..b310df7e9ef42 100644 --- a/src/Symfony/Component/DomCrawler/Crawler.php +++ b/src/Symfony/Component/DomCrawler/Crawler.php @@ -33,6 +33,11 @@ class Crawler implements \Countable, \IteratorAggregate */ private $namespaces = []; + /** + * @var \ArrayObject A map of cached namespaces + */ + private $cachedNamespaces; + /** * @var string The base href value */ @@ -68,6 +73,7 @@ public function __construct($node = null, string $uri = null, string $baseHref = $this->uri = $uri; $this->baseHref = $baseHref ?: $uri; $this->html5Parser = class_exists(HTML5::class) ? new HTML5(['disable_html_ns' => true]) : null; + $this->cachedNamespaces = new \ArrayObject(); $this->add($node); } @@ -99,6 +105,7 @@ public function clear() { $this->nodes = []; $this->document = null; + $this->cachedNamespaces = new \ArrayObject(); } /** @@ -487,13 +494,27 @@ public function previousAll() } /** - * Returns the parents nodes of the current selection. + * Returns the parent nodes of the current selection. * * @return static * * @throws \InvalidArgumentException When current node is empty */ public function parents() + { + trigger_deprecation('symfony/dom-crawler', '5.3', sprintf('The %s() method is deprecated, use ancestors() instead.', __METHOD__)); + + return $this->ancestors(); + } + + /** + * Returns the ancestors of the current selection. + * + * @return static + * + * @throws \InvalidArgumentException When the current node is empty + */ + public function ancestors() { if (!$this->nodes) { throw new \InvalidArgumentException('The current node list is empty.'); @@ -967,12 +988,14 @@ public static function xpathLiteral(string $s) */ private function filterRelativeXPath(string $xpath): object { - $prefixes = $this->findNamespacePrefixes($xpath); - $crawler = $this->createSubCrawler(null); + if (null === $this->document) { + return $crawler; + } + + $domxpath = $this->createDOMXPath($this->document, $this->findNamespacePrefixes($xpath)); foreach ($this->nodes as $node) { - $domxpath = $this->createDOMXPath($node->ownerDocument, $prefixes); $crawler->add($domxpath->query($xpath, $node)); } @@ -1185,14 +1208,18 @@ private function createDOMXPath(\DOMDocument $document, array $prefixes = []): \ */ private function discoverNamespace(\DOMXPath $domxpath, string $prefix): ?string { - if (isset($this->namespaces[$prefix])) { + if (\array_key_exists($prefix, $this->namespaces)) { return $this->namespaces[$prefix]; } + if ($this->cachedNamespaces->offsetExists($prefix)) { + return $this->cachedNamespaces[$prefix]; + } + // ask for one namespace, otherwise we'd get a collection with an item for each node $namespaces = $domxpath->query(sprintf('(//namespace::*[name()="%s"])[last()]', $this->defaultNamespacePrefix === $prefix ? '' : $prefix)); - return ($node = $namespaces->item(0)) ? $node->nodeValue : null; + return $this->cachedNamespaces[$prefix] = ($node = $namespaces->item(0)) ? $node->nodeValue : null; } private function findNamespacePrefixes(string $xpath): array @@ -1217,6 +1244,7 @@ private function createSubCrawler($nodes): object $crawler->isHtml = $this->isHtml; $crawler->document = $this->document; $crawler->namespaces = $this->namespaces; + $crawler->cachedNamespaces = $this->cachedNamespaces; $crawler->html5Parser = $this->html5Parser; return $crawler; diff --git a/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php b/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php index 69fab34d7f1b5..a1369e081fa24 100644 --- a/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php +++ b/src/Symfony/Component/DomCrawler/Field/ChoiceFormField.php @@ -268,6 +268,8 @@ private function buildOptionValue(\DOMElement $node): array /** * Checks whether given value is in the existing options. * + * @internal since Symfony 5.3 + * * @return bool */ public function containsOption(string $optionValue, array $options) @@ -288,6 +290,8 @@ public function containsOption(string $optionValue, array $options) /** * Returns list of available field options. * + * @internal since Symfony 5.3 + * * @return array */ public function availableOptionValues() @@ -304,6 +308,8 @@ public function availableOptionValues() /** * Disables the internal validation of the field. * + * @internal since Symfony 5.3 + * * @return self */ public function disableValidation() diff --git a/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTest.php b/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTest.php index 43ba6879ebf02..77183348e9932 100644 --- a/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTest.php +++ b/src/Symfony/Component/DomCrawler/Tests/AbstractCrawlerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DomCrawler\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Form; use Symfony\Component\DomCrawler\Image; @@ -19,6 +20,8 @@ abstract class AbstractCrawlerTest extends TestCase { + use ExpectDeprecationTrait; + abstract public function getDoctype(): string; protected function createCrawler($node = null, string $uri = null, string $baseHref = null) @@ -412,7 +415,7 @@ public function testFilterXPath() $this->assertCount(6, $crawler->filterXPath('//li'), '->filterXPath() filters the node list with the XPath expression'); $crawler = $this->createTestCrawler(); - $this->assertCount(3, $crawler->filterXPath('//body')->filterXPath('//button')->parents(), '->filterXpath() preserves parents when chained'); + $this->assertCount(3, $crawler->filterXPath('//body')->filterXPath('//button')->ancestors(), '->filterXpath() preserves ancestors when chained'); } public function testFilterRemovesDuplicates() @@ -1085,8 +1088,13 @@ public function testFilteredChildren() $this->assertEquals(1, $foo->children('.ipsum')->count()); } + /** + * @group legacy + */ public function testParents() { + $this->expectDeprecation('Since symfony/dom-crawler 5.3: The Symfony\Component\DomCrawler\Crawler::parents() method is deprecated, use ancestors() instead.'); + $crawler = $this->createTestCrawler()->filterXPath('//li[1]'); $this->assertNotSame($crawler, $crawler->parents(), '->parents() returns a new instance of a crawler'); $this->assertInstanceOf(Crawler::class, $crawler->parents(), '->parents() returns a new instance of a crawler'); @@ -1105,6 +1113,27 @@ public function testParents() } } + public function testAncestors() + { + $crawler = $this->createTestCrawler()->filterXPath('//li[1]'); + + $nodes = $crawler->ancestors(); + + $this->assertNotSame($crawler, $nodes, '->ancestors() returns a new instance of a crawler'); + $this->assertInstanceOf(Crawler::class, $nodes, '->ancestors() returns a new instance of a crawler'); + + $this->assertEquals(3, $crawler->ancestors()->count()); + + $this->assertEquals(0, $this->createTestCrawler()->filterXPath('//html')->ancestors()->count()); + } + + public function testAncestorsThrowsIfNodeListIsEmpty() + { + $this->expectException(\InvalidArgumentException::class); + + $this->createTestCrawler()->filterXPath('//ol')->ancestors(); + } + /** * @dataProvider getBaseTagData */ diff --git a/src/Symfony/Component/DomCrawler/composer.json b/src/Symfony/Component/DomCrawler/composer.json index 230dce9014960..8c430824fe9f0 100644 --- a/src/Symfony/Component/DomCrawler/composer.json +++ b/src/Symfony/Component/DomCrawler/composer.json @@ -17,6 +17,7 @@ ], "require": { "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php80": "^1.15" diff --git a/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php b/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php index 09643dcb70ab9..3858e6b90362a 100644 --- a/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php +++ b/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php @@ -285,7 +285,7 @@ private function fileExcerpt(string $file, int $line, int $srcContext = 3): stri } for ($i = max($line - $srcContext, 1), $max = min($line + $srcContext, \count($content)); $i <= $max; ++$i) { - $lines[] = ''.$this->fixCodeMarkup($content[$i - 1]).''; + $lines[] = ''.$this->fixCodeMarkup($content[$i - 1]).''; } return '
    '.implode("\n", $lines).'
'; @@ -304,9 +304,9 @@ private function fixCodeMarkup(string $line) } // missing tag at the end of line - $opening = strpos($line, ''); - if (false !== $opening && (false === $closing || $closing > $opening)) { + $opening = strrpos($line, ''); + if (false !== $opening && (false === $closing || $closing < $opening)) { $line .= ''; } diff --git a/src/Symfony/Component/ErrorHandler/Resources/assets/css/exception.css b/src/Symfony/Component/ErrorHandler/Resources/assets/css/exception.css index e873c7366f84d..5822ea2043eb4 100644 --- a/src/Symfony/Component/ErrorHandler/Resources/assets/css/exception.css +++ b/src/Symfony/Component/ErrorHandler/Resources/assets/css/exception.css @@ -124,7 +124,7 @@ tr.status-error td, tr.status-warning td { border-bottom: 1px solid var(--base-2 .status-warning .colored { color: #A46A1F; } .status-error .colored { color: var(--color-error); } -.sf-toggle { cursor: pointer; } +.sf-toggle { cursor: pointer; position: relative; } .sf-toggle-content { -moz-transition: display .25s ease; -webkit-transition: display .25s ease; transition: display .25s ease; } .sf-toggle-content.sf-toggle-hidden { display: none; } .sf-toggle-content.sf-toggle-visible { display: block; } diff --git a/src/Symfony/Component/ErrorHandler/Resources/views/logs.html.php b/src/Symfony/Component/ErrorHandler/Resources/views/logs.html.php index f886200fb3b5b..9757e1a61151f 100644 --- a/src/Symfony/Component/ErrorHandler/Resources/views/logs.html.php +++ b/src/Symfony/Component/ErrorHandler/Resources/views/logs.html.php @@ -23,7 +23,7 @@ $status = \E_DEPRECATED === $severity || \E_USER_DEPRECATED === $severity ? 'warning' : 'normal'; } ?> data-filter-channel="escape($log['channel']); ?>"> - + escape($log['priorityName']); ?> diff --git a/src/Symfony/Component/ErrorHandler/Resources/views/traces.html.php b/src/Symfony/Component/ErrorHandler/Resources/views/traces.html.php index d587b058e26bf..f64d917138258 100644 --- a/src/Symfony/Component/ErrorHandler/Resources/views/traces.html.php +++ b/src/Symfony/Component/ErrorHandler/Resources/views/traces.html.php @@ -1,21 +1,30 @@
- -

- include('assets/images/icon-minus-square-o.svg'); ?> - include('assets/images/icon-plus-square-o.svg'); ?> - - - 1 ? '\\' : ''; ?> - - -

+
+ include('assets/images/icon-minus-square-o.svg'); ?> + include('assets/images/icon-plus-square-o.svg'); ?> + + +
+ +

+ + + + +

+ 1) { ?>

escape($exception['message']); ?>

- +
diff --git a/src/Symfony/Component/ErrorHandler/Resources/views/traces_text.html.php b/src/Symfony/Component/ErrorHandler/Resources/views/traces_text.html.php index a7090fbe8909e..6b478402c85a7 100644 --- a/src/Symfony/Component/ErrorHandler/Resources/views/traces_text.html.php +++ b/src/Symfony/Component/ErrorHandler/Resources/views/traces_text.html.php @@ -2,14 +2,14 @@ -

+
1) { ?> [/] include('assets/images/icon-minus-square-o.svg'); ?> include('assets/images/icon-plus-square-o.svg'); ?> -

+
diff --git a/src/Symfony/Component/EventDispatcher/Attribute/EventListener.php b/src/Symfony/Component/EventDispatcher/Attribute/EventListener.php new file mode 100644 index 0000000000000..a752673e565e1 --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/Attribute/EventListener.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\Attribute; + +/** + * Service tag to autoconfigure event listeners. + * + * @author Alexander M. Turek + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +class EventListener +{ + public function __construct( + public ?string $event = null, + public ?string $method = null, + public int $priority = 0, + public ?string $dispatcher = null, + ) { + } +} diff --git a/src/Symfony/Component/EventDispatcher/CHANGELOG.md b/src/Symfony/Component/EventDispatcher/CHANGELOG.md index 92a3b8bfc4d9e..f1a3b2220f4ec 100644 --- a/src/Symfony/Component/EventDispatcher/CHANGELOG.md +++ b/src/Symfony/Component/EventDispatcher/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Add `EventListener` attribute for declaring listeners on PHP 8 + 5.1.0 ----- diff --git a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php index bf2cebf6c0660..7ff08506582e8 100644 --- a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php +++ b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php @@ -13,12 +13,19 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\AttributeAutoconfigurationPass; +use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionalsPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\EventDispatcher\Attribute\EventListener; use Symfony\Component\EventDispatcher\DependencyInjection\AddEventAliasesPass; use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\EventDispatcher\Tests\Fixtures\CustomEvent; +use Symfony\Component\EventDispatcher\Tests\Fixtures\TaggedInvokableListener; +use Symfony\Component\EventDispatcher\Tests\Fixtures\TaggedMultiListener; class RegisterListenersPassTest extends TestCase { @@ -231,6 +238,90 @@ public function testInvokableEventListener() $this->assertEquals($expectedCalls, $definition->getMethodCalls()); } + /** + * @requires PHP 8 + */ + public function testTaggedInvokableEventListener() + { + if (!class_exists(AttributeAutoconfigurationPass::class)) { + self::markTestSkipped('This test requires Symfony DependencyInjection >= 5.3'); + } + + $container = new ContainerBuilder(); + $container->registerAttributeForAutoconfiguration(EventListener::class, static function (ChildDefinition $definition, EventListener $attribute): void { + $definition->addTag('kernel.event_listener', get_object_vars($attribute)); + }); + $container->register('foo', TaggedInvokableListener::class)->setAutoconfigured(true); + $container->register('event_dispatcher', \stdClass::class); + + (new AttributeAutoconfigurationPass())->process($container); + (new ResolveInstanceofConditionalsPass())->process($container); + (new RegisterListenersPass())->process($container); + + $definition = $container->getDefinition('event_dispatcher'); + $expectedCalls = [ + [ + 'addListener', + [ + CustomEvent::class, + [new ServiceClosureArgument(new Reference('foo')), '__invoke'], + 0, + ], + ], + ]; + $this->assertEquals($expectedCalls, $definition->getMethodCalls()); + } + + /** + * @requires PHP 8 + */ + public function testTaggedMultiEventListener() + { + if (!class_exists(AttributeAutoconfigurationPass::class)) { + self::markTestSkipped('This test requires Symfony DependencyInjection >= 5.3'); + } + + $container = new ContainerBuilder(); + $container->registerAttributeForAutoconfiguration(EventListener::class, static function (ChildDefinition $definition, EventListener $attribute): void { + $definition->addTag('kernel.event_listener', get_object_vars($attribute)); + }); + $container->register('foo', TaggedMultiListener::class)->setAutoconfigured(true); + $container->register('event_dispatcher', \stdClass::class); + + (new AttributeAutoconfigurationPass())->process($container); + (new ResolveInstanceofConditionalsPass())->process($container); + (new RegisterListenersPass())->process($container); + + $definition = $container->getDefinition('event_dispatcher'); + $expectedCalls = [ + [ + 'addListener', + [ + CustomEvent::class, + [new ServiceClosureArgument(new Reference('foo')), 'onCustomEvent'], + 0, + ], + ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('foo')), 'onFoo'], + 42, + ], + ], + [ + 'addListener', + [ + 'bar', + [new ServiceClosureArgument(new Reference('foo')), 'onBarEvent'], + 0, + ], + ], + ]; + $this->assertEquals($expectedCalls, $definition->getMethodCalls()); + } + public function testAliasedEventListener() { $container = new ContainerBuilder(); @@ -416,10 +507,6 @@ final class AliasedEvent { } -final class CustomEvent -{ -} - final class TypedListener { public function __invoke(AliasedEvent $event): void diff --git a/src/Symfony/Component/EventDispatcher/Tests/Fixtures/CustomEvent.php b/src/Symfony/Component/EventDispatcher/Tests/Fixtures/CustomEvent.php new file mode 100644 index 0000000000000..41d951c7abd04 --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/Tests/Fixtures/CustomEvent.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\Tests\Fixtures; + +final class CustomEvent +{ +} diff --git a/src/Symfony/Component/EventDispatcher/Tests/Fixtures/TaggedInvokableListener.php b/src/Symfony/Component/EventDispatcher/Tests/Fixtures/TaggedInvokableListener.php new file mode 100644 index 0000000000000..00a5e14d9e120 --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/Tests/Fixtures/TaggedInvokableListener.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\Tests\Fixtures; + +use Symfony\Component\EventDispatcher\Attribute\EventListener; + +#[EventListener] +final class TaggedInvokableListener +{ + public function __invoke(CustomEvent $event): void + { + } +} diff --git a/src/Symfony/Component/EventDispatcher/Tests/Fixtures/TaggedMultiListener.php b/src/Symfony/Component/EventDispatcher/Tests/Fixtures/TaggedMultiListener.php new file mode 100644 index 0000000000000..65a66b8aa221b --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/Tests/Fixtures/TaggedMultiListener.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\Tests\Fixtures; + +use Symfony\Component\EventDispatcher\Attribute\EventListener; + +#[EventListener(event: CustomEvent::class, method: 'onCustomEvent')] +#[EventListener(event: 'foo', priority: 42)] +#[EventListener(event: 'bar', method: 'onBarEvent')] +final class TaggedMultiListener +{ + public function onCustomEvent(CustomEvent $event): void + { + } + + public function onFoo(): void + { + } + + public function onBarEvent(): void + { + } +} diff --git a/src/Symfony/Component/Filesystem/Filesystem.php b/src/Symfony/Component/Filesystem/Filesystem.php index 35c568e71e7d5..9708e2b1bb869 100644 --- a/src/Symfony/Component/Filesystem/Filesystem.php +++ b/src/Symfony/Component/Filesystem/Filesystem.php @@ -50,13 +50,13 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe if ($doCopy) { // https://bugs.php.net/64634 - if (false === $source = @fopen($originFile, 'r')) { - throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading.', $originFile, $targetFile), 0, null, $originFile); + if (!$source = self::box('fopen', $originFile, 'r')) { + throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); } // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default - if (false === $target = @fopen($targetFile, 'w', null, stream_context_create(['ftp' => ['overwrite' => true]]))) { - throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing.', $originFile, $targetFile), 0, null, $originFile); + if (!$target = self::box('fopen', $targetFile, 'w', null, stream_context_create(['ftp' => ['overwrite' => true]]))) { + throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); } $bytesCopied = stream_copy_to_stream($source, $target); @@ -70,7 +70,7 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe if ($originIsLocal) { // Like `cp`, preserve executable permission bits - @chmod($targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111)); + self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111)); if ($bytesCopied !== $bytesOrigin = filesize($originFile)) { throw new IOException(sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile); @@ -93,14 +93,8 @@ public function mkdir($dirs, int $mode = 0777) continue; } - if (!self::box('mkdir', $dir, $mode, true)) { - if (!is_dir($dir)) { - // The directory was not created by a concurrent process. Let's throw an exception with a developer friendly error message if we have one - if (self::$lastError) { - throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); - } - throw new IOException(sprintf('Failed to create "%s".', $dir), 0, null, $dir); - } + if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) { + throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); } } } @@ -141,9 +135,8 @@ public function exists($files) public function touch($files, int $time = null, int $atime = null) { foreach ($this->toIterable($files) as $file) { - $touch = $time ? @touch($file, $time, $atime) : @touch($file); - if (true !== $touch) { - throw new IOException(sprintf('Failed to touch "%s".', $file), 0, null, $file); + if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) { + throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); } } } @@ -162,6 +155,12 @@ public function remove($files) } elseif (!\is_array($files)) { $files = [$files]; } + + self::doRemove($files, false); + } + + private static function doRemove(array $files, bool $isRecursive): void + { $files = array_reverse($files); foreach ($files as $file) { if (is_link($file)) { @@ -170,10 +169,35 @@ public function remove($files) throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); } } elseif (is_dir($file)) { - $this->remove(new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS)); + if (!$isRecursive) { + $tmpName = \dirname(realpath($file)).'/.'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-.')); + + if (file_exists($tmpName)) { + try { + self::doRemove([$tmpName], true); + } catch (IOException $e) { + } + } + + if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) { + $origFile = $file; + $file = $tmpName; + } else { + $origFile = null; + } + } + + $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); + self::doRemove(iterator_to_array($files, true), true); + + if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { + $lastError = self::$lastError; - if (!self::box('rmdir', $file) && file_exists($file)) { - throw new IOException(sprintf('Failed to remove directory "%s": ', $file).self::$lastError); + if (null !== $origFile && self::box('rename', $file, $origFile)) { + $file = $origFile; + } + + throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError); } } elseif (!self::box('unlink', $file) && (false !== strpos(self::$lastError, 'Permission denied') || file_exists($file))) { throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError); @@ -194,8 +218,8 @@ public function remove($files) public function chmod($files, int $mode, int $umask = 0000, bool $recursive = false) { foreach ($this->toIterable($files) as $file) { - if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && true !== @chmod($file, $mode & ~$umask)) { - throw new IOException(sprintf('Failed to chmod file "%s".', $file), 0, null, $file); + if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && !self::box('chmod', $file, $mode & ~$umask)) { + throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); } if ($recursive && is_dir($file) && !is_link($file)) { $this->chmod(new \FilesystemIterator($file), $mode, $umask, true); @@ -219,12 +243,12 @@ public function chown($files, $user, bool $recursive = false) $this->chown(new \FilesystemIterator($file), $user, true); } if (is_link($file) && \function_exists('lchown')) { - if (true !== @lchown($file, $user)) { - throw new IOException(sprintf('Failed to chown file "%s".', $file), 0, null, $file); + if (!self::box('lchown', $file, $user)) { + throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); } } else { - if (true !== @chown($file, $user)) { - throw new IOException(sprintf('Failed to chown file "%s".', $file), 0, null, $file); + if (!self::box('chown', $file, $user)) { + throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); } } } @@ -246,12 +270,12 @@ public function chgrp($files, $group, bool $recursive = false) $this->chgrp(new \FilesystemIterator($file), $group, true); } if (is_link($file) && \function_exists('lchgrp')) { - if (true !== @lchgrp($file, $group)) { - throw new IOException(sprintf('Failed to chgrp file "%s".', $file), 0, null, $file); + if (!self::box('lchgrp', $file, $group)) { + throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); } } else { - if (true !== @chgrp($file, $group)) { - throw new IOException(sprintf('Failed to chgrp file "%s".', $file), 0, null, $file); + if (!self::box('chgrp', $file, $group)) { + throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); } } } @@ -270,7 +294,7 @@ public function rename(string $origin, string $target, bool $overwrite = false) throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); } - if (true !== @rename($origin, $target)) { + if (!self::box('rename', $origin, $target)) { if (is_dir($origin)) { // See https://bugs.php.net/54097 & https://php.net/rename#113943 $this->mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]); @@ -278,7 +302,7 @@ public function rename(string $origin, string $target, bool $overwrite = false) return; } - throw new IOException(sprintf('Cannot rename "%s" to "%s".', $origin, $target), 0, null, $target); + throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); } } @@ -372,7 +396,7 @@ private function linkException(string $origin, string $target, string $linkType) throw new IOException(sprintf('Unable to create "%s" link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target); } } - throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s".', $linkType, $origin, $target), 0, null, $target); + throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); } /** @@ -594,10 +618,8 @@ public function tempnam(string $dir, string $prefix/*, string $suffix = ''*/) // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) { - $tmpFile = @tempnam($hierarchy, $prefix); - // If tempnam failed or no scheme return the filename otherwise prepend the scheme - if (false !== $tmpFile) { + if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) { if (null !== $scheme && 'gs' !== $scheme) { return $scheme.'://'.$tmpFile; } @@ -605,7 +627,7 @@ public function tempnam(string $dir, string $prefix/*, string $suffix = ''*/) return $tmpFile; } - throw new IOException('A temporary file could not be created.'); + throw new IOException('A temporary file could not be created: '.self::$lastError); } // Loop until we create a valid temp file or have reached 10 attempts @@ -615,20 +637,17 @@ public function tempnam(string $dir, string $prefix/*, string $suffix = ''*/) // Use fopen instead of file_exists as some streams do not support stat // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability - $handle = @fopen($tmpFile, 'x+'); - - // If unsuccessful restart the loop - if (false === $handle) { + if (!$handle = self::box('fopen', $tmpFile, 'x+')) { continue; } // Close the file if it was successfully opened - @fclose($handle); + self::box('fclose', $handle); return $tmpFile; } - throw new IOException('A temporary file could not be created.'); + throw new IOException('A temporary file could not be created: '.self::$lastError); } /** @@ -659,16 +678,16 @@ public function dumpFile(string $filename, $content) $tmpFile = $this->tempnam($dir, basename($filename)); try { - if (false === @file_put_contents($tmpFile, $content)) { - throw new IOException(sprintf('Failed to write file "%s".', $filename), 0, null, $filename); + if (false === self::box('file_put_contents', $tmpFile, $content)) { + throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); } - @chmod($tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask()); + self::box('chmod', $tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask()); $this->rename($tmpFile, $filename, true); } finally { if (file_exists($tmpFile)) { - @unlink($tmpFile); + self::box('unlink', $tmpFile); } } } @@ -696,8 +715,8 @@ public function appendToFile(string $filename, $content) throw new IOException(sprintf('Unable to write to the "%s" directory.', $dir), 0, null, $dir); } - if (false === @file_put_contents($filename, $content, \FILE_APPEND)) { - throw new IOException(sprintf('Failed to write file "%s".', $filename), 0, null, $filename); + if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND)) { + throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); } } diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index c5e227dbe0c38..2ab805eb990b1 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -1,6 +1,21 @@ CHANGELOG ========= +5.3 +--- + + * Changed `$forms` parameter type of the `DataMapperInterface::mapDataToForms()` method from `iterable` to `\Traversable`. + * Changed `$forms` parameter type of the `DataMapperInterface::mapFormsToData()` method from `iterable` to `\Traversable`. + * Deprecated passing an array as the second argument of the `DataMapper::mapDataToForms()` method, pass `\Traversable` instead. + * Deprecated passing an array as the first argument of the `DataMapper::mapFormsToData()` method, pass `\Traversable` instead. + * Deprecated passing an array as the second argument of the `CheckboxListMapper::mapDataToForms()` method, pass `\Traversable` instead. + * Deprecated passing an array as the first argument of the `CheckboxListMapper::mapFormsToData()` method, pass `\Traversable` instead. + * Deprecated passing an array as the second argument of the `RadioListMapper::mapDataToForms()` method, pass `\Traversable` instead. + * Deprecated passing an array as the first argument of the `RadioListMapper::mapFormsToData()` method, pass `\Traversable` instead. + * Added a `choice_translation_parameters` option to `ChoiceType` + * Add `UuidType` and `UlidType` + * Dependency on `symfony/intl` was removed. Install `symfony/intl` if you are using `LocaleType`, `CountryType`, `CurrencyType`, `LanguageType` or `TimezoneType`. + 5.2.0 ----- diff --git a/src/Symfony/Component/Form/ChoiceList/ChoiceList.php b/src/Symfony/Component/Form/ChoiceList/ChoiceList.php index 045ded01e2e05..df63c83d89dee 100644 --- a/src/Symfony/Component/Form/ChoiceList/ChoiceList.php +++ b/src/Symfony/Component/Form/ChoiceList/ChoiceList.php @@ -16,6 +16,7 @@ use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFilter; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceTranslationParameters; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue; use Symfony\Component\Form\ChoiceList\Factory\Cache\GroupBy; use Symfony\Component\Form\ChoiceList\Factory\Cache\PreferredChoice; @@ -113,6 +114,18 @@ public static function attr($formType, $attr, $vary = null): ChoiceAttr return new ChoiceAttr($formType, $attr, $vary); } + /** + * Decorates a "choice_translation_parameters" option to make it cacheable. + * + * @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list + * @param callable|array $translationParameters Any pseudo callable or array to create translation parameters from a choice + * @param mixed|null $vary Dynamic data used to compute a unique hash when caching the option + */ + public static function translationParameters($formType, $translationParameters, $vary = null): ChoiceTranslationParameters + { + return new ChoiceTranslationParameters($formType, $translationParameters, $vary); + } + /** * Decorates a "group_by" callback to make it cacheable. * diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceTranslationParameters.php b/src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceTranslationParameters.php new file mode 100644 index 0000000000000..e9ab5c7119922 --- /dev/null +++ b/src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceTranslationParameters.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_translation_parameters" option. + * + * @internal + * + * @author Vincent Langlet + */ +final class ChoiceTranslationParameters extends AbstractStaticOption +{ +} diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php b/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php index 2e1dc9a317654..c376f1a2d1c54 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php @@ -174,14 +174,16 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, $value = nul /** * {@inheritdoc} * - * @param array|callable|Cache\PreferredChoice|null $preferredChoices The preferred choices - * @param callable|false|Cache\ChoiceLabel|null $label The option or static option generating the choice labels - * @param callable|Cache\ChoiceFieldName|null $index The option or static option generating the view indices - * @param callable|Cache\GroupBy|null $groupBy The option or static option generating the group names - * @param array|callable|Cache\ChoiceAttr|null $attr The option or static option generating the HTML attributes + * @param array|callable|Cache\PreferredChoice|null $preferredChoices The preferred choices + * @param callable|false|Cache\ChoiceLabel|null $label The option or static option generating the choice labels + * @param callable|Cache\ChoiceFieldName|null $index The option or static option generating the view indices + * @param callable|Cache\GroupBy|null $groupBy The option or static option generating the group names + * @param array|callable|Cache\ChoiceAttr|null $attr The option or static option generating the HTML attributes + * @param array|callable|Cache\ChoiceTranslationParameters $labelTranslationParameters The parameters used to translate the choice labels */ - public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, $index = null, $groupBy = null, $attr = null) + public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, $index = null, $groupBy = null, $attr = null/*, $labelTranslationParameters = []*/) { + $labelTranslationParameters = \func_num_args() > 6 ? func_get_arg(6) : []; $cache = true; if ($preferredChoices instanceof Cache\PreferredChoice) { @@ -214,11 +216,25 @@ public function createView(ChoiceListInterface $list, $preferredChoices = null, $cache = false; } + if ($labelTranslationParameters instanceof Cache\ChoiceTranslationParameters) { + $labelTranslationParameters = $labelTranslationParameters->getOption(); + } elseif ([] !== $labelTranslationParameters) { + $cache = false; + } + if (!$cache) { - return $this->decoratedFactory->createView($list, $preferredChoices, $label, $index, $groupBy, $attr); + return $this->decoratedFactory->createView( + $list, + $preferredChoices, + $label, + $index, + $groupBy, + $attr, + $labelTranslationParameters + ); } - $hash = self::generateHash([$list, $preferredChoices, $label, $index, $groupBy, $attr]); + $hash = self::generateHash([$list, $preferredChoices, $label, $index, $groupBy, $attr, $labelTranslationParameters]); if (!isset($this->views[$hash])) { $this->views[$hash] = $this->decoratedFactory->createView( @@ -227,7 +243,8 @@ public function createView(ChoiceListInterface $list, $preferredChoices = null, $label, $index, $groupBy, - $attr + $attr, + $labelTranslationParameters ); } diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php b/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php index 82b1e4dc7de6b..6834009190f81 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php @@ -76,12 +76,13 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, callable $va * match the keys of the choices. The values should be arrays of HTML * attributes that should be added to the respective choice. * - * @param array|callable|null $preferredChoices The preferred choices - * @param callable|false|null $label The callable generating the choice labels; - * pass false to discard the label - * @param array|callable|null $attr The callable generating the HTML attributes + * @param array|callable|null $preferredChoices The preferred choices + * @param callable|false|null $label The callable generating the choice labels; + * pass false to discard the label + * @param array|callable|null $attr The callable generating the HTML attributes + * @param array|callable $labelTranslationParameters The parameters used to translate the choice labels * * @return ChoiceListView The choice list view */ - public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, callable $index = null, callable $groupBy = null, $attr = null); + public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, callable $index = null, callable $groupBy = null, $attr = null/*, $labelTranslationParameters = []*/); } diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php b/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php index 83a12339de26e..6545f60998aff 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php @@ -68,9 +68,12 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, callable $va /** * {@inheritdoc} + * + * @param array|callable $labelTranslationParameters The parameters used to translate the choice labels */ - public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, callable $index = null, callable $groupBy = null, $attr = null) + public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, callable $index = null, callable $groupBy = null, $attr = null/*, $labelTranslationParameters = []*/) { + $labelTranslationParameters = \func_num_args() > 6 ? func_get_arg(6) : []; $preferredViews = []; $preferredViewsOrder = []; $otherViews = []; @@ -109,6 +112,7 @@ public function createView(ChoiceListInterface $list, $preferredChoices = null, $keys, $index, $attr, + $labelTranslationParameters, $preferredChoices, $preferredViews, $preferredViewsOrder, @@ -146,6 +150,7 @@ public function createView(ChoiceListInterface $list, $preferredChoices = null, $keys, $index, $attr, + $labelTranslationParameters, $preferredChoices, $preferredViews, $preferredViewsOrder, @@ -162,7 +167,7 @@ public function createView(ChoiceListInterface $list, $preferredChoices = null, return new ChoiceListView($otherViews, $preferredViews); } - private static function addChoiceView($choice, string $value, $label, array $keys, &$index, $attr, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews) + private static function addChoiceView($choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews) { // $value may be an integer or a string, since it's stored in the array // keys. We want to guarantee it's a string though. @@ -186,7 +191,10 @@ private static function addChoiceView($choice, string $value, $label, array $key $label, // The attributes may be a callable or a mapping from choice indices // to nested arrays - \is_callable($attr) ? $attr($choice, $key, $value) : ($attr[$key] ?? []) + \is_callable($attr) ? $attr($choice, $key, $value) : ($attr[$key] ?? []), + // The label translation parameters may be a callable or a mapping from choice indices + // to nested arrays + \is_callable($labelTranslationParameters) ? $labelTranslationParameters($choice, $key, $value) : ($labelTranslationParameters[$key] ?? []) ); // $isPreferred may be null if no choices are preferred @@ -198,7 +206,7 @@ private static function addChoiceView($choice, string $value, $label, array $key $otherViews[$nextIndex] = $view; } - private static function addChoiceViewsFromStructuredValues(array $values, $label, array $choices, array $keys, &$index, $attr, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews) + private static function addChoiceViewsFromStructuredValues(array $values, $label, array $choices, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews) { foreach ($values as $key => $value) { if (null === $value) { @@ -217,6 +225,7 @@ private static function addChoiceViewsFromStructuredValues(array $values, $label $keys, $index, $attr, + $labelTranslationParameters, $isPreferred, $preferredViewsForGroup, $preferredViewsOrder, @@ -242,6 +251,7 @@ private static function addChoiceViewsFromStructuredValues(array $values, $label $keys, $index, $attr, + $labelTranslationParameters, $isPreferred, $preferredViews, $preferredViewsOrder, @@ -250,7 +260,7 @@ private static function addChoiceViewsFromStructuredValues(array $values, $label } } - private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choice, string $value, $label, array $keys, &$index, $attr, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews) + private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews) { $groupLabels = $groupBy($choice, $keys[$value], $value); @@ -263,6 +273,7 @@ private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choi $keys, $index, $attr, + $labelTranslationParameters, $isPreferred, $preferredViews, $preferredViewsOrder, @@ -292,6 +303,7 @@ private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choi $keys, $index, $attr, + $labelTranslationParameters, $isPreferred, $preferredViews[$groupLabel]->choices, $preferredViewsOrder[$groupLabel], diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php b/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php index bfa37973a565e..6d323fbb08777 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php @@ -145,16 +145,18 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, $value = nul /** * {@inheritdoc} * - * @param array|callable|string|PropertyPath|null $preferredChoices The preferred choices - * @param callable|string|PropertyPath|null $label The callable or path generating the choice labels - * @param callable|string|PropertyPath|null $index The callable or path generating the view indices - * @param callable|string|PropertyPath|null $groupBy The callable or path generating the group names - * @param array|callable|string|PropertyPath|null $attr The callable or path generating the HTML attributes + * @param array|callable|string|PropertyPath|null $preferredChoices The preferred choices + * @param callable|string|PropertyPath|null $label The callable or path generating the choice labels + * @param callable|string|PropertyPath|null $index The callable or path generating the view indices + * @param callable|string|PropertyPath|null $groupBy The callable or path generating the group names + * @param array|callable|string|PropertyPath|null $attr The callable or path generating the HTML attributes + * @param array|callable|string|PropertyPath $labelTranslationParameters The callable or path generating the parameters used to translate the choice labels * * @return ChoiceListView The choice list view */ - public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, $index = null, $groupBy = null, $attr = null) + public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, $index = null, $groupBy = null, $attr = null/*, $labelTranslationParameters = []*/) { + $labelTranslationParameters = \func_num_args() > 6 ? func_get_arg(6) : []; $accessor = $this->propertyAccessor; if (\is_string($label)) { @@ -217,6 +219,24 @@ public function createView(ChoiceListInterface $list, $preferredChoices = null, }; } - return $this->decoratedFactory->createView($list, $preferredChoices, $label, $index, $groupBy, $attr); + if (\is_string($labelTranslationParameters)) { + $labelTranslationParameters = new PropertyPath($labelTranslationParameters); + } + + if ($labelTranslationParameters instanceof PropertyPath) { + $labelTranslationParameters = static function ($choice) use ($accessor, $labelTranslationParameters) { + return $accessor->getValue($choice, $labelTranslationParameters); + }; + } + + return $this->decoratedFactory->createView( + $list, + $preferredChoices, + $label, + $index, + $groupBy, + $attr, + $labelTranslationParameters + ); } } diff --git a/src/Symfony/Component/Form/ChoiceList/View/ChoiceView.php b/src/Symfony/Component/Form/ChoiceList/View/ChoiceView.php index 2b5636b17eb1d..cbb9ed540e9a9 100644 --- a/src/Symfony/Component/Form/ChoiceList/View/ChoiceView.php +++ b/src/Symfony/Component/Form/ChoiceList/View/ChoiceView.php @@ -27,19 +27,26 @@ class ChoiceView */ public $attr; + /** + * Additional parameters used to translate the label. + */ + public $labelTranslationParameters; + /** * Creates a new choice view. * - * @param mixed $data The original choice - * @param string $value The view representation of the choice - * @param string|false $label The label displayed to humans; pass false to discard the label - * @param array $attr Additional attributes for the HTML tag + * @param mixed $data The original choice + * @param string $value The view representation of the choice + * @param string|false $label The label displayed to humans; pass false to discard the label + * @param array $attr Additional attributes for the HTML tag + * @param array $labelTranslationParameters Additional parameters used to translate the label */ - public function __construct($data, string $value, $label, array $attr = []) + public function __construct($data, string $value, $label, array $attr = [], array $labelTranslationParameters = []) { $this->data = $data; $this->value = $value; $this->label = $label; $this->attr = $attr; + $this->labelTranslationParameters = $labelTranslationParameters; } } diff --git a/src/Symfony/Component/Form/Command/DebugCommand.php b/src/Symfony/Component/Form/Command/DebugCommand.php index 4150feaf8ce85..9eac585dc8548 100644 --- a/src/Symfony/Component/Form/Command/DebugCommand.php +++ b/src/Symfony/Component/Form/Command/DebugCommand.php @@ -32,6 +32,7 @@ class DebugCommand extends Command { protected static $defaultName = 'debug:form'; + protected static $defaultDescription = 'Displays form type information'; private $formRegistry; private $namespaces; @@ -64,7 +65,7 @@ protected function configure() new InputOption('show-deprecated', null, InputOption::VALUE_NONE, 'Display deprecated options in form types'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt or json)', 'txt'), ]) - ->setDescription('Displays form type information') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command displays information about form types. diff --git a/src/Symfony/Component/Form/DataMapperInterface.php b/src/Symfony/Component/Form/DataMapperInterface.php index 1e0583aa08fff..c2cd3601e4b5b 100644 --- a/src/Symfony/Component/Form/DataMapperInterface.php +++ b/src/Symfony/Component/Form/DataMapperInterface.php @@ -22,12 +22,12 @@ interface DataMapperInterface * The method is responsible for calling {@link FormInterface::setData()} * on the children of compound forms, defining their underlying model data. * - * @param mixed $viewData View data of the compound form being initialized - * @param FormInterface[]|iterable $forms A list of {@link FormInterface} instances + * @param mixed $viewData View data of the compound form being initialized + * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances * * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported */ - public function mapDataToForms($viewData, iterable $forms); + public function mapDataToForms($viewData, \Traversable $forms); /** * Maps the model data of a list of children forms into the view data of their parent. @@ -52,11 +52,11 @@ public function mapDataToForms($viewData, iterable $forms); * The model data can be an array or an object, so this second argument is always passed * by reference. * - * @param FormInterface[]|iterable $forms A list of {@link FormInterface} instances - * @param mixed $viewData The compound form's view data that get mapped - * its children model data + * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances + * @param mixed $viewData The compound form's view data that get mapped + * its children model data * * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported */ - public function mapFormsToData(iterable $forms, &$viewData); + public function mapFormsToData(\Traversable $forms, &$viewData); } diff --git a/src/Symfony/Component/Form/Extension/Core/DataMapper/CheckboxListMapper.php b/src/Symfony/Component/Form/Extension/Core/DataMapper/CheckboxListMapper.php index f03d1ad86a006..ecfd83a066a8d 100644 --- a/src/Symfony/Component/Form/Extension/Core/DataMapper/CheckboxListMapper.php +++ b/src/Symfony/Component/Form/Extension/Core/DataMapper/CheckboxListMapper.php @@ -30,6 +30,10 @@ class CheckboxListMapper implements DataMapperInterface */ public function mapDataToForms($choices, iterable $checkboxes) { + if (\is_array($checkboxes)) { + trigger_deprecation('symfony/form', '5.3', 'Passing an array as the second argument of the "%s()" method is deprecated, pass "\Traversable" instead.', __METHOD__); + } + if (null === $choices) { $choices = []; } @@ -49,6 +53,10 @@ public function mapDataToForms($choices, iterable $checkboxes) */ public function mapFormsToData(iterable $checkboxes, &$choices) { + if (\is_array($checkboxes)) { + trigger_deprecation('symfony/form', '5.3', 'Passing an array as the first argument of the "%s()" method is deprecated, pass "\Traversable" instead.', __METHOD__); + } + if (!\is_array($choices)) { throw new UnexpectedTypeException($choices, 'array'); } diff --git a/src/Symfony/Component/Form/Extension/Core/DataMapper/DataMapper.php b/src/Symfony/Component/Form/Extension/Core/DataMapper/DataMapper.php index f7cb81f34a493..5f4c498a33526 100644 --- a/src/Symfony/Component/Form/Extension/Core/DataMapper/DataMapper.php +++ b/src/Symfony/Component/Form/Extension/Core/DataMapper/DataMapper.php @@ -40,6 +40,10 @@ public function __construct(DataAccessorInterface $dataAccessor = null) */ public function mapDataToForms($data, iterable $forms): void { + if (\is_array($forms)) { + trigger_deprecation('symfony/form', '5.3', 'Passing an array as the second argument of the "%s()" method is deprecated, pass "\Traversable" instead.', __METHOD__); + } + $empty = null === $data || [] === $data; if (!$empty && !\is_array($data) && !\is_object($data)) { @@ -62,6 +66,10 @@ public function mapDataToForms($data, iterable $forms): void */ public function mapFormsToData(iterable $forms, &$data): void { + if (\is_array($forms)) { + trigger_deprecation('symfony/form', '5.3', 'Passing an array as the first argument of the "%s()" method is deprecated, pass "\Traversable" instead.', __METHOD__); + } + if (null === $data) { return; } diff --git a/src/Symfony/Component/Form/Extension/Core/DataMapper/RadioListMapper.php b/src/Symfony/Component/Form/Extension/Core/DataMapper/RadioListMapper.php index bb34c912a2ebe..b54adfa5d1ba1 100644 --- a/src/Symfony/Component/Form/Extension/Core/DataMapper/RadioListMapper.php +++ b/src/Symfony/Component/Form/Extension/Core/DataMapper/RadioListMapper.php @@ -30,6 +30,10 @@ class RadioListMapper implements DataMapperInterface */ public function mapDataToForms($choice, iterable $radios) { + if (\is_array($radios)) { + trigger_deprecation('symfony/form', '5.3', 'Passing an array as the second argument of the "%s()" method is deprecated, pass "\Traversable" instead.', __METHOD__); + } + if (!\is_string($choice)) { throw new UnexpectedTypeException($choice, 'string'); } @@ -45,6 +49,10 @@ public function mapDataToForms($choice, iterable $radios) */ public function mapFormsToData(iterable $radios, &$choice) { + if (\is_array($radios)) { + trigger_deprecation('symfony/form', '5.3', 'Passing an array as the first argument of the "%s()" method is deprecated, pass "\Traversable" instead.', __METHOD__); + } + if (null !== $choice && !\is_string($choice)) { throw new UnexpectedTypeException($choice, 'null or string'); } diff --git a/src/Symfony/Component/Form/Extension/Core/DataTransformer/UlidToStringTransformer.php b/src/Symfony/Component/Form/Extension/Core/DataTransformer/UlidToStringTransformer.php new file mode 100644 index 0000000000000..ea3fdec341ea9 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/DataTransformer/UlidToStringTransformer.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Uid\Ulid; + +/** + * Transforms between a ULID string and a Ulid object. + * + * @author Pavel Dyakonov + */ +class UlidToStringTransformer implements DataTransformerInterface +{ + /** + * Transforms a Ulid object into a string. + * + * @param Ulid $value A Ulid object + * + * @return string|null A value as produced by Uid component + * + * @throws TransformationFailedException If the given value is not a Ulid object + */ + public function transform($value) + { + if (null === $value) { + return null; + } + + if (!$value instanceof Ulid) { + throw new TransformationFailedException('Expected a Ulid.'); + } + + return (string) $value; + } + + /** + * Transforms a ULID string into a Ulid object. + * + * @param string $value A ULID string + * + * @return Ulid|null An instance of Ulid + * + * @throws TransformationFailedException If the given value is not a string, + * or could not be transformed + */ + public function reverseTransform($value) + { + if (null === $value || '' === $value) { + return null; + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + try { + $ulid = new Ulid($value); + } catch (\InvalidArgumentException $e) { + throw new TransformationFailedException(sprintf('The value "%s" is not a valid ULID.', $value), $e->getCode(), $e); + } + + return $ulid; + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/DataTransformer/UuidToStringTransformer.php b/src/Symfony/Component/Form/Extension/Core/DataTransformer/UuidToStringTransformer.php new file mode 100644 index 0000000000000..a019847ae4adc --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/DataTransformer/UuidToStringTransformer.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Uid\Uuid; + +/** + * Transforms between a UUID string and a Uuid object. + * + * @author Pavel Dyakonov + */ +class UuidToStringTransformer implements DataTransformerInterface +{ + /** + * Transforms a Uuid object into a string. + * + * @param Uuid $value A Uuid object + * + * @return string|null A value as produced by Uid component + * + * @throws TransformationFailedException If the given value is not a Uuid object + */ + public function transform($value) + { + if (null === $value) { + return null; + } + + if (!$value instanceof Uuid) { + throw new TransformationFailedException('Expected a Uuid.'); + } + + return (string) $value; + } + + /** + * Transforms a UUID string into a Uuid object. + * + * @param string $value A UUID string + * + * @return Uuid|null An instance of Uuid + * + * @throws TransformationFailedException If the given value is not a string, + * or could not be transformed + */ + public function reverseTransform($value) + { + if (null === $value || '' === $value) { + return null; + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + try { + $uuid = new Uuid($value); + } catch (\InvalidArgumentException $e) { + throw new TransformationFailedException(sprintf('The value "%s" is not a valid UUID.', $value), $e->getCode(), $e); + } + + return $uuid; + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php index eaea6651dc325..670c18bfd85fa 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php @@ -18,6 +18,7 @@ use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFilter; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceTranslationParameters; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue; use Symfony\Component\Form\ChoiceList\Factory\Cache\GroupBy; use Symfony\Component\Form\ChoiceList\Factory\Cache\PreferredChoice; @@ -258,6 +259,7 @@ public function buildView(FormView $view, FormInterface $form, array $options) 'separator' => '-------------------', 'placeholder' => null, 'choice_translation_domain' => $choiceTranslationDomain, + 'choice_translation_parameters' => $options['choice_translation_parameters'], ]); // The decision, whether a choice is selected, is potentially done @@ -372,6 +374,7 @@ public function configureOptions(OptionsResolver $resolver) 'choice_name' => null, 'choice_value' => null, 'choice_attr' => null, + 'choice_translation_parameters' => [], 'preferred_choices' => [], 'group_by' => null, 'empty_data' => $emptyData, @@ -402,6 +405,7 @@ public function configureOptions(OptionsResolver $resolver) $resolver->setAllowedTypes('choice_name', ['null', 'callable', 'string', PropertyPath::class, ChoiceFieldName::class]); $resolver->setAllowedTypes('choice_value', ['null', 'callable', 'string', PropertyPath::class, ChoiceValue::class]); $resolver->setAllowedTypes('choice_attr', ['null', 'array', 'callable', 'string', PropertyPath::class, ChoiceAttr::class]); + $resolver->setAllowedTypes('choice_translation_parameters', ['null', 'array', 'callable', ChoiceTranslationParameters::class]); $resolver->setAllowedTypes('preferred_choices', ['array', \Traversable::class, 'callable', 'string', PropertyPath::class, PreferredChoice::class]); $resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', PropertyPath::class, GroupBy::class]); } @@ -442,6 +446,7 @@ private function addSubForm(FormBuilderInterface $builder, string $name, ChoiceV 'label' => $choiceView->label, 'label_html' => $options['label_html'], 'attr' => $choiceView->attr, + 'label_translation_parameters' => $choiceView->labelTranslationParameters, 'translation_domain' => $options['choice_translation_domain'], 'block_name' => 'entry', ]; @@ -486,7 +491,8 @@ private function createChoiceListView(ChoiceListInterface $choiceList, array $op $options['choice_label'], $options['choice_name'], $options['group_by'], - $options['choice_attr'] + $options['choice_attr'], + $options['choice_translation_parameters'] ); } } diff --git a/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php b/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php index e0b1976864326..85293bc284c18 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php @@ -14,7 +14,9 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\ChoiceList\ChoiceList; use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; use Symfony\Component\Intl\Countries; +use Symfony\Component\Intl\Intl; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -27,6 +29,10 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + $choiceTranslationLocale = $options['choice_translation_locale']; $alpha3 = $options['alpha3']; diff --git a/src/Symfony/Component/Form/Extension/Core/Type/CurrencyType.php b/src/Symfony/Component/Form/Extension/Core/Type/CurrencyType.php index 7b6f69f48b221..427b493f7e046 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/CurrencyType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/CurrencyType.php @@ -14,7 +14,9 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\ChoiceList\ChoiceList; use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; use Symfony\Component\Intl\Currencies; +use Symfony\Component\Intl\Intl; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -27,6 +29,10 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + $choiceTranslationLocale = $options['choice_translation_locale']; return ChoiceList::loader($this, new IntlCallbackChoiceLoader(function () use ($choiceTranslationLocale) { diff --git a/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php b/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php index 23e5e50319e79..7bcbda2077a42 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php @@ -16,6 +16,7 @@ use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; use Symfony\Component\Form\Exception\LogicException; use Symfony\Component\Intl\Exception\MissingResourceException; +use Symfony\Component\Intl\Intl; use Symfony\Component\Intl\Languages; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -29,6 +30,9 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } $choiceTranslationLocale = $options['choice_translation_locale']; $useAlpha3Codes = $options['alpha3']; $choiceSelfTranslation = $options['choice_self_translation']; diff --git a/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php b/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php index 461640deeb354..14113e4ac1101 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php @@ -14,6 +14,8 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\ChoiceList\ChoiceList; use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Intl\Intl; use Symfony\Component\Intl\Locales; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -27,6 +29,10 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + $choiceTranslationLocale = $options['choice_translation_locale']; return ChoiceList::loader($this, new IntlCallbackChoiceLoader(function () use ($choiceTranslationLocale) { diff --git a/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php b/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php index 9829cba2cd7c2..31b5df5c3c9c9 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php @@ -18,6 +18,7 @@ use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeZoneToStringTransformer; use Symfony\Component\Form\Extension\Core\DataTransformer\IntlTimeZoneToStringTransformer; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Intl\Intl; use Symfony\Component\Intl\Timezones; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -47,6 +48,10 @@ public function configureOptions(OptionsResolver $resolver) $input = $options['input']; if ($options['intl']) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s" with option "intl=true". Try running "composer require symfony/intl".', static::class)); + } + $choiceTranslationLocale = $options['choice_translation_locale']; return ChoiceList::loader($this, new IntlCallbackChoiceLoader(function () use ($input, $choiceTranslationLocale) { diff --git a/src/Symfony/Component/Form/Extension/Core/Type/UlidType.php b/src/Symfony/Component/Form/Extension/Core/Type/UlidType.php new file mode 100644 index 0000000000000..640d38ffa957c --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/Type/UlidType.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\UlidToStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Pavel Dyakonov + */ +class UlidType extends AbstractType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->addViewTransformer(new UlidToStringTransformer()) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'compound' => false, + 'invalid_message' => function (Options $options, $previousValue) { + return ($options['legacy_error_messages'] ?? true) + ? $previousValue + : 'Please enter a valid ULID.'; + }, + ]); + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/UuidType.php b/src/Symfony/Component/Form/Extension/Core/Type/UuidType.php new file mode 100644 index 0000000000000..0c27802b37a93 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/Type/UuidType.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\UuidToStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Pavel Dyakonov + */ +class UuidType extends AbstractType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->addViewTransformer(new UuidToStringTransformer()) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'compound' => false, + 'invalid_message' => function (Options $options, $previousValue) { + return ($options['legacy_error_messages'] ?? true) + ? $previousValue + : 'Please enter a valid UUID.'; + }, + ]); + } +} diff --git a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php index ae968393edbbe..a1e8edba5dbfa 100644 --- a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php +++ b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php @@ -78,6 +78,11 @@ public function getAttr($object) return $object->attr; } + public function getLabelTranslationParameters($object) + { + return $object->labelTranslationParameters; + } + public function getGroup($object) { return $this->obj1 === $object || $this->obj2 === $object ? 'Group 1' : 'Group 2'; @@ -97,10 +102,10 @@ public function getGroupAsObject($object) protected function setUp(): void { - $this->obj1 = (object) ['label' => 'A', 'index' => 'w', 'value' => 'a', 'preferred' => false, 'group' => 'Group 1', 'attr' => []]; - $this->obj2 = (object) ['label' => 'B', 'index' => 'x', 'value' => 'b', 'preferred' => true, 'group' => 'Group 1', 'attr' => ['attr1' => 'value1']]; - $this->obj3 = (object) ['label' => 'C', 'index' => 'y', 'value' => 1, 'preferred' => true, 'group' => 'Group 2', 'attr' => ['attr2' => 'value2']]; - $this->obj4 = (object) ['label' => 'D', 'index' => 'z', 'value' => 2, 'preferred' => false, 'group' => 'Group 2', 'attr' => []]; + $this->obj1 = (object) ['label' => 'A', 'index' => 'w', 'value' => 'a', 'preferred' => false, 'group' => 'Group 1', 'attr' => [], 'labelTranslationParameters' => []]; + $this->obj2 = (object) ['label' => 'B', 'index' => 'x', 'value' => 'b', 'preferred' => true, 'group' => 'Group 1', 'attr' => ['attr1' => 'value1'], 'labelTranslationParameters' => []]; + $this->obj3 = (object) ['label' => 'C', 'index' => 'y', 'value' => 1, 'preferred' => true, 'group' => 'Group 2', 'attr' => ['attr2' => 'value2'], 'labelTranslationParameters' => []]; + $this->obj4 = (object) ['label' => 'D', 'index' => 'z', 'value' => 2, 'preferred' => false, 'group' => 'Group 2', 'attr' => [], 'labelTranslationParameters' => ['%placeholder1%' => 'value1']]; $this->list = new ArrayChoiceList(['A' => $this->obj1, 'B' => $this->obj2, 'C' => $this->obj3, 'D' => $this->obj4]); $this->factory = new DefaultChoiceListFactory(); } @@ -754,6 +759,110 @@ function ($object, $key, $value) { $this->assertFlatViewWithAttr($view); } + public function testCreateViewFlatLabelTranslationParametersAsArray() + { + $view = $this->factory->createView( + $this->list, + [$this->obj2, $this->obj3], + null, // label + null, // index + null, // group + null, // attr + [ + 'D' => ['%placeholder1%' => 'value1'], + ] + ); + + $this->assertFlatViewWithlabelTranslationParameters($view); + } + + public function testCreateViewFlatlabelTranslationParametersEmpty() + { + $view = $this->factory->createView( + $this->list, + [$this->obj2, $this->obj3], + null, // label + null, // index + null, // group + null, // attr + [] + ); + + $this->assertFlatView($view); + } + + public function testCreateViewFlatlabelTranslationParametersAsCallable() + { + $view = $this->factory->createView( + $this->list, + [$this->obj2, $this->obj3], + null, // label + null, // index + null, // group + null, // attr + [$this, 'getlabelTranslationParameters'] + ); + + $this->assertFlatViewWithlabelTranslationParameters($view); + } + + public function testCreateViewFlatlabelTranslationParametersAsClosure() + { + $view = $this->factory->createView( + $this->list, + [$this->obj2, $this->obj3], + null, // label + null, // index + null, // group + null, // attr + function ($object) { + return $object->labelTranslationParameters; + } + ); + + $this->assertFlatViewWithlabelTranslationParameters($view); + } + + public function testCreateViewFlatlabelTranslationParametersClosureReceivesKey() + { + $view = $this->factory->createView( + $this->list, + [$this->obj2, $this->obj3], + null, // label + null, // index + null, // group + null, // attr + function ($object, $key) { + switch ($key) { + case 'D': return ['%placeholder1%' => 'value1']; + default: return []; + } + } + ); + + $this->assertFlatViewWithlabelTranslationParameters($view); + } + + public function testCreateViewFlatlabelTranslationParametersClosureReceivesValue() + { + $view = $this->factory->createView( + $this->list, + [$this->obj2, $this->obj3], + null, // label + null, // index + null, // group + null, // attr + function ($object, $key, $value) { + switch ($value) { + case '3': return ['%placeholder1%' => 'value1']; + default: return []; + } + } + ); + + $this->assertFlatViewWithlabelTranslationParameters($view); + } + private function assertScalarListWithChoiceValues(ChoiceListInterface $list) { $this->assertSame(['a', 'b', 'c', 'd'], $list->getValues()); @@ -895,6 +1004,21 @@ private function assertFlatViewWithAttr($view) ), $view); } + private function assertFlatViewWithlabelTranslationParameters($view) + { + $this->assertEquals(new ChoiceListView( + [ + 0 => new ChoiceView($this->obj1, '0', 'A'), + 1 => new ChoiceView($this->obj2, '1', 'B'), + 2 => new ChoiceView($this->obj3, '2', 'C'), + 3 => new ChoiceView($this->obj4, '3', 'D', [], ['%placeholder1%' => 'value1']), + ], [ + 1 => new ChoiceView($this->obj2, '1', 'B'), + 2 => new ChoiceView($this->obj3, '2', 'C'), + ] + ), $view); + } + private function assertGroupedView($view) { $this->assertEquals(new ChoiceListView( diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php index b20b827fdbb5a..bc6efb6d3bdc5 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php @@ -50,7 +50,7 @@ public function testMapDataToFormsPassesObjectRefIfByReference() $config->setPropertyPath($propertyPath); $form = new Form($config); - $this->mapper->mapDataToForms($car, [$form]); + $this->mapper->mapDataToForms($car, new \ArrayIterator([$form])); self::assertSame($engine, $form->getData()); } @@ -68,7 +68,7 @@ public function testMapDataToFormsPassesObjectCloneIfNotByReference() $config->setPropertyPath($propertyPath); $form = new Form($config); - $this->mapper->mapDataToForms($car, [$form]); + $this->mapper->mapDataToForms($car, new \ArrayIterator([$form])); self::assertNotSame($engine, $form->getData()); self::assertEquals($engine, $form->getData()); @@ -84,7 +84,7 @@ public function testMapDataToFormsIgnoresEmptyPropertyPath() self::assertNull($form->getPropertyPath()); - $this->mapper->mapDataToForms($car, [$form]); + $this->mapper->mapDataToForms($car, new \ArrayIterator([$form])); self::assertNull($form->getData()); } @@ -101,7 +101,7 @@ public function testMapDataToFormsIgnoresUnmapped() $config->setPropertyPath($propertyPath); $form = new Form($config); - $this->mapper->mapDataToForms($car, [$form]); + $this->mapper->mapDataToForms($car, new \ArrayIterator([$form])); self::assertNull($form->getData()); } @@ -117,7 +117,7 @@ public function testMapDataToFormsIgnoresUninitializedProperties() $car = new TypehintedPropertiesCar(); $car->engine = 'BMW'; - $this->mapper->mapDataToForms($car, [$engineForm, $colorForm]); + $this->mapper->mapDataToForms($car, new \ArrayIterator([$engineForm, $colorForm])); self::assertSame($car->engine, $engineForm->getData()); self::assertNull($colorForm->getData()); @@ -135,7 +135,7 @@ public function testMapDataToFormsSetsDefaultDataIfPassedDataIsNull() $form = new Form($config); - $this->mapper->mapDataToForms(null, [$form]); + $this->mapper->mapDataToForms(null, new \ArrayIterator([$form])); self::assertSame($default, $form->getData()); } @@ -152,7 +152,7 @@ public function testMapDataToFormsSetsDefaultDataIfPassedDataIsEmptyArray() $form = new Form($config); - $this->mapper->mapDataToForms([], [$form]); + $this->mapper->mapDataToForms([], new \ArrayIterator([$form])); self::assertSame($default, $form->getData()); } @@ -171,7 +171,7 @@ public function testMapFormsToDataWritesBackIfNotByReference() $config->setData($engine); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertEquals($engine, $car->engine); self::assertNotSame($engine, $car->engine); @@ -190,7 +190,7 @@ public function testMapFormsToDataWritesBackIfByReferenceButNoReference() $config->setData($engine); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame($engine, $car->engine); } @@ -209,7 +209,7 @@ public function testMapFormsToDataWritesBackIfByReferenceAndReference() $car->engine = 'Rolls-Royce'; - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame('Rolls-Royce', $car->engine); } @@ -229,7 +229,7 @@ public function testMapFormsToDataIgnoresUnmapped() $config->setMapped(false); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame($initialEngine, $car->engine); } @@ -248,7 +248,7 @@ public function testMapFormsToDataIgnoresUnsubmittedForms() $config->setData($engine); $form = new Form($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame($initialEngine, $car->engine); } @@ -266,7 +266,7 @@ public function testMapFormsToDataIgnoresEmptyData() $config->setData(null); $form = new Form($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame($initialEngine, $car->engine); } @@ -285,7 +285,7 @@ public function testMapFormsToDataIgnoresUnsynchronized() $config->setData($engine); $form = new NotSynchronizedForm($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame($initialEngine, $car->engine); } @@ -305,7 +305,7 @@ public function testMapFormsToDataIgnoresDisabled() $config->setDisabled(true); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame($initialEngine, $car->engine); } @@ -320,7 +320,7 @@ public function testMapFormsToUninitializedProperties() $config->setData('BMW'); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $car); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $car); self::assertSame('BMW', $car->engine); } @@ -342,7 +342,7 @@ public function testMapFormsToDataDoesNotChangeEqualDateTimeInstance($date) $config->setData($publishedAt); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $article); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $article); self::assertSame($publishedAtValue, $article['publishedAt']); } @@ -367,7 +367,7 @@ public function testMapDataToFormsUsingGetCallbackOption() ]); $form = new Form($config); - $this->mapper->mapDataToForms($person, [$form]); + $this->mapper->mapDataToForms($person, new \ArrayIterator([$form])); self::assertSame($initialName, $form->getData()); } @@ -384,7 +384,7 @@ public function testMapFormsToDataUsingSetCallbackOption() $config->setData('Jane Doe'); $form = new SubmittedForm($config); - $this->mapper->mapFormsToData([$form], $person); + $this->mapper->mapFormsToData(new \ArrayIterator([$form]), $person); self::assertSame('Jane Doe', $person->myName()); } diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/UlidToStringTransformerTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/UlidToStringTransformerTest.php new file mode 100644 index 0000000000000..87a1592e25fc1 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/UlidToStringTransformerTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Core\DataTransformer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Extension\Core\DataTransformer\UlidToStringTransformer; +use Symfony\Component\Uid\Ulid; + +class UlidToStringTransformerTest extends TestCase +{ + public function provideValidUlid() + { + return [ + ['01D85PP1982GF6KTVFHQ7W78FB', new Ulid('01d85pp1982gf6ktvfhq7w78fb')], + ]; + } + + /** + * @dataProvider provideValidUlid + */ + public function testTransform($output, $input) + { + $transformer = new UlidToStringTransformer(); + + $input = new Ulid($input); + + $this->assertEquals($output, $transformer->transform($input)); + } + + public function testTransformEmpty() + { + $transformer = new UlidToStringTransformer(); + + $this->assertNull($transformer->transform(null)); + } + + public function testTransformExpectsUlid() + { + $transformer = new UlidToStringTransformer(); + + $this->expectException(TransformationFailedException::class); + + $transformer->transform('1234'); + } + + /** + * @dataProvider provideValidUlid + */ + public function testReverseTransform($input, $output) + { + $reverseTransformer = new UlidToStringTransformer(); + + $output = new Ulid($output); + + $this->assertEquals($output, $reverseTransformer->reverseTransform($input)); + } + + public function testReverseTransformEmpty() + { + $reverseTransformer = new UlidToStringTransformer(); + + $this->assertNull($reverseTransformer->reverseTransform('')); + } + + public function testReverseTransformExpectsString() + { + $reverseTransformer = new UlidToStringTransformer(); + + $this->expectException(TransformationFailedException::class); + + $reverseTransformer->reverseTransform(1234); + } + + public function testReverseTransformExpectsValidUlidString() + { + $reverseTransformer = new UlidToStringTransformer(); + + $this->expectException(TransformationFailedException::class); + + $reverseTransformer->reverseTransform('1234'); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/UuidToStringTransformerTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/UuidToStringTransformerTest.php new file mode 100644 index 0000000000000..f7a93beca8fb9 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/UuidToStringTransformerTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Core\DataTransformer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Extension\Core\DataTransformer\UuidToStringTransformer; +use Symfony\Component\Uid\Uuid; + +class UuidToStringTransformerTest extends TestCase +{ + public function provideValidUuid() + { + return [ + ['123e4567-e89b-12d3-a456-426655440000', new Uuid('123e4567-e89b-12d3-a456-426655440000')], + ]; + } + + /** + * @dataProvider provideValidUuid + */ + public function testTransform($output, $input) + { + $transformer = new UuidToStringTransformer(); + + $input = new Uuid($input); + + $this->assertEquals($output, $transformer->transform($input)); + } + + public function testTransformEmpty() + { + $transformer = new UuidToStringTransformer(); + + $this->assertNull($transformer->transform(null)); + } + + public function testTransformExpectsUuid() + { + $transformer = new UuidToStringTransformer(); + + $this->expectException(TransformationFailedException::class); + + $transformer->transform('1234'); + } + + /** + * @dataProvider provideValidUuid + */ + public function testReverseTransform($input, $output) + { + $reverseTransformer = new UuidToStringTransformer(); + + $output = new Uuid($output); + + $this->assertEquals($output, $reverseTransformer->reverseTransform($input)); + } + + public function testReverseTransformEmpty() + { + $reverseTransformer = new UuidToStringTransformer(); + + $this->assertNull($reverseTransformer->reverseTransform('')); + } + + public function testReverseTransformExpectsString() + { + $reverseTransformer = new UuidToStringTransformer(); + + $this->expectException(TransformationFailedException::class); + + $reverseTransformer->reverseTransform(1234); + } + + public function testReverseTransformExpectsValidUuidString() + { + $reverseTransformer = new UuidToStringTransformer(); + + $this->expectException(TransformationFailedException::class); + + $reverseTransformer->reverseTransform('1234'); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php index 3eeee26a7e9d7..aeae35117dbde 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php @@ -1812,14 +1812,26 @@ public function testPassChoiceDataToView() 'choices' => [$obj1, $obj2, $obj3, $obj4], 'choice_label' => 'label', 'choice_value' => 'value', + 'choice_attr' => [ + ['attr1' => 'value1'], + ['attr2' => 'value2'], + ['attr3' => 'value3'], + ['attr4' => 'value4'], + ], + 'choice_translation_parameters' => [ + ['%placeholder1%' => 'value1'], + ['%placeholder2%' => 'value2'], + ['%placeholder3%' => 'value3'], + ['%placeholder4%' => 'value4'], + ], ]) ->createView(); $this->assertEquals([ - new ChoiceView($obj1, 'a', 'A'), - new ChoiceView($obj2, 'b', 'B'), - new ChoiceView($obj3, 'c', 'C'), - new ChoiceView($obj4, 'd', 'D'), + new ChoiceView($obj1, 'a', 'A', ['attr1' => 'value1'], ['%placeholder1%' => 'value1']), + new ChoiceView($obj2, 'b', 'B', ['attr2' => 'value2'], ['%placeholder2%' => 'value2']), + new ChoiceView($obj3, 'c', 'C', ['attr3' => 'value3'], ['%placeholder3%' => 'value3']), + new ChoiceView($obj4, 'd', 'D', ['attr4' => 'value4'], ['%placeholder4%' => 'value4']), ], $view->vars['choices']); } diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/UlidTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/UlidTypeTest.php new file mode 100644 index 0000000000000..4f61992cb64a6 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/UlidTypeTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Core\Type; + +use Symfony\Component\Form\Extension\Core\Type\UlidType; +use Symfony\Component\Uid\Ulid; + +final class UlidTypeTest extends BaseTypeTest +{ + public const TESTED_TYPE = UlidType::class; + + public function testPassUlidToView() + { + $ulid = '01D85PP1982GF6KTVFHQ7W78FB'; + + $form = $this->factory->create(static::TESTED_TYPE); + $form->setData(new Ulid($ulid)); + + $this->assertSame($ulid, $form->createView()->vars['value']); + } + + public function testSubmitNullUsesDefaultEmptyData($emptyData = '', $expectedData = null) + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'empty_data' => $emptyData, + ]); + $form->submit(null); + + $this->assertSame($expectedData, $form->getViewData()); + $this->assertSame($expectedData, $form->getNormData()); + $this->assertSame($expectedData, $form->getData()); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/UuidTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/UuidTypeTest.php new file mode 100644 index 0000000000000..9b9bfb2d2a14e --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/UuidTypeTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Core\Type; + +use Symfony\Component\Form\Extension\Core\Type\UuidType; +use Symfony\Component\Uid\Uuid; + +final class UuidTypeTest extends BaseTypeTest +{ + public const TESTED_TYPE = UuidType::class; + + public function testPassUuidToView() + { + $uuid = '123e4567-e89b-12d3-a456-426655440000'; + + $form = $this->factory->create(static::TESTED_TYPE); + $form->setData(new Uuid($uuid)); + + $this->assertSame($uuid, $form->createView()->vars['value']); + } + + public function testSubmitNullUsesDefaultEmptyData($emptyData = '', $expectedData = null) + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'empty_data' => $emptyData, + ]); + $form->submit(null); + + $this->assertSame($expectedData, $form->getViewData()); + $this->assertSame($expectedData, $form->getNormData()); + $this->assertSame($expectedData, $form->getData()); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json index cc7d5544a95eb..3cd6ca5ca40ca 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json +++ b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json @@ -9,6 +9,7 @@ "choice_loader", "choice_name", "choice_translation_domain", + "choice_translation_parameters", "choice_value", "choices", "expanded", diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt index 74603552e0d1d..0952ccc3e54ec 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt +++ b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt @@ -2,44 +2,44 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice") ============================================================================== - --------------------------- -------------------- ------------------------------ ----------------------- - Options Overridden options Parent options Extension options - --------------------------- -------------------- ------------------------------ ----------------------- - choice_attr FormType FormType FormTypeCsrfExtension - choice_filter -------------------- ------------------------------ ----------------------- - choice_label compound action csrf_field_name - choice_loader data_class allow_file_upload csrf_message - choice_name empty_data attr csrf_protection - choice_translation_domain error_bubbling attr_translation_parameters csrf_token_id - choice_value invalid_message auto_initialize csrf_token_manager - choices trim block_name - expanded block_prefix - group_by by_reference - multiple data - placeholder disabled - preferred_choices getter - help - help_attr - help_html - help_translation_parameters - inherit_data - invalid_message_parameters - is_empty_callback - label - label_attr - label_format - label_html - label_translation_parameters - mapped - method - post_max_size_message - property_path - required - row_attr - setter - translation_domain - upload_max_size_message - --------------------------- -------------------- ------------------------------ ----------------------- + ------------------------------- -------------------- ------------------------------ ----------------------- + Options Overridden options Parent options Extension options + ------------------------------- -------------------- ------------------------------ ----------------------- + choice_attr FormType FormType FormTypeCsrfExtension + choice_filter -------------------- ------------------------------ ----------------------- + choice_label compound action csrf_field_name + choice_loader data_class allow_file_upload csrf_message + choice_name empty_data attr csrf_protection + choice_translation_domain error_bubbling attr_translation_parameters csrf_token_id + choice_translation_parameters invalid_message auto_initialize csrf_token_manager + choice_value trim block_name + choices block_prefix + expanded by_reference + group_by data + multiple disabled + placeholder getter + preferred_choices help + help_attr + help_html + help_translation_parameters + inherit_data + invalid_message_parameters + is_empty_callback + label + label_attr + label_format + label_html + label_translation_parameters + mapped + method + post_max_size_message + property_path + required + row_attr + setter + translation_domain + upload_max_size_message + ------------------------------- -------------------- ------------------------------ ----------------------- Parent types ------------ diff --git a/src/Symfony/Component/Form/composer.json b/src/Symfony/Component/Form/composer.json index b4ac5bb8bc84c..b99e08620a8d0 100644 --- a/src/Symfony/Component/Form/composer.json +++ b/src/Symfony/Component/Form/composer.json @@ -19,9 +19,9 @@ "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1", "symfony/event-dispatcher": "^4.4|^5.0", - "symfony/intl": "^4.4|^5.0", "symfony/options-resolver": "^5.1", "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-icu": "^1.21", "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php80": "^1.15", "symfony/property-access": "^5.0.8", @@ -36,9 +36,11 @@ "symfony/console": "^4.4|^5.0", "symfony/http-foundation": "^4.4|^5.0", "symfony/http-kernel": "^4.4|^5.0", + "symfony/intl": "^4.4|^5.0", "symfony/security-csrf": "^4.4|^5.0", "symfony/translation": "^4.4|^5.0", - "symfony/var-dumper": "^4.4|^5.0" + "symfony/var-dumper": "^4.4|^5.0", + "symfony/uid": "^5.1" }, "conflict": { "phpunit/phpunit": "<5.4.3", @@ -48,7 +50,6 @@ "symfony/error-handler": "<4.4.5", "symfony/framework-bundle": "<4.4", "symfony/http-kernel": "<4.4", - "symfony/intl": "<4.4", "symfony/translation": "<4.4", "symfony/translation-contracts": "<1.1.7", "symfony/twig-bridge": "<4.4" diff --git a/src/Symfony/Component/HttpClient/AsyncDecoratorTrait.php b/src/Symfony/Component/HttpClient/AsyncDecoratorTrait.php index c5d40a251d3d8..2e6267300d138 100644 --- a/src/Symfony/Component/HttpClient/AsyncDecoratorTrait.php +++ b/src/Symfony/Component/HttpClient/AsyncDecoratorTrait.php @@ -51,4 +51,15 @@ public function stream($responses, float $timeout = null): ResponseStreamInterfa return new ResponseStream(AsyncResponse::stream($responses, $timeout, static::class)); } + + /** + * {@inheritdoc} + */ + public function withOptions(array $options): self + { + $clone = clone $this; + $clone->client = $this->client->withOptions($options); + + return $clone; + } } diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index f25989e168396..3b97488c93fa1 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Implement `HttpClientInterface::withOptions()` from `symfony/contracts` v2.4 + 5.2.0 ----- diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index 48ecf773d4cfd..3a75e675e25e8 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -341,30 +341,8 @@ public function stream($responses, float $timeout = null): ResponseStreamInterfa public function reset() { - if ($this->logger) { - foreach ($this->multi->pushedResponses as $url => $response) { - $this->logger->debug(sprintf('Unused pushed response: "%s"', $url)); - } - } - - $this->multi->pushedResponses = []; - $this->multi->dnsCache->evictions = $this->multi->dnsCache->evictions ?: $this->multi->dnsCache->removals; - $this->multi->dnsCache->removals = $this->multi->dnsCache->hostnames = []; - - if (\is_resource($this->multi->handle) || $this->multi->handle instanceof \CurlMultiHandle) { - if (\defined('CURLMOPT_PUSHFUNCTION')) { - curl_multi_setopt($this->multi->handle, \CURLMOPT_PUSHFUNCTION, null); - } - - $active = 0; - while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)); - } - - foreach ($this->multi->openHandles as [$ch]) { - if (\is_resource($ch) || $ch instanceof \CurlHandle) { - curl_setopt($ch, \CURLOPT_VERBOSE, false); - } - } + $this->multi->logger = $this->logger; + $this->multi->reset(); } public function __sleep() @@ -379,7 +357,7 @@ public function __wakeup() public function __destruct() { - $this->reset(); + $this->multi->logger = $this->logger; } private function handlePush($parent, $pushed, array $requestHeaders, int $maxPendingPushes): int diff --git a/src/Symfony/Component/HttpClient/EventSourceHttpClient.php b/src/Symfony/Component/HttpClient/EventSourceHttpClient.php index d6f27a7fae99f..81d9f4bfc8bf8 100644 --- a/src/Symfony/Component/HttpClient/EventSourceHttpClient.php +++ b/src/Symfony/Component/HttpClient/EventSourceHttpClient.php @@ -26,8 +26,9 @@ */ final class EventSourceHttpClient implements HttpClientInterface { - use AsyncDecoratorTrait; - use HttpClientTrait; + use AsyncDecoratorTrait, HttpClientTrait { + AsyncDecoratorTrait::withOptions insteadof HttpClientTrait; + } private $reconnectionTime; diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index 4db3a3ca8ae3e..9d68f9a61f61d 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -17,7 +17,7 @@ /** * Provides the common logic from writing HttpClientInterface implementations. * - * All methods are static to prevent implementers from creating memory leaks via circular references. + * All private methods are static to prevent implementers from creating memory leaks via circular references. * * @author Nicolas Grekas */ @@ -25,6 +25,17 @@ trait HttpClientTrait { private static $CHUNK_SIZE = 16372; + /** + * {@inheritdoc} + */ + public function withOptions(array $options): self + { + $clone = clone $this; + $clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions); + + return $clone; + } + /** * Validates and normalizes method, URL and options, and merges them with defaults. * @@ -436,6 +447,10 @@ private static function resolveUrl(array $url, ?array $base, array $queryDefault $url['path'] = '/'; } + if ('?' === ($url['query'] ?? '')) { + $url['query'] = null; + } + return $url; } diff --git a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php index f381968d143c1..40ed0f1fc8c97 100644 --- a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php +++ b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php @@ -11,6 +11,8 @@ namespace Symfony\Component\HttpClient\Internal; +use Psr\Log\LoggerInterface; + /** * Internal representation of the cURL client's state. * @@ -29,10 +31,55 @@ final class CurlClientState extends ClientState /** @var float[] */ public $pauseExpiries = []; public $execCounter = \PHP_INT_MIN; + /** @var LoggerInterface|null */ + public $logger; public function __construct() { $this->handle = curl_multi_init(); $this->dnsCache = new DnsCache(); } + + public function reset() + { + if ($this->logger) { + foreach ($this->pushedResponses as $url => $response) { + $this->logger->debug(sprintf('Unused pushed response: "%s"', $url)); + } + } + + $this->pushedResponses = []; + $this->dnsCache->evictions = $this->dnsCache->evictions ?: $this->dnsCache->removals; + $this->dnsCache->removals = $this->dnsCache->hostnames = []; + + if (\is_resource($this->handle) || $this->handle instanceof \CurlMultiHandle) { + if (\defined('CURLMOPT_PUSHFUNCTION')) { + curl_multi_setopt($this->handle, \CURLMOPT_PUSHFUNCTION, null); + } + + $active = 0; + while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->handle, $active)); + } + + foreach ($this->openHandles as [$ch]) { + if (\is_resource($ch) || $ch instanceof \CurlHandle) { + curl_setopt($ch, \CURLOPT_VERBOSE, false); + } + } + } + + public function __sleep() + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + public function __wakeup() + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + public function __destruct() + { + $this->reset(); + } } diff --git a/src/Symfony/Component/HttpClient/MockHttpClient.php b/src/Symfony/Component/HttpClient/MockHttpClient.php index 08571f07c209b..a794faff6e75c 100644 --- a/src/Symfony/Component/HttpClient/MockHttpClient.php +++ b/src/Symfony/Component/HttpClient/MockHttpClient.php @@ -28,8 +28,8 @@ class MockHttpClient implements HttpClientInterface use HttpClientTrait; private $responseFactory; - private $baseUri; private $requestsCount = 0; + private $defaultOptions = []; /** * @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory @@ -47,7 +47,7 @@ public function __construct($responseFactory = null, string $baseUri = null) } $this->responseFactory = $responseFactory; - $this->baseUri = $baseUri; + $this->defaultOptions['base_uri'] = $baseUri; } /** @@ -55,7 +55,7 @@ public function __construct($responseFactory = null, string $baseUri = null) */ public function request(string $method, string $url, array $options = []): ResponseInterface { - [$url, $options] = $this->prepareRequest($method, $url, $options, ['base_uri' => $this->baseUri], true); + [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); $url = implode('', $url); if (null === $this->responseFactory) { @@ -96,4 +96,15 @@ public function getRequestsCount(): int { return $this->requestsCount; } + + /** + * {@inheritdoc} + */ + public function withOptions(array $options): self + { + $clone = clone $this; + $clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions, true); + + return $clone; + } } diff --git a/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php b/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php index d4c69cabcea95..b9db846992cf3 100644 --- a/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php +++ b/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php @@ -110,4 +110,15 @@ public function setLogger(LoggerInterface $logger): void $this->client->setLogger($logger); } } + + /** + * {@inheritdoc} + */ + public function withOptions(array $options): self + { + $clone = clone $this; + $clone->client = $this->client->withOptions($options); + + return $clone; + } } diff --git a/src/Symfony/Component/HttpClient/ScopingHttpClient.php b/src/Symfony/Component/HttpClient/ScopingHttpClient.php index 66dcccf0e93f2..2a6e70e15d7c3 100644 --- a/src/Symfony/Component/HttpClient/ScopingHttpClient.php +++ b/src/Symfony/Component/HttpClient/ScopingHttpClient.php @@ -110,4 +110,15 @@ public function setLogger(LoggerInterface $logger): void $this->client->setLogger($logger); } } + + /** + * {@inheritdoc} + */ + public function withOptions(array $options): self + { + $clone = clone $this; + $clone->client = $this->client->withOptions($options); + + return $clone; + } } diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php index dbcd8efab46e9..cc44e9d5625cd 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php @@ -44,6 +44,9 @@ public function providePrepareRequestUrl(): iterable yield ['http://example.com/?a=2&b=b', '.?a=2']; yield ['http://example.com/?a=3&b=b', '.', ['a' => 3]]; yield ['http://example.com/?a=3&b=b', '.?a=0', ['a' => 3]]; + yield ['http://example.com/', 'http://example.com/', ['a' => null]]; + yield ['http://example.com/?b=', 'http://example.com/', ['b' => '']]; + yield ['http://example.com/?b=', 'http://example.com/', ['a' => null, 'b' => '']]; } /** diff --git a/src/Symfony/Component/HttpClient/TraceableHttpClient.php b/src/Symfony/Component/HttpClient/TraceableHttpClient.php index 34dc01ad2553f..bc842115900de 100644 --- a/src/Symfony/Component/HttpClient/TraceableHttpClient.php +++ b/src/Symfony/Component/HttpClient/TraceableHttpClient.php @@ -105,4 +105,15 @@ public function setLogger(LoggerInterface $logger): void $this->client->setLogger($logger); } } + + /** + * {@inheritdoc} + */ + public function withOptions(array $options): self + { + $clone = clone $this; + $clone->client = $this->client->withOptions($options); + + return $clone; + } } diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 880798e83b032..be66a8f5c28e5 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -18,12 +18,12 @@ "php-http/async-client-implementation": "*", "php-http/client-implementation": "*", "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "2.2" + "symfony/http-client-implementation": "2.4" }, "require": { "php": ">=7.2.5", "psr/log": "^1.0", - "symfony/http-client-contracts": "^2.2", + "symfony/http-client-contracts": "^2.4", "symfony/polyfill-php73": "^1.11", "symfony/polyfill-php80": "^1.15", "symfony/service-contracts": "^1.0|^2" diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 472fef05a57ae..d6a57aae1400f 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +5.3 +--- + + * Add the `SessionFactory`, `NativeSessionStorageFactory`, `PhpBridgeSessionStorageFactory` and `MockFileSessionStorageFactory` classes + * Calling `Request::getSession()` when there is no available session throws a `SessionNotFoundException` + * Add the `RequestStack::getSession` method + * Deprecate the `NamespacedAttributeBag` class + * added `ResponseFormatSame` PHPUnit constraint + 5.2.0 ----- diff --git a/src/Symfony/Component/HttpFoundation/Exception/SessionNotFoundException.php b/src/Symfony/Component/HttpFoundation/Exception/SessionNotFoundException.php new file mode 100644 index 0000000000000..eb7acbbafc38e --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Exception/SessionNotFoundException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * Raised when a session does not exists. This happens in the following cases: + * - the session is not enabled + * - attempt to read a session outside a request context (ie. cli script). + * + * @author Jérémy Derussé + */ +class SessionNotFoundException extends \LogicException implements RequestExceptionInterface +{ + public function __construct($message = 'There is currently no session available.', $code = 0, \Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php b/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php index 885ca02af5be4..ae0a7d93e80ee 100644 --- a/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php +++ b/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php @@ -22,7 +22,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ abstract class AbstractRequestRateLimiter implements RequestRateLimiterInterface { diff --git a/src/Symfony/Component/HttpFoundation/RateLimiter/RequestRateLimiterInterface.php b/src/Symfony/Component/HttpFoundation/RateLimiter/RequestRateLimiterInterface.php index 4b95f1ef654c0..513435accaa19 100644 --- a/src/Symfony/Component/HttpFoundation/RateLimiter/RequestRateLimiterInterface.php +++ b/src/Symfony/Component/HttpFoundation/RateLimiter/RequestRateLimiterInterface.php @@ -22,7 +22,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface RequestRateLimiterInterface { diff --git a/src/Symfony/Component/HttpFoundation/Request.php b/src/Symfony/Component/HttpFoundation/Request.php index 919f44fd47cac..99c295d36a077 100644 --- a/src/Symfony/Component/HttpFoundation/Request.php +++ b/src/Symfony/Component/HttpFoundation/Request.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\JsonException; +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -735,7 +736,7 @@ public function getSession() } if (null === $session) { - throw new \BadMethodCallException('Session has not been set.'); + throw new SessionNotFoundException('Session has not been set.'); } return $session; diff --git a/src/Symfony/Component/HttpFoundation/RequestStack.php b/src/Symfony/Component/HttpFoundation/RequestStack.php index 244a77d631a8f..fe07d594b108b 100644 --- a/src/Symfony/Component/HttpFoundation/RequestStack.php +++ b/src/Symfony/Component/HttpFoundation/RequestStack.php @@ -11,6 +11,9 @@ namespace Symfony\Component\HttpFoundation; +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; +use Symfony\Component\HttpFoundation\Session\SessionInterface; + /** * Request stack that controls the lifecycle of requests. * @@ -100,4 +103,18 @@ public function getParentRequest() return $this->requests[$pos]; } + + /** + * Gets the current session. + * + * @throws SessionNotFoundException + */ + public function getSession(): SessionInterface + { + if ((null !== $request = end($this->requests) ?: null) && $request->hasSession()) { + return $request->getSession(); + } + + throw new SessionNotFoundException(); + } } diff --git a/src/Symfony/Component/HttpFoundation/Session/Attribute/NamespacedAttributeBag.php b/src/Symfony/Component/HttpFoundation/Session/Attribute/NamespacedAttributeBag.php index 7e752ddaa7ec1..1e29e92eac92f 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Attribute/NamespacedAttributeBag.php +++ b/src/Symfony/Component/HttpFoundation/Session/Attribute/NamespacedAttributeBag.php @@ -11,11 +11,15 @@ namespace Symfony\Component\HttpFoundation\Session\Attribute; +trigger_deprecation('symfony/http-foundation', '5.3', sprintf('The "%s" class is deprecated.', NamespacedAttributeBag::class)); + /** * This class provides structured storage of session attributes using * a name spacing character in the key. * * @author Drak + * + * @deprecated since Symfony 5.3 */ class NamespacedAttributeBag extends AttributeBag { diff --git a/src/Symfony/Component/HttpFoundation/Session/SessionFactory.php b/src/Symfony/Component/HttpFoundation/Session/SessionFactory.php new file mode 100644 index 0000000000000..a9982ed0c3f54 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/SessionFactory.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session; + +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageFactoryInterface; + +// Help opcache.preload discover always-needed symbols +class_exists(Session::class); + +/** + * @author Jérémy Derussé + */ +class SessionFactory +{ + private $requestStack; + private $storageFactory; + private $usageReporter; + + public function __construct(RequestStack $requestStack, SessionStorageFactoryInterface $storageFactory, callable $usageReporter = null) + { + $this->requestStack = $requestStack; + $this->storageFactory = $storageFactory; + $this->usageReporter = $usageReporter; + } + + public function createSession(): SessionInterface + { + return new Session($this->storageFactory->createStorage($this->requestStack->getMasterRequest()), null, null, $this->usageReporter); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/MockFileSessionStorageFactory.php b/src/Symfony/Component/HttpFoundation/Session/Storage/MockFileSessionStorageFactory.php new file mode 100644 index 0000000000000..d0da1e16922fc --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/MockFileSessionStorageFactory.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage; + +use Symfony\Component\HttpFoundation\Request; + +// Help opcache.preload discover always-needed symbols +class_exists(MockFileSessionStorage::class); + +/** + * @author Jérémy Derussé + */ +class MockFileSessionStorageFactory implements SessionStorageFactoryInterface +{ + private $savePath; + private $name; + private $metaBag; + + /** + * @see MockFileSessionStorage constructor. + */ + public function __construct(string $savePath = null, string $name = 'MOCKSESSID', MetadataBag $metaBag = null) + { + $this->savePath = $savePath; + $this->name = $name; + $this->metaBag = $metaBag; + } + + public function createStorage(?Request $request): SessionStorageInterface + { + return new MockFileSessionStorage($this->savePath, $this->name, $this->metaBag); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/NativeSessionStorageFactory.php b/src/Symfony/Component/HttpFoundation/Session/Storage/NativeSessionStorageFactory.php new file mode 100644 index 0000000000000..a7d7411ff3fc9 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/NativeSessionStorageFactory.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage; + +use Symfony\Component\HttpFoundation\Request; + +// Help opcache.preload discover always-needed symbols +class_exists(NativeSessionStorage::class); + +/** + * @author Jérémy Derussé + */ +class NativeSessionStorageFactory implements SessionStorageFactoryInterface +{ + private $options; + private $handler; + private $metaBag; + private $secure; + + /** + * @see NativeSessionStorage constructor. + */ + public function __construct(array $options = [], $handler = null, MetadataBag $metaBag = null, bool $secure = false) + { + $this->options = $options; + $this->handler = $handler; + $this->metaBag = $metaBag; + $this->secure = $secure; + } + + public function createStorage(?Request $request): SessionStorageInterface + { + $storage = new NativeSessionStorage($this->options, $this->handler, $this->metaBag); + if ($this->secure && $request && $request->isSecure()) { + $storage->setOptions(['cookie_secure' => true]); + } + + return $storage; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/PhpBridgeSessionStorageFactory.php b/src/Symfony/Component/HttpFoundation/Session/Storage/PhpBridgeSessionStorageFactory.php new file mode 100644 index 0000000000000..173ef71dea424 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/PhpBridgeSessionStorageFactory.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage; + +use Symfony\Component\HttpFoundation\Request; + +// Help opcache.preload discover always-needed symbols +class_exists(PhpBridgeSessionStorage::class); + +/** + * @author Jérémy Derussé + */ +class PhpBridgeSessionStorageFactory implements SessionStorageFactoryInterface +{ + private $handler; + private $metaBag; + private $secure; + + /** + * @see PhpBridgeSessionStorage constructor. + */ + public function __construct($handler = null, MetadataBag $metaBag = null, bool $secure = false) + { + $this->handler = $handler; + $this->metaBag = $metaBag; + $this->secure = $secure; + } + + public function createStorage(?Request $request): SessionStorageInterface + { + $storage = new PhpBridgeSessionStorage($this->handler, $this->metaBag); + if ($this->secure && $request && $request->isSecure()) { + $storage->setOptions(['cookie_secure' => true]); + } + + return $storage; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/ServiceSessionFactory.php b/src/Symfony/Component/HttpFoundation/Session/Storage/ServiceSessionFactory.php new file mode 100644 index 0000000000000..d17c60aebaac1 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/ServiceSessionFactory.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage; + +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Jérémy Derussé + * + * @internal to be removed in Symfony 6 + */ +final class ServiceSessionFactory implements SessionStorageFactoryInterface +{ + private $storage; + + public function __construct(SessionStorageInterface $storage) + { + $this->storage = $storage; + } + + public function createStorage(?Request $request): SessionStorageInterface + { + if ($this->storage instanceof NativeSessionStorage && $request && $request->isSecure()) { + $this->storage->setOptions(['cookie_secure' => true]); + } + + return $this->storage; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/SessionStorageFactoryInterface.php b/src/Symfony/Component/HttpFoundation/Session/Storage/SessionStorageFactoryInterface.php new file mode 100644 index 0000000000000..f0387b5e12866 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/SessionStorageFactoryInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage; + +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Jérémy Derussé + */ +interface SessionStorageFactoryInterface +{ + /** + * Creates a new instance of SessionStorageInterface + */ + public function createStorage(?Request $request): SessionStorageInterface; +} diff --git a/src/Symfony/Component/HttpFoundation/Test/Constraint/ResponseFormatSame.php b/src/Symfony/Component/HttpFoundation/Test/Constraint/ResponseFormatSame.php new file mode 100644 index 0000000000000..f73aedfa11688 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Test/Constraint/ResponseFormatSame.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Test\Constraint; + +use PHPUnit\Framework\Constraint\Constraint; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Asserts that the response is in the given format. + * + * @author Kévin Dunglas + */ +final class ResponseFormatSame extends Constraint +{ + private $request; + private $format; + + public function __construct(Request $request, ?string $format) + { + $this->request = $request; + $this->format = $format; + } + + /** + * {@inheritdoc} + */ + public function toString(): string + { + return 'format is '.($this->format ?? 'null'); + } + + /** + * @param Response $response + * + * {@inheritdoc} + */ + protected function matches($response): bool + { + return $this->format === $this->request->getFormat($response->headers->get('Content-Type')); + } + + /** + * @param Response $response + * + * {@inheritdoc} + */ + protected function failureDescription($response): string + { + return 'the Response '.$this->toString(); + } + + /** + * @param Response $response + * + * {@inheritdoc} + */ + protected function additionalFailureDescription($response): string + { + return (string) $response; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Attribute/NamespacedAttributeBagTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Attribute/NamespacedAttributeBagTest.php index 3a3251d05b799..fe7838408d941 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Attribute/NamespacedAttributeBagTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Attribute/NamespacedAttributeBagTest.php @@ -18,6 +18,8 @@ * Tests NamespacedAttributeBag. * * @author Drak + * + * @group legacy */ class NamespacedAttributeBagTest extends TestCase { diff --git a/src/Symfony/Component/HttpFoundation/Tests/Test/Constraint/ResponseFormatSameTest.php b/src/Symfony/Component/HttpFoundation/Tests/Test/Constraint/ResponseFormatSameTest.php new file mode 100644 index 0000000000000..aed9285f24224 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Test/Constraint/ResponseFormatSameTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\Test\Constraint; + +use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestFailure; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseFormatSame; + +/** + * @author Kévin Dunglas + */ +class ResponseFormatSameTest extends TestCase +{ + public function testConstraint() + { + $request = new Request(); + $request->setFormat('custom', ['application/vnd.myformat']); + + $constraint = new ResponseFormatSame($request, 'custom'); + $this->assertTrue($constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/vnd.myformat']), '', true)); + $this->assertFalse($constraint->evaluate(new Response(), '', true)); + + try { + $constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/ld+json'])); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString("Failed asserting that the Response format is custom.\nHTTP/1.0 200 OK", TestFailure::exceptionToString($e)); + + return; + } + + $this->fail(); + } + + public function testNullFormat() + { + $constraint = new ResponseFormatSame(new Request(), null); + $this->assertTrue($constraint->evaluate(new Response(), '', true)); + + try { + $constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/ld+json'])); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString("Failed asserting that the Response format is null.\nHTTP/1.0 200 OK", TestFailure::exceptionToString($e)); + + return; + } + + $this->fail(); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php b/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php index 8f0c6fb8b060c..78769f1ac0bd2 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php +++ b/src/Symfony/Component/HttpKernel/Attribute/ArgumentInterface.php @@ -11,8 +11,12 @@ namespace Symfony\Component\HttpKernel\Attribute; +trigger_deprecation('symfony/http-kernel', '5.3', 'The "%s" interface is deprecated.', ArgumentInterface::class); + /** * Marker interface for controller argument attributes. + * + * @deprecated since Symfony 5.3 */ interface ArgumentInterface { diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index db2821334323d..e2509ba1bbeca 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +5.3 +--- + + * Deprecate `ArgumentInterface` + * Add `ArgumentMetadata::getAttributes()` + * Deprecate `ArgumentMetadata::getAttribute()`, use `getAttributes()` instead + * marked the class `Symfony\Component\HttpKernel\EventListener\DebugHandlersListener` as internal + 5.2.0 ----- diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php index 3454ff6e49417..1a9ebc0c3a5d1 100644 --- a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadata.php @@ -20,15 +20,20 @@ */ class ArgumentMetadata { + public const IS_INSTANCEOF = 2; + private $name; private $type; private $isVariadic; private $hasDefaultValue; private $defaultValue; private $isNullable; - private $attribute; + private $attributes; - public function __construct(string $name, ?string $type, bool $isVariadic, bool $hasDefaultValue, $defaultValue, bool $isNullable = false, ?ArgumentInterface $attribute = null) + /** + * @param object[] $attributes + */ + public function __construct(string $name, ?string $type, bool $isVariadic, bool $hasDefaultValue, $defaultValue, bool $isNullable = false, $attributes = []) { $this->name = $name; $this->type = $type; @@ -36,7 +41,13 @@ public function __construct(string $name, ?string $type, bool $isVariadic, bool $this->hasDefaultValue = $hasDefaultValue; $this->defaultValue = $defaultValue; $this->isNullable = $isNullable || null === $type || ($hasDefaultValue && null === $defaultValue); - $this->attribute = $attribute; + + if (null === $attributes || $attributes instanceof ArgumentInterface) { + trigger_deprecation('symfony/http-kernel', '5.3', 'The "%s" constructor expects an array of PHP attributes as last argument, %s given.', __CLASS__, get_debug_type($attributes)); + $attributes = $attributes ? [$attributes] : []; + } + + $this->attributes = $attributes; } /** @@ -114,6 +125,39 @@ public function getDefaultValue() */ public function getAttribute(): ?ArgumentInterface { - return $this->attribute; + trigger_deprecation('symfony/http-kernel', '5.3', 'Method "%s()" is deprecated, use "getAttributes()" instead.', __METHOD__); + + if (!$this->attributes) { + return null; + } + + return $this->attributes[0] instanceof ArgumentInterface ? $this->attributes[0] : null; + } + + /** + * @return object[] + */ + public function getAttributes(string $name = null, int $flags = 0): array + { + if (!$name) { + return $this->attributes; + } + + $attributes = []; + if ($flags & self::IS_INSTANCEOF) { + foreach ($this->attributes as $attribute) { + if ($attribute instanceof $name) { + $attributes[] = $attribute; + } + } + } else { + foreach ($this->attributes as $attribute) { + if (\get_class($attribute) === $name) { + $attributes[] = $attribute; + } + } + } + + return $attributes; } } diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php index f53bf065b96b1..a2feb05c10340 100644 --- a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php @@ -11,9 +11,6 @@ namespace Symfony\Component\HttpKernel\ControllerMetadata; -use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; -use Symfony\Component\HttpKernel\Exception\InvalidMetadataException; - /** * Builds {@see ArgumentMetadata} objects based on the given Controller. * @@ -37,28 +34,15 @@ public function createArgumentMetadata($controller): array } foreach ($reflection->getParameters() as $param) { - $attribute = null; if (\PHP_VERSION_ID >= 80000) { - $reflectionAttributes = $param->getAttributes(ArgumentInterface::class, \ReflectionAttribute::IS_INSTANCEOF); - - if (\count($reflectionAttributes) > 1) { - $representative = $controller; - - if (\is_array($representative)) { - $representative = sprintf('%s::%s()', \get_class($representative[0]), $representative[1]); - } elseif (\is_object($representative)) { - $representative = \get_class($representative); + foreach ($param->getAttributes() as $reflectionAttribute) { + if (class_exists($reflectionAttribute->getName())) { + $attributes[] = $reflectionAttribute->newInstance(); } - - throw new InvalidMetadataException(sprintf('Controller "%s" has more than one attribute for "$%s" argument.', $representative, $param->getName())); - } - - if (isset($reflectionAttributes[0])) { - $attribute = $reflectionAttributes[0]->newInstance(); } } - $arguments[] = new ArgumentMetadata($param->getName(), $this->getType($param, $reflection), $param->isVariadic(), $param->isDefaultValueAvailable(), $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, $param->allowsNull(), $attribute); + $arguments[] = new ArgumentMetadata($param->getName(), $this->getType($param, $reflection), $param->isVariadic(), $param->isDefaultValueAvailable(), $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, $param->allowsNull(), $attributes ?? []); } return $arguments; diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php index a2a9a0c71b1c9..e24e0e6d641a2 100644 --- a/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php +++ b/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php @@ -23,6 +23,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\TypedReference; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\SessionInterface; /** * Creates the service-locators required by ServiceValueResolver. @@ -165,7 +166,7 @@ public function process(ContainerBuilder $container) $invalidBehavior = ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE; } - if (Request::class === $type) { + if (Request::class === $type || SessionInterface::class === $type) { continue; } diff --git a/src/Symfony/Component/HttpKernel/EventListener/DebugHandlersListener.php b/src/Symfony/Component/HttpKernel/EventListener/DebugHandlersListener.php index 620cd590c2774..df12569bfd5c6 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/DebugHandlersListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/DebugHandlersListener.php @@ -27,6 +27,8 @@ * @author Nicolas Grekas * * @final + * + * @internal since Symfony 5.3 */ class DebugHandlersListener implements EventSubscriberInterface { diff --git a/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php b/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php index c10897a9e2659..9c36f7cb58977 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php @@ -127,7 +127,7 @@ public function onKernelRequest(RequestEvent $event) unset($parameters['_route'], $parameters['_controller']); $request->attributes->set('_route_params', $parameters); } catch (ResourceNotFoundException $e) { - $message = sprintf('No route found for "%s %s"', $request->getMethod(), $request->getPathInfo()); + $message = sprintf('No route found for "%s %s"', $request->getMethod(), $request->getUriForPath($request->getPathInfo())); if ($referer = $request->headers->get('referer')) { $message .= sprintf(' (from "%s")', $referer); @@ -135,7 +135,7 @@ public function onKernelRequest(RequestEvent $event) throw new NotFoundHttpException($message, $e); } catch (MethodNotAllowedException $e) { - $message = sprintf('No route found for "%s %s": Method Not Allowed (Allow: %s)', $request->getMethod(), $request->getPathInfo(), implode(', ', $e->getAllowedMethods())); + $message = sprintf('No route found for "%s %s": Method Not Allowed (Allow: %s)', $request->getMethod(), $request->getUriForPath($request->getPathInfo()), implode(', ', $e->getAllowedMethods())); throw new MethodNotAllowedHttpException($e->getAllowedMethods(), $message, $e); } diff --git a/src/Symfony/Component/HttpKernel/Exception/AccessDeniedHttpException.php b/src/Symfony/Component/HttpKernel/Exception/AccessDeniedHttpException.php index f0c81111db543..58680a327838c 100644 --- a/src/Symfony/Component/HttpKernel/Exception/AccessDeniedHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/AccessDeniedHttpException.php @@ -24,6 +24,12 @@ class AccessDeniedHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(403, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/BadRequestHttpException.php b/src/Symfony/Component/HttpKernel/Exception/BadRequestHttpException.php index 8eccce1e163e7..f530f7db4927c 100644 --- a/src/Symfony/Component/HttpKernel/Exception/BadRequestHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/BadRequestHttpException.php @@ -23,6 +23,12 @@ class BadRequestHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(400, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/ConflictHttpException.php b/src/Symfony/Component/HttpKernel/Exception/ConflictHttpException.php index 72b8aa1274fe2..79c36041c3f55 100644 --- a/src/Symfony/Component/HttpKernel/Exception/ConflictHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/ConflictHttpException.php @@ -23,6 +23,12 @@ class ConflictHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(409, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/GoneHttpException.php b/src/Symfony/Component/HttpKernel/Exception/GoneHttpException.php index 6bba8159a2dce..9ea65057b38f5 100644 --- a/src/Symfony/Component/HttpKernel/Exception/GoneHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/GoneHttpException.php @@ -23,6 +23,12 @@ class GoneHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(410, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/HttpException.php b/src/Symfony/Component/HttpKernel/Exception/HttpException.php index f3c0c3362f949..249fe366d5b76 100644 --- a/src/Symfony/Component/HttpKernel/Exception/HttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/HttpException.php @@ -23,6 +23,17 @@ class HttpException extends \RuntimeException implements HttpExceptionInterface public function __construct(int $statusCode, ?string $message = '', \Throwable $previous = null, array $headers = [], ?int $code = 0) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + if (null === $code) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + $this->statusCode = $statusCode; $this->headers = $headers; diff --git a/src/Symfony/Component/HttpKernel/Exception/LengthRequiredHttpException.php b/src/Symfony/Component/HttpKernel/Exception/LengthRequiredHttpException.php index 92f9c74daf5e1..fcac13785220a 100644 --- a/src/Symfony/Component/HttpKernel/Exception/LengthRequiredHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/LengthRequiredHttpException.php @@ -23,6 +23,12 @@ class LengthRequiredHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(411, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php b/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php index 665ae355b47a2..37576bcacb354 100644 --- a/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php @@ -24,6 +24,17 @@ class MethodNotAllowedHttpException extends HttpException */ public function __construct(array $allow, ?string $message = '', \Throwable $previous = null, ?int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + if (null === $code) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + $headers['Allow'] = strtoupper(implode(', ', $allow)); parent::__construct(405, $message, $previous, $headers, $code); diff --git a/src/Symfony/Component/HttpKernel/Exception/NotAcceptableHttpException.php b/src/Symfony/Component/HttpKernel/Exception/NotAcceptableHttpException.php index a985e86b9b02d..5a422406ba715 100644 --- a/src/Symfony/Component/HttpKernel/Exception/NotAcceptableHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/NotAcceptableHttpException.php @@ -23,6 +23,12 @@ class NotAcceptableHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(406, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php b/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php index 3be305ee9ea5b..a475113c5fe81 100644 --- a/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php @@ -23,6 +23,12 @@ class NotFoundHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(404, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/PreconditionFailedHttpException.php b/src/Symfony/Component/HttpKernel/Exception/PreconditionFailedHttpException.php index bdedea143d2e7..e23740a28dcf2 100644 --- a/src/Symfony/Component/HttpKernel/Exception/PreconditionFailedHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/PreconditionFailedHttpException.php @@ -23,6 +23,12 @@ class PreconditionFailedHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(412, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/PreconditionRequiredHttpException.php b/src/Symfony/Component/HttpKernel/Exception/PreconditionRequiredHttpException.php index bc26804830012..5c31fae822b0c 100644 --- a/src/Symfony/Component/HttpKernel/Exception/PreconditionRequiredHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/PreconditionRequiredHttpException.php @@ -25,6 +25,12 @@ class PreconditionRequiredHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(428, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/ServiceUnavailableHttpException.php b/src/Symfony/Component/HttpKernel/Exception/ServiceUnavailableHttpException.php index 1fb793dbf0b31..d5681bbeb3bc8 100644 --- a/src/Symfony/Component/HttpKernel/Exception/ServiceUnavailableHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/ServiceUnavailableHttpException.php @@ -24,6 +24,17 @@ class ServiceUnavailableHttpException extends HttpException */ public function __construct($retryAfter = null, ?string $message = '', \Throwable $previous = null, ?int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + if (null === $code) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + if ($retryAfter) { $headers['Retry-After'] = $retryAfter; } diff --git a/src/Symfony/Component/HttpKernel/Exception/TooManyRequestsHttpException.php b/src/Symfony/Component/HttpKernel/Exception/TooManyRequestsHttpException.php index e1e47d048b248..fd74402b5d033 100644 --- a/src/Symfony/Component/HttpKernel/Exception/TooManyRequestsHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/TooManyRequestsHttpException.php @@ -26,6 +26,17 @@ class TooManyRequestsHttpException extends HttpException */ public function __construct($retryAfter = null, ?string $message = '', \Throwable $previous = null, ?int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + if (null === $code) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + if ($retryAfter) { $headers['Retry-After'] = $retryAfter; } diff --git a/src/Symfony/Component/HttpKernel/Exception/UnauthorizedHttpException.php b/src/Symfony/Component/HttpKernel/Exception/UnauthorizedHttpException.php index ddb48f116fb52..aeb9713a3ded6 100644 --- a/src/Symfony/Component/HttpKernel/Exception/UnauthorizedHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/UnauthorizedHttpException.php @@ -24,6 +24,17 @@ class UnauthorizedHttpException extends HttpException */ public function __construct(string $challenge, ?string $message = '', \Throwable $previous = null, ?int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + if (null === $code) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $code to "%s()" is deprecated, pass 0 instead.', __METHOD__); + + $code = 0; + } + $headers['WWW-Authenticate'] = $challenge; parent::__construct(401, $message, $previous, $headers, $code); diff --git a/src/Symfony/Component/HttpKernel/Exception/UnprocessableEntityHttpException.php b/src/Symfony/Component/HttpKernel/Exception/UnprocessableEntityHttpException.php index 237340a574d79..7b828b1d92ccb 100644 --- a/src/Symfony/Component/HttpKernel/Exception/UnprocessableEntityHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/UnprocessableEntityHttpException.php @@ -23,6 +23,12 @@ class UnprocessableEntityHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(422, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Exception/UnsupportedMediaTypeHttpException.php b/src/Symfony/Component/HttpKernel/Exception/UnsupportedMediaTypeHttpException.php index 74ddbfccbc9f3..7908423f42580 100644 --- a/src/Symfony/Component/HttpKernel/Exception/UnsupportedMediaTypeHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/UnsupportedMediaTypeHttpException.php @@ -23,6 +23,12 @@ class UnsupportedMediaTypeHttpException extends HttpException */ public function __construct(?string $message = '', \Throwable $previous = null, int $code = 0, array $headers = []) { + if (null === $message) { + trigger_deprecation('symfony/http-kernel', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct(415, $message, $previous, $headers, $code); } } diff --git a/src/Symfony/Component/HttpKernel/Kernel.php b/src/Symfony/Component/HttpKernel/Kernel.php index 1ff31b5593851..ad6a7e1ec0301 100644 --- a/src/Symfony/Component/HttpKernel/Kernel.php +++ b/src/Symfony/Component/HttpKernel/Kernel.php @@ -74,15 +74,15 @@ abstract class Kernel implements KernelInterface, RebootableInterface, Terminabl private static $freshCache = []; - public const VERSION = '5.2.4-DEV'; - public const VERSION_ID = 50204; + public const VERSION = '5.3.0-DEV'; + public const VERSION_ID = 50300; public const MAJOR_VERSION = 5; - public const MINOR_VERSION = 2; - public const RELEASE_VERSION = 4; + public const MINOR_VERSION = 3; + public const RELEASE_VERSION = 0; public const EXTRA_VERSION = 'DEV'; - public const END_OF_MAINTENANCE = '07/2021'; - public const END_OF_LIFE = '07/2021'; + public const END_OF_MAINTENANCE = '05/2021'; + public const END_OF_LIFE = '01/2022'; public function __construct(string $environment, bool $debug) { @@ -751,15 +751,16 @@ protected function dumpContainer(ConfigCache $cache, ContainerBuilder $container */ protected function getContainerLoader(ContainerInterface $container) { + $env = $this->getEnvironment(); $locator = new FileLocator($this); $resolver = new LoaderResolver([ - new XmlFileLoader($container, $locator), - new YamlFileLoader($container, $locator), - new IniFileLoader($container, $locator), - new PhpFileLoader($container, $locator), - new GlobFileLoader($container, $locator), - new DirectoryLoader($container, $locator), - new ClosureLoader($container), + new XmlFileLoader($container, $locator, $env), + new YamlFileLoader($container, $locator, $env), + new IniFileLoader($container, $locator, $env), + new PhpFileLoader($container, $locator, $env), + new GlobFileLoader($container, $locator, $env), + new DirectoryLoader($container, $locator, $env), + new ClosureLoader($container, $env), ]); return new DelegatingLoader($resolver); diff --git a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php index 3c57c3161c8c5..d952e424c557e 100644 --- a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php @@ -15,7 +15,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; -use Symfony\Component\HttpKernel\Exception\InvalidMetadataException; use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Foo; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\AttributeController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\BasicTypesController; @@ -128,18 +127,17 @@ public function testAttributeSignature() $arguments = $this->factory->createArgumentMetadata([new AttributeController(), 'action']); $this->assertEquals([ - new ArgumentMetadata('baz', 'string', false, false, null, false, new Foo('bar')), + new ArgumentMetadata('baz', 'string', false, false, null, false, [new Foo('bar')]), ], $arguments); } /** * @requires PHP 8 */ - public function testAttributeSignatureError() + public function testMultipleAttributes() { - $this->expectException(InvalidMetadataException::class); - - $this->factory->createArgumentMetadata([new AttributeController(), 'invalidAction']); + $this->factory->createArgumentMetadata([new AttributeController(), 'multiAttributeArg']); + $this->assertCount(1, $this->factory->createArgumentMetadata([new AttributeController(), 'multiAttributeArg'])[0]->getAttributes()); } private function signature1(self $foo, array $bar, callable $baz) diff --git a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataTest.php b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataTest.php index fef6cd00025f0..45b15e174445f 100644 --- a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataTest.php @@ -12,10 +12,15 @@ namespace Symfony\Component\HttpKernel\Tests\ControllerMetadata; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Foo; class ArgumentMetadataTest extends TestCase { + use ExpectDeprecationTrait; + public function testWithBcLayerWithDefault() { $argument = new ArgumentMetadata('foo', 'string', false, true, 'default value'); @@ -41,4 +46,27 @@ public function testDefaultValueUnavailable() $this->assertFalse($argument->hasDefaultValue()); $argument->getDefaultValue(); } + + /** + * @group legacy + */ + public function testLegacyAttribute() + { + $attribute = $this->createMock(ArgumentInterface::class); + + $this->expectDeprecation('Since symfony/http-kernel 5.3: The "Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata" constructor expects an array of PHP attributes as last argument, %s given.'); + $this->expectDeprecation('Since symfony/http-kernel 5.3: Method "Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata::getAttribute()" is deprecated, use "getAttributes()" instead.'); + + $argument = new ArgumentMetadata('foo', 'string', false, true, 'default value', true, $attribute); + $this->assertSame($attribute, $argument->getAttribute()); + } + + /** + * @requires PHP 8 + */ + public function testGetAttributes() + { + $argument = new ArgumentMetadata('foo', 'string', false, true, 'default value', true, [new Foo('bar')]); + $this->assertEquals([new Foo('bar')], $argument->getAttributes()); + } } diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php index cc46fd8db5952..453bf5b0e39cf 100644 --- a/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php @@ -24,9 +24,13 @@ use Symfony\Component\HttpKernel\EventListener\RouterListener; use Symfony\Component\HttpKernel\EventListener\ValidateRequestListener; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\HttpKernel; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\NoConfigurationException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Matcher\RequestMatcherInterface; use Symfony\Component\Routing\Matcher\UrlMatcherInterface; use Symfony\Component\Routing\RequestContext; @@ -217,4 +221,57 @@ public function testRequestWithBadHost() $listener = new RouterListener($requestMatcher, $this->requestStack, new RequestContext()); $listener->onKernelRequest($event); } + + public function testResourceNotFoundException() + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('No route found for "GET https://www.symfony.com/path" (from "https://www.google.com")'); + + $context = new RequestContext(); + + $urlMatcher = $this->createMock(UrlMatcherInterface::class); + + $urlMatcher->expects($this->any()) + ->method('getContext') + ->willReturn($context); + + $urlMatcher->expects($this->any()) + ->method('match') + ->willThrowException(new ResourceNotFoundException()); + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('https://www.symfony.com/path'); + $request->headers->set('referer', 'https://www.google.com'); + + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST); + + $listener = new RouterListener($urlMatcher, $this->requestStack); + $listener->onKernelRequest($event); + } + + public function testMethodNotAllowedException() + { + $this->expectException(MethodNotAllowedHttpException::class); + $this->expectExceptionMessage('No route found for "GET https://www.symfony.com/path": Method Not Allowed (Allow: POST)'); + + $context = new RequestContext(); + + $urlMatcher = $this->createMock(UrlMatcherInterface::class); + + $urlMatcher->expects($this->any()) + ->method('getContext') + ->willReturn($context); + + $urlMatcher->expects($this->any()) + ->method('match') + ->willThrowException(new MethodNotAllowedException(['POST'])); + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('https://www.symfony.com/path'); + + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST); + + $listener = new RouterListener($urlMatcher, $this->requestStack); + $listener->onKernelRequest($event); + } } diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php index 96a03adaad0bd..e01a5a6e8ddcd 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Attribute/Foo.php @@ -14,7 +14,7 @@ use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; #[\Attribute(\Attribute::TARGET_PARAMETER)] -class Foo implements ArgumentInterface +class Foo { private $foo; diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php index 910f418ae1eb1..d6e0cde58d883 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AttributeController.php @@ -18,6 +18,6 @@ class AttributeController public function action(#[Foo('bar')] string $baz) { } - public function invalidAction(#[Foo('bar'), Foo('bar')] string $baz) { + public function multiAttributeArg(#[Foo('bar'), Undefined('bar')] string $baz) { } } diff --git a/src/Symfony/Component/Intl/CHANGELOG.md b/src/Symfony/Component/Intl/CHANGELOG.md index b7fb561e7c080..012a894c50179 100644 --- a/src/Symfony/Component/Intl/CHANGELOG.md +++ b/src/Symfony/Component/Intl/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Add `Currencies::getCashFractionDigits()` and `Currencies::getCashRoundingIncrement()` + 5.0.0 ----- diff --git a/src/Symfony/Component/Intl/Collator/Collator.php b/src/Symfony/Component/Intl/Collator/Collator.php index 679d315b4d042..2a8845eed91b0 100644 --- a/src/Symfony/Component/Intl/Collator/Collator.php +++ b/src/Symfony/Component/Intl/Collator/Collator.php @@ -32,6 +32,8 @@ * @author Bernhard Schussek * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class Collator { diff --git a/src/Symfony/Component/Intl/Currencies.php b/src/Symfony/Component/Intl/Currencies.php index c155c8f09f425..60dbfcd6f1d36 100644 --- a/src/Symfony/Component/Intl/Currencies.php +++ b/src/Symfony/Component/Intl/Currencies.php @@ -25,6 +25,8 @@ final class Currencies extends ResourceBundle private const INDEX_NAME = 1; private const INDEX_FRACTION_DIGITS = 0; private const INDEX_ROUNDING_INCREMENT = 1; + private const INDEX_CASH_FRACTION_DIGITS = 2; + private const INDEX_CASH_ROUNDING_INCREMENT = 3; /** * @return string[] @@ -94,10 +96,7 @@ public static function getFractionDigits(string $currency): int } } - /** - * @return float|int - */ - public static function getRoundingIncrement(string $currency) + public static function getRoundingIncrement(string $currency): int { try { return self::readEntry(['Meta', $currency, self::INDEX_ROUNDING_INCREMENT], 'meta'); @@ -106,6 +105,24 @@ public static function getRoundingIncrement(string $currency) } } + public static function getCashFractionDigits(string $currency): int + { + try { + return self::readEntry(['Meta', $currency, self::INDEX_CASH_FRACTION_DIGITS], 'meta'); + } catch (MissingResourceException $e) { + return self::readEntry(['Meta', 'DEFAULT', self::INDEX_CASH_FRACTION_DIGITS], 'meta'); + } + } + + public static function getCashRoundingIncrement(string $currency): int + { + try { + return self::readEntry(['Meta', $currency, self::INDEX_CASH_ROUNDING_INCREMENT], 'meta'); + } catch (MissingResourceException $e) { + return self::readEntry(['Meta', 'DEFAULT', self::INDEX_CASH_ROUNDING_INCREMENT], 'meta'); + } + } + /** * @throws MissingResourceException if the currency code has no numeric code */ diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/AmPmTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/AmPmTransformer.php index 36a1294c70b92..7d370bfb87d95 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/AmPmTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/AmPmTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class AmPmTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfWeekTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfWeekTransformer.php index f18abb9024b51..626d304b54f81 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfWeekTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfWeekTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class DayOfWeekTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php index 93c09d8b22da3..0d6f0b60cdf9d 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class DayOfYearTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayTransformer.php index 91676d3e58f19..47295252991ec 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class DayTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php index 60e8fe978fe1c..4c051bfecfc7a 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php @@ -20,6 +20,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class FullTransformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1200Transformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1200Transformer.php index 59d10fe20dae4..34e4b3a5c5594 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1200Transformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1200Transformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class Hour1200Transformer extends HourTransformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1201Transformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1201Transformer.php index f090ba4c27759..8e5eba1daf4fe 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1201Transformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour1201Transformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class Hour1201Transformer extends HourTransformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2400Transformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2400Transformer.php index 9ca6dfaacf94a..4296978713f13 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2400Transformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2400Transformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class Hour2400Transformer extends HourTransformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2401Transformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2401Transformer.php index e4db51cdf0e1b..0db1a888b5ee7 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2401Transformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Hour2401Transformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class Hour2401Transformer extends HourTransformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/HourTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/HourTransformer.php index 349cd794de3ae..54dcbfe25d24d 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/HourTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/HourTransformer.php @@ -17,6 +17,8 @@ * @author Eriksen Costa * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class HourTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/MinuteTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/MinuteTransformer.php index 32f60d0928c55..30b76c9779383 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/MinuteTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/MinuteTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class MinuteTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/MonthTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/MonthTransformer.php index fcc4c4fee9fe5..5db91114f8c79 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/MonthTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/MonthTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class MonthTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/QuarterTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/QuarterTransformer.php index 6efea71f2ff44..71b95c8b9e7e0 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/QuarterTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/QuarterTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class QuarterTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/SecondTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/SecondTransformer.php index bce89af1c95f3..b6428e114f21e 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/SecondTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/SecondTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class SecondTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/TimezoneTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/TimezoneTransformer.php index 4a4e38e3e1a1a..ad243634d3790 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/TimezoneTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/TimezoneTransformer.php @@ -19,6 +19,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class TimezoneTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Transformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Transformer.php index 2351323946090..4ab993338224c 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/Transformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/Transformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/YearTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/YearTransformer.php index 8718094c06df0..8ed7b4165741b 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/YearTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/YearTransformer.php @@ -17,6 +17,8 @@ * @author Igor Wiedler * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class YearTransformer extends Transformer { diff --git a/src/Symfony/Component/Intl/DateFormatter/IntlDateFormatter.php b/src/Symfony/Component/Intl/DateFormatter/IntlDateFormatter.php index d6a88894c5bde..022624c284343 100644 --- a/src/Symfony/Component/Intl/DateFormatter/IntlDateFormatter.php +++ b/src/Symfony/Component/Intl/DateFormatter/IntlDateFormatter.php @@ -45,6 +45,8 @@ * @author Bernhard Schussek * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class IntlDateFormatter { diff --git a/src/Symfony/Component/Intl/Exception/MethodArgumentNotImplementedException.php b/src/Symfony/Component/Intl/Exception/MethodArgumentNotImplementedException.php index bd6e4791debb1..d0a1d61c4c773 100644 --- a/src/Symfony/Component/Intl/Exception/MethodArgumentNotImplementedException.php +++ b/src/Symfony/Component/Intl/Exception/MethodArgumentNotImplementedException.php @@ -13,6 +13,8 @@ /** * @author Eriksen Costa + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class MethodArgumentNotImplementedException extends NotImplementedException { diff --git a/src/Symfony/Component/Intl/Exception/MethodArgumentValueNotImplementedException.php b/src/Symfony/Component/Intl/Exception/MethodArgumentValueNotImplementedException.php index ee9ebe5e2fb55..611e6ed02fb63 100644 --- a/src/Symfony/Component/Intl/Exception/MethodArgumentValueNotImplementedException.php +++ b/src/Symfony/Component/Intl/Exception/MethodArgumentValueNotImplementedException.php @@ -13,6 +13,8 @@ /** * @author Eriksen Costa + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class MethodArgumentValueNotImplementedException extends NotImplementedException { diff --git a/src/Symfony/Component/Intl/Exception/MethodNotImplementedException.php b/src/Symfony/Component/Intl/Exception/MethodNotImplementedException.php index 6a45d71a483b2..6eb7dc120d9e6 100644 --- a/src/Symfony/Component/Intl/Exception/MethodNotImplementedException.php +++ b/src/Symfony/Component/Intl/Exception/MethodNotImplementedException.php @@ -13,6 +13,8 @@ /** * @author Eriksen Costa + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class MethodNotImplementedException extends NotImplementedException { diff --git a/src/Symfony/Component/Intl/Exception/NotImplementedException.php b/src/Symfony/Component/Intl/Exception/NotImplementedException.php index 1bae867f6f18f..735d3ec99263f 100644 --- a/src/Symfony/Component/Intl/Exception/NotImplementedException.php +++ b/src/Symfony/Component/Intl/Exception/NotImplementedException.php @@ -15,6 +15,8 @@ * Base exception class for not implemented behaviors of the intl extension in the Locale component. * * @author Eriksen Costa + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ class NotImplementedException extends RuntimeException { diff --git a/src/Symfony/Component/Intl/Globals/IntlGlobals.php b/src/Symfony/Component/Intl/Globals/IntlGlobals.php index 80022e6c04794..4811cb9e21708 100644 --- a/src/Symfony/Component/Intl/Globals/IntlGlobals.php +++ b/src/Symfony/Component/Intl/Globals/IntlGlobals.php @@ -11,12 +11,16 @@ namespace Symfony\Component\Intl\Globals; +use Symfony\Polyfill\Intl\Icu\Icu; + /** * Provides fake static versions of the global functions in the intl extension. * * @author Bernhard Schussek * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class IntlGlobals { @@ -61,6 +65,12 @@ abstract class IntlGlobals */ public static function isFailure(int $errorCode): bool { + if (class_exists(Icu::class)) { + return Icu::isFailure($errorCode); + } + + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + return isset(self::ERROR_CODES[$errorCode]) && $errorCode > self::U_ZERO_ERROR; } @@ -74,6 +84,12 @@ public static function isFailure(int $errorCode): bool */ public static function getErrorCode() { + if (class_exists(Icu::class)) { + return Icu::getErrorCode(); + } + + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + return self::$errorCode; } @@ -84,6 +100,12 @@ public static function getErrorCode() */ public static function getErrorMessage(): string { + if (class_exists(Icu::class)) { + return Icu::getErrorMessage(); + } + + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + return self::$errorMessage; } @@ -94,6 +116,12 @@ public static function getErrorMessage(): string */ public static function getErrorName(int $code): string { + if (class_exists(Icu::class)) { + return Icu::getErrorName($code); + } + + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + return self::ERROR_CODES[$code] ?? '[BOGUS UErrorCode]'; } @@ -107,6 +135,10 @@ public static function getErrorName(int $code): string */ public static function setError(int $code, string $message = '') { + if (class_exists(Icu::class)) { + return Icu::setError($code, $message); + } + if (!isset(self::ERROR_CODES[$code])) { throw new \InvalidArgumentException(sprintf('No such error code: "%s".', $code)); } diff --git a/src/Symfony/Component/Intl/Locale/Locale.php b/src/Symfony/Component/Intl/Locale/Locale.php index 81c287e12a0ed..b5be7e15178e3 100644 --- a/src/Symfony/Component/Intl/Locale/Locale.php +++ b/src/Symfony/Component/Intl/Locale/Locale.php @@ -23,6 +23,8 @@ * @author Bernhard Schussek * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class Locale { diff --git a/src/Symfony/Component/Intl/NumberFormatter/NumberFormatter.php b/src/Symfony/Component/Intl/NumberFormatter/NumberFormatter.php index 165f461fa68a7..501e999eb0366 100644 --- a/src/Symfony/Component/Intl/NumberFormatter/NumberFormatter.php +++ b/src/Symfony/Component/Intl/NumberFormatter/NumberFormatter.php @@ -39,6 +39,8 @@ * @author Bernhard Schussek * * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead */ abstract class NumberFormatter { diff --git a/src/Symfony/Component/Intl/README.md b/src/Symfony/Component/Intl/README.md index e1fdbc9789051..ae6d05336331f 100644 --- a/src/Symfony/Component/Intl/README.md +++ b/src/Symfony/Component/Intl/README.md @@ -17,5 +17,3 @@ Resources in the [main Symfony repository](https://github.com/symfony/symfony) * [Docker images with intl support](https://hub.docker.com/r/jakzal/php-intl) (for the Intl component development) - -[0]: https://php.net/intl.setup diff --git a/src/Symfony/Component/Intl/Resources/functions.php b/src/Symfony/Component/Intl/Resources/functions.php new file mode 100644 index 0000000000000..251d039fdd843 --- /dev/null +++ b/src/Symfony/Component/Intl/Resources/functions.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Intl\Globals\IntlGlobals; + +if (!function_exists('intl_is_failure')) { + function intl_is_failure($error_code) + { + return IntlGlobals::isFailure($error_code); + } +} +if (!function_exists('intl_get_error_code')) { + function intl_get_error_code() + { + return IntlGlobals::getErrorCode(); + } +} +if (!function_exists('intl_get_error_message')) { + function intl_get_error_message() + { + return IntlGlobals::getErrorMessage(); + } +} +if (!function_exists('intl_error_name')) { + function intl_error_name($error_code) + { + return IntlGlobals::getErrorName($error_code); + } +} diff --git a/src/Symfony/Component/Intl/Resources/stubs/Collator.php b/src/Symfony/Component/Intl/Resources/stubs/Collator.php index 1977fdf6173f9..91acdf146d2c6 100644 --- a/src/Symfony/Component/Intl/Resources/stubs/Collator.php +++ b/src/Symfony/Component/Intl/Resources/stubs/Collator.php @@ -10,12 +10,26 @@ */ use Symfony\Component\Intl\Collator\Collator as IntlCollator; +use Symfony\Polyfill\Intl\Icu\Collator as CollatorPolyfill; -/** - * Stub implementation for the Collator class of the intl extension. - * - * @author Bernhard Schussek - */ -class Collator extends IntlCollator -{ +if (!class_exists(CollatorPolyfill::class)) { + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + + /** + * Stub implementation for the Collator class of the intl extension. + * + * @author Bernhard Schussek + */ + class Collator extends IntlCollator + { + } +} else { + /** + * Stub implementation for the Collator class of the intl extension. + * + * @author Bernhard Schussek + */ + class Collator extends CollatorPolyfill + { + } } diff --git a/src/Symfony/Component/Intl/Resources/stubs/IntlDateFormatter.php b/src/Symfony/Component/Intl/Resources/stubs/IntlDateFormatter.php index e5209b62cccd4..b8948c157bbd2 100644 --- a/src/Symfony/Component/Intl/Resources/stubs/IntlDateFormatter.php +++ b/src/Symfony/Component/Intl/Resources/stubs/IntlDateFormatter.php @@ -10,14 +10,30 @@ */ use Symfony\Component\Intl\DateFormatter\IntlDateFormatter as BaseIntlDateFormatter; +use Symfony\Polyfill\Intl\Icu\IntlDateFormatter as IntlDateFormatterPolyfill; -/** - * Stub implementation for the IntlDateFormatter class of the intl extension. - * - * @author Bernhard Schussek - * - * @see BaseIntlDateFormatter - */ -class IntlDateFormatter extends BaseIntlDateFormatter -{ +if (!class_exists(IntlDateFormatterPolyfill::class)) { + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + + /** + * Stub implementation for the IntlDateFormatter class of the intl extension. + * + * @author Bernhard Schussek + * + * @see BaseIntlDateFormatter + */ + class IntlDateFormatter extends BaseIntlDateFormatter + { + } +} else { + /** + * Stub implementation for the IntlDateFormatter class of the intl extension. + * + * @author Bernhard Schussek + * + * @see BaseIntlDateFormatter + */ + class IntlDateFormatter extends IntlDateFormatterPolyfill + { + } } diff --git a/src/Symfony/Component/Intl/Resources/stubs/Locale.php b/src/Symfony/Component/Intl/Resources/stubs/Locale.php index 8a3b89bc3efe7..5021fa0ceb773 100644 --- a/src/Symfony/Component/Intl/Resources/stubs/Locale.php +++ b/src/Symfony/Component/Intl/Resources/stubs/Locale.php @@ -10,14 +10,30 @@ */ use Symfony\Component\Intl\Locale\Locale as IntlLocale; +use Symfony\Polyfill\Intl\Icu\Locale as LocalePolyfill; -/** - * Stub implementation for the Locale class of the intl extension. - * - * @author Bernhard Schussek - * - * @see IntlLocale - */ -class Locale extends IntlLocale -{ +if (!class_exists(LocalePolyfill::class)) { + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + + /** + * Stub implementation for the Locale class of the intl extension. + * + * @author Bernhard Schussek + * + * @see IntlLocale + */ + class Locale extends IntlLocale + { + } +} else { + /** + * Stub implementation for the Locale class of the intl extension. + * + * @author Bernhard Schussek + * + * @see IntlLocale + */ + class Locale extends LocalePolyfill + { + } } diff --git a/src/Symfony/Component/Intl/Resources/stubs/NumberFormatter.php b/src/Symfony/Component/Intl/Resources/stubs/NumberFormatter.php index c8e689b3abbd6..7ec6e1c1028fb 100644 --- a/src/Symfony/Component/Intl/Resources/stubs/NumberFormatter.php +++ b/src/Symfony/Component/Intl/Resources/stubs/NumberFormatter.php @@ -10,14 +10,30 @@ */ use Symfony\Component\Intl\NumberFormatter\NumberFormatter as IntlNumberFormatter; +use Symfony\Polyfill\Intl\Icu\NumberFormatter as NumberFormatterPolyfill; -/** - * Stub implementation for the NumberFormatter class of the intl extension. - * - * @author Bernhard Schussek - * - * @see IntlNumberFormatter - */ -class NumberFormatter extends IntlNumberFormatter -{ +if (!class_exists(NumberFormatterPolyfill::class)) { + trigger_deprecation('symfony/intl', '5.3', 'Polyfills are deprecated, try running "composer require symfony/polyfill-intl-icu ^1.21" instead.'); + + /** + * Stub implementation for the NumberFormatter class of the intl extension. + * + * @author Bernhard Schussek + * + * @see IntlNumberFormatter + */ + class NumberFormatter extends IntlNumberFormatter + { + } +} else { + /** + * Stub implementation for the NumberFormatter class of the intl extension. + * + * @author Bernhard Schussek + * + * @see IntlNumberFormatter + */ + class NumberFormatter extends NumberFormatterPolyfill + { + } } diff --git a/src/Symfony/Component/Intl/Tests/Collator/CollatorTest.php b/src/Symfony/Component/Intl/Tests/Collator/CollatorTest.php index d73263e151918..495b01a983dae 100644 --- a/src/Symfony/Component/Intl/Tests/Collator/CollatorTest.php +++ b/src/Symfony/Component/Intl/Tests/Collator/CollatorTest.php @@ -16,6 +16,9 @@ use Symfony\Component\Intl\Exception\MethodNotImplementedException; use Symfony\Component\Intl\Globals\IntlGlobals; +/** + * @group legacy + */ class CollatorTest extends AbstractCollatorTest { public function testConstructorWithUnsupportedLocale() diff --git a/src/Symfony/Component/Intl/Tests/DateFormatter/AbstractIntlDateFormatterTest.php b/src/Symfony/Component/Intl/Tests/DateFormatter/AbstractIntlDateFormatterTest.php index cee6b548a29fb..e18b01f604e21 100644 --- a/src/Symfony/Component/Intl/Tests/DateFormatter/AbstractIntlDateFormatterTest.php +++ b/src/Symfony/Component/Intl/Tests/DateFormatter/AbstractIntlDateFormatterTest.php @@ -21,6 +21,8 @@ * Test case for IntlDateFormatter implementations. * * @author Bernhard Schussek + * + * @group legacy */ abstract class AbstractIntlDateFormatterTest extends TestCase { diff --git a/src/Symfony/Component/Intl/Tests/DateFormatter/IntlDateFormatterTest.php b/src/Symfony/Component/Intl/Tests/DateFormatter/IntlDateFormatterTest.php index de61e7fb8409d..214ddb26e95f4 100644 --- a/src/Symfony/Component/Intl/Tests/DateFormatter/IntlDateFormatterTest.php +++ b/src/Symfony/Component/Intl/Tests/DateFormatter/IntlDateFormatterTest.php @@ -18,6 +18,9 @@ use Symfony\Component\Intl\Exception\NotImplementedException; use Symfony\Component\Intl\Globals\IntlGlobals; +/** + * @group legacy + */ class IntlDateFormatterTest extends AbstractIntlDateFormatterTest { public function testConstructor() diff --git a/src/Symfony/Component/Intl/Tests/Globals/AbstractIntlGlobalsTest.php b/src/Symfony/Component/Intl/Tests/Globals/AbstractIntlGlobalsTest.php index 2cb49ef8275b3..5c4731babd8d2 100644 --- a/src/Symfony/Component/Intl/Tests/Globals/AbstractIntlGlobalsTest.php +++ b/src/Symfony/Component/Intl/Tests/Globals/AbstractIntlGlobalsTest.php @@ -17,6 +17,8 @@ * Test case for intl function implementations. * * @author Bernhard Schussek + * + * @group legacy */ abstract class AbstractIntlGlobalsTest extends TestCase { diff --git a/src/Symfony/Component/Intl/Tests/Globals/IntlGlobalsTest.php b/src/Symfony/Component/Intl/Tests/Globals/IntlGlobalsTest.php index 34e3a6a3a7875..27400e65fd74c 100644 --- a/src/Symfony/Component/Intl/Tests/Globals/IntlGlobalsTest.php +++ b/src/Symfony/Component/Intl/Tests/Globals/IntlGlobalsTest.php @@ -13,6 +13,9 @@ use Symfony\Component\Intl\Globals\IntlGlobals; +/** + * @group legacy + */ class IntlGlobalsTest extends AbstractIntlGlobalsTest { protected function getIntlErrorName($errorCode) diff --git a/src/Symfony/Component/Intl/Tests/Globals/Verification/IntlGlobalsTest.php b/src/Symfony/Component/Intl/Tests/Globals/Verification/IntlGlobalsTest.php index 4b390d58c1ea0..c7bc125b2e7c4 100644 --- a/src/Symfony/Component/Intl/Tests/Globals/Verification/IntlGlobalsTest.php +++ b/src/Symfony/Component/Intl/Tests/Globals/Verification/IntlGlobalsTest.php @@ -19,6 +19,8 @@ * intl functions with a specific version of ICU. * * @author Bernhard Schussek + * + * @group legacy */ class IntlGlobalsTest extends AbstractIntlGlobalsTest { diff --git a/src/Symfony/Component/Intl/Tests/NumberFormatter/AbstractNumberFormatterTest.php b/src/Symfony/Component/Intl/Tests/NumberFormatter/AbstractNumberFormatterTest.php index c20681303e610..30479fd3878b1 100644 --- a/src/Symfony/Component/Intl/Tests/NumberFormatter/AbstractNumberFormatterTest.php +++ b/src/Symfony/Component/Intl/Tests/NumberFormatter/AbstractNumberFormatterTest.php @@ -20,6 +20,8 @@ /** * Note that there are some values written like -2147483647 - 1. This is the lower 32bit int max and is a known * behavior of PHP. + * + * @group legacy */ abstract class AbstractNumberFormatterTest extends TestCase { diff --git a/src/Symfony/Component/Intl/Tests/NumberFormatter/NumberFormatterTest.php b/src/Symfony/Component/Intl/Tests/NumberFormatter/NumberFormatterTest.php index 0c5507de84b0e..6635ca92ef064 100644 --- a/src/Symfony/Component/Intl/Tests/NumberFormatter/NumberFormatterTest.php +++ b/src/Symfony/Component/Intl/Tests/NumberFormatter/NumberFormatterTest.php @@ -21,6 +21,8 @@ /** * Note that there are some values written like -2147483647 - 1. This is the lower 32bit int max and is a known * behavior of PHP. + * + * @group legacy */ class NumberFormatterTest extends AbstractNumberFormatterTest { diff --git a/src/Symfony/Component/Intl/composer.json b/src/Symfony/Component/Intl/composer.json index 661089f58399c..4e4624c7468e6 100644 --- a/src/Symfony/Component/Intl/composer.json +++ b/src/Symfony/Component/Intl/composer.json @@ -25,18 +25,16 @@ ], "require": { "php": ">=7.2.5", - "symfony/polyfill-intl-icu": "~1.0", + "symfony/deprecation-contracts": "^2.1", "symfony/polyfill-php80": "^1.15" }, "require-dev": { "symfony/filesystem": "^4.4|^5.0" }, - "suggest": { - "ext-intl": "to use the component with locales other than \"en\"" - }, "autoload": { "psr-4": { "Symfony\\Component\\Intl\\": "" }, "classmap": [ "Resources/stubs" ], + "files": [ "Resources/functions.php" ], "exclude-from-classmap": [ "/Tests/" ] diff --git a/src/Symfony/Component/Ldap/CHANGELOG.md b/src/Symfony/Component/Ldap/CHANGELOG.md index f54a3e824184e..4f73e6e20959a 100644 --- a/src/Symfony/Component/Ldap/CHANGELOG.md +++ b/src/Symfony/Component/Ldap/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Added caseSensitive option for attribute keys in the Entry class. + 5.1.0 ----- diff --git a/src/Symfony/Component/Ldap/Entry.php b/src/Symfony/Component/Ldap/Entry.php index 4770d898a2fa1..71b675aa7006c 100644 --- a/src/Symfony/Component/Ldap/Entry.php +++ b/src/Symfony/Component/Ldap/Entry.php @@ -13,16 +13,23 @@ /** * @author Charles Sarrazin + * @author Karl Shea */ class Entry { private $dn; private $attributes; + private $lowerMap; public function __construct(string $dn, array $attributes = []) { $this->dn = $dn; - $this->attributes = $attributes; + $this->attributes = []; + $this->lowerMap = []; + + foreach ($attributes as $key => $attribute) { + $this->setAttribute($key, $attribute); + } } /** @@ -38,13 +45,21 @@ public function getDn() /** * Returns whether an attribute exists. * - * @param string $name The name of the attribute + * @param string $name The name of the attribute + * @param bool $caseSensitive Whether the check should be case-sensitive * * @return bool */ - public function hasAttribute(string $name) + public function hasAttribute(string $name/* , bool $caseSensitive = true */) { - return isset($this->attributes[$name]); + $caseSensitive = 2 > \func_num_args() || true === func_get_arg(1); + $attributeKey = $this->getAttributeKey($name, $caseSensitive); + + if (null === $attributeKey) { + return false; + } + + return isset($this->attributes[$attributeKey]); } /** @@ -53,13 +68,21 @@ public function hasAttribute(string $name) * As LDAP can return multiple values for a single attribute, * this value is returned as an array. * - * @param string $name The name of the attribute + * @param string $name The name of the attribute + * @param bool $caseSensitive Whether the attribute name is case-sensitive * * @return array|null */ - public function getAttribute(string $name) + public function getAttribute(string $name/* , bool $caseSensitive = true */) { - return $this->attributes[$name] ?? null; + $caseSensitive = 2 > \func_num_args() || true === func_get_arg(1); + $attributeKey = $this->getAttributeKey($name, $caseSensitive); + + if (null === $attributeKey) { + return null; + } + + return $this->attributes[$attributeKey] ?? null; } /** @@ -78,6 +101,7 @@ public function getAttributes() public function setAttribute(string $name, array $value) { $this->attributes[$name] = $value; + $this->lowerMap[strtolower($name)] = $name; } /** @@ -86,5 +110,15 @@ public function setAttribute(string $name, array $value) public function removeAttribute(string $name) { unset($this->attributes[$name]); + unset($this->lowerMap[strtolower($name)]); + } + + private function getAttributeKey(string $name, bool $caseSensitive = true): ?string + { + if ($caseSensitive) { + return $name; + } + + return $this->lowerMap[strtolower($name)] ?? null; } } diff --git a/src/Symfony/Component/Ldap/Tests/EntryTest.php b/src/Symfony/Component/Ldap/Tests/EntryTest.php new file mode 100644 index 0000000000000..7185db1040281 --- /dev/null +++ b/src/Symfony/Component/Ldap/Tests/EntryTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Ldap\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Ldap\Entry; + +class EntryTest extends TestCase +{ + public function testCaseSensitiveAttributeAccessors() + { + $mail = 'fabpot@symfony.com'; + $givenName = 'Fabien Potencier'; + + $entry = new Entry('cn=fabpot,dc=symfony,dc=com', [ + 'mail' => [$mail], + 'givenName' => [$givenName], + ]); + + $this->assertFalse($entry->hasAttribute('givenname')); + $this->assertTrue($entry->hasAttribute('givenname', false)); + + $this->assertNull($entry->getAttribute('givenname')); + $this->assertSame($givenName, $entry->getAttribute('givenname', false)[0]); + + $firstName = 'Fabien'; + + $entry->setAttribute('firstName', [$firstName]); + $this->assertSame($firstName, $entry->getAttribute('firstname', false)[0]); + $entry->removeAttribute('firstName'); + $this->assertFalse($entry->hasAttribute('firstname', false)); + } +} diff --git a/src/Symfony/Component/Lock/README.md b/src/Symfony/Component/Lock/README.md index 720986febfc66..34ca84c13bc47 100644 --- a/src/Symfony/Component/Lock/README.md +++ b/src/Symfony/Component/Lock/README.md @@ -7,7 +7,7 @@ access to a shared resource. Resources --------- - * [Documentation](https://symfony.com/doc/master/components/lock.html) + * [Documentation](https://symfony.com/doc/current/components/lock.html) * [Contributing](https://symfony.com/doc/current/contributing/index.html) * [Report issues](https://github.com/symfony/symfony/issues) and [send Pull Requests](https://github.com/symfony/symfony/pulls) diff --git a/src/Symfony/Component/Lock/Store/FlockStore.php b/src/Symfony/Component/Lock/Store/FlockStore.php index 76e2b355c1854..0fd55c8456c06 100644 --- a/src/Symfony/Component/Lock/Store/FlockStore.php +++ b/src/Symfony/Component/Lock/Store/FlockStore.php @@ -42,8 +42,12 @@ public function __construct(string $lockPath = null) if (null === $lockPath) { $lockPath = sys_get_temp_dir(); } - if (!is_dir($lockPath) || !is_writable($lockPath)) { - throw new InvalidArgumentException(sprintf('The directory "%s" is not writable.', $lockPath)); + if (!is_dir($lockPath)) { + if (false === @mkdir($lockPath, 0777, true) && !is_dir($lockPath)) { + throw new InvalidArgumentException(sprintf('The FlockStore directory "%s" does not exists and cannot be created.', $lockPath)); + } + } elseif (!is_writable($lockPath)) { + throw new InvalidArgumentException(sprintf('The FlockStore directory "%s" is not writable.', $lockPath)); } $this->lockPath = $lockPath; diff --git a/src/Symfony/Component/Lock/Store/PdoStore.php b/src/Symfony/Component/Lock/Store/PdoStore.php index f3db199c33d75..b300e9fff3a5e 100644 --- a/src/Symfony/Component/Lock/Store/PdoStore.php +++ b/src/Symfony/Component/Lock/Store/PdoStore.php @@ -284,7 +284,7 @@ public function createTable(): void switch ($driver) { case 'mysql': - $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(44) NOT NULL, $this->expirationCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; + $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(44) NOT NULL, $this->expirationCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB"; break; case 'sqlite': $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)"; diff --git a/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php index 9dcf7eddaedcf..f8b57ee6ff7d4 100644 --- a/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php @@ -33,10 +33,10 @@ protected function getStore(): PersistingStoreInterface return new FlockStore(); } - public function testConstructWhenRepositoryDoesNotExist() + public function testConstructWhenRepositoryCannotBeCreated() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The directory "/a/b/c/d/e" is not writable.'); + $this->expectExceptionMessage('The FlockStore directory "/a/b/c/d/e" does not exists and cannot be created.'); if (!getenv('USER') || 'root' === getenv('USER')) { $this->markTestSkipped('This test will fail if run under superuser'); } @@ -47,7 +47,7 @@ public function testConstructWhenRepositoryDoesNotExist() public function testConstructWhenRepositoryIsNotWriteable() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The directory "/" is not writable.'); + $this->expectExceptionMessage('The FlockStore directory "/" is not writable.'); if (!getenv('USER') || 'root' === getenv('USER')) { $this->markTestSkipped('This test will fail if run under superuser'); } @@ -55,6 +55,14 @@ public function testConstructWhenRepositoryIsNotWriteable() new FlockStore('/'); } + public function testConstructWithSubdir() + { + new FlockStore($dir = (sys_get_temp_dir().'/sf-flock')); + $this->assertDirectoryExists($dir); + // cleanup + @rmdir($dir); + } + public function testSaveSanitizeName() { $store = $this->getStore(); diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiAsyncAwsTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiAsyncAwsTransportTest.php index 52a7c29f41cb7..b0a52538318d9 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiAsyncAwsTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiAsyncAwsTransportTest.php @@ -70,6 +70,7 @@ public function testSend() $this->assertSame('Hello There!', $content['Content']['Simple']['Body']['Html']['Data']); $this->assertSame(['replyto-1@example.com', 'replyto-2@example.com'], $content['ReplyToAddresses']); $this->assertSame('aws-configuration-set-name', $content['ConfigurationSetName']); + $this->assertSame('aws-source-arn', $content['FromEmailAddressIdentityArn']); $this->assertSame('bounces@example.com', $content['FeedbackForwardingEmailAddress']); $json = '{"MessageId": "foobar"}'; @@ -91,6 +92,7 @@ public function testSend() ->returnPath(new Address('bounces@example.com')); $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); + $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); $message = $transport->send($mail); diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiTransportTest.php index b4dfa191aea0f..cdbdaa9dfdb87 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesApiTransportTest.php @@ -69,6 +69,7 @@ public function testSend() $this->assertSame('Fabien ', $content['Source']); $this->assertSame('Hello There!', $content['Message_Body_Text_Data']); $this->assertSame('aws-configuration-set-name', $content['ConfigurationSetName']); + $this->assertSame('aws-source-arn', $content['FromEmailAddressIdentityArn']); $xml = ' @@ -90,6 +91,7 @@ public function testSend() ->text('Hello There!'); $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); + $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); $message = $transport->send($mail); @@ -135,6 +137,7 @@ public function testSendWithAttachments() ->attach('attached data'); $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); + $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); $message = $transport->send($mail); diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpAsyncAwsTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpAsyncAwsTransportTest.php index 6bdd9b779d58d..981b92d8fb8f4 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpAsyncAwsTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpAsyncAwsTransportTest.php @@ -69,6 +69,7 @@ public function testSend() $this->assertStringContainsString('Fabien ', $content); $this->assertStringContainsString('Hello There!', $content); $this->assertSame('aws-configuration-set-name', $body['ConfigurationSetName']); + $this->assertSame('aws-source-arn', $body['FromEmailAddressIdentityArn']); $json = '{"MessageId": "foobar"}'; @@ -86,6 +87,7 @@ public function testSend() ->text('Hello There!'); $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); + $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); $message = $transport->send($mail); diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpTransportTest.php index cce8a4792cc64..bd5babdadd0e3 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Tests/Transport/SesHttpTransportTest.php @@ -77,6 +77,7 @@ public function testSend() $this->assertStringContainsString('Hello There!', $content); $this->assertSame('aws-configuration-set-name', $body['ConfigurationSetName']); + $this->assertSame('aws-source-arn', $body['FromEmailAddressIdentityArn']); $xml = ' @@ -99,6 +100,7 @@ public function testSend() ->text('Hello There!'); $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); + $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); $message = $transport->send($mail); diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiAsyncAwsTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiAsyncAwsTransport.php index 0791a64aa270a..a0e2cb286eaca 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiAsyncAwsTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiAsyncAwsTransport.php @@ -92,6 +92,9 @@ protected function getRequest(SentMessage $message): SendEmailRequest if ($header = $email->getHeaders()->get('X-SES-CONFIGURATION-SET')) { $request['ConfigurationSetName'] = $header->getBodyAsString(); } + if ($header = $email->getHeaders()->get('X-SES-SOURCE-ARN')) { + $request['FromEmailAddressIdentityArn'] = $header->getBodyAsString(); + } if ($email->getReturnPath()) { $request['FeedbackForwardingEmailAddress'] = $email->getReturnPath()->toString(); } diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiTransport.php index 33682615186c4..6f53aa0cccb8a 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesApiTransport.php @@ -99,6 +99,10 @@ private function getPayload(Email $email, Envelope $envelope): array $payload['ConfigurationSetName'] = $header->getBodyAsString(); } + if ($header = $email->getHeaders()->get('X-SES-SOURCE-ARN')) { + $payload['FromEmailAddressIdentityArn'] = $header->getBodyAsString(); + } + return $payload; } @@ -127,6 +131,9 @@ private function getPayload(Email $email, Envelope $envelope): array if ($header = $email->getHeaders()->get('X-SES-CONFIGURATION-SET')) { $payload['ConfigurationSetName'] = $header->getBodyAsString(); } + if ($header = $email->getHeaders()->get('X-SES-SOURCE-ARN')) { + $payload['FromEmailAddressIdentityArn'] = $header->getBodyAsString(); + } return $payload; } diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpAsyncAwsTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpAsyncAwsTransport.php index 58ae25e792190..8f2177186bf14 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpAsyncAwsTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpAsyncAwsTransport.php @@ -83,6 +83,10 @@ protected function getRequest(SentMessage $message): SendEmailRequest && $configurationSetHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-CONFIGURATION-SET')) { $request['ConfigurationSetName'] = $configurationSetHeader->getBodyAsString(); } + if (($message->getOriginalMessage() instanceof Message) + && $sourceArnHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-SOURCE-ARN')) { + $request['FromEmailAddressIdentityArn'] = $sourceArnHeader->getBodyAsString(); + } return new SendEmailRequest($request); } diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpTransport.php b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpTransport.php index 8e9e9de5f7b25..779566788af32 100644 --- a/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Amazon/Transport/SesHttpTransport.php @@ -77,6 +77,11 @@ protected function doSendHttp(SentMessage $message): ResponseInterface $request['body']['ConfigurationSetName'] = $configurationSetHeader->getBodyAsString(); } + if ($message->getOriginalMessage() instanceof Message + && $sourceArnHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-SOURCE-ARN')) { + $request['body']['FromEmailAddressIdentityArn'] = $sourceArnHeader->getBodyAsString(); + } + $response = $this->client->request('POST', 'https://'.$this->getEndpoint(), $request); $result = new \SimpleXMLElement($response->getContent(false)); diff --git a/src/Symfony/Component/Mailer/CHANGELOG.md b/src/Symfony/Component/Mailer/CHANGELOG.md index cca04f11aa78e..5461a2a4e0fa0 100644 --- a/src/Symfony/Component/Mailer/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * added the `mailer` monolog channel and set it on all transport definitions + * Add support for `X-SES-SOURCE-ARN` in `symfony/amazon-mailer` + 5.2.0 ----- diff --git a/src/Symfony/Component/Mailer/Exception/HttpTransportException.php b/src/Symfony/Component/Mailer/Exception/HttpTransportException.php index 3428b70e07eae..c72eb6cf6e3ee 100644 --- a/src/Symfony/Component/Mailer/Exception/HttpTransportException.php +++ b/src/Symfony/Component/Mailer/Exception/HttpTransportException.php @@ -22,6 +22,12 @@ class HttpTransportException extends TransportException public function __construct(?string $message, ResponseInterface $response, int $code = 0, \Throwable $previous = null) { + if (null === $message) { + trigger_deprecation('symfony/mailer', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + parent::__construct($message, $code, $previous); $this->response = $response; diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/CHANGELOG.md b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/CHANGELOG.md index 67a5252f0b21c..fbe4fce5d0fd4 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * Added new `debug` option to log HTTP requests and responses. + * Allowed for receiver & sender injection into AmazonSqsTransport + 5.2.0 ----- diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportTest.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportTest.php index 62f596f40fe8c..caf5f0a539b10 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportTest.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportTest.php @@ -11,17 +11,55 @@ namespace Symfony\Component\Messenger\Bridge\AmazonSqs\Tests\Transport; +use AsyncAws\Core\Exception\Http\HttpException; +use AsyncAws\Core\Exception\Http\ServerException; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Bridge\AmazonSqs\Tests\Fixtures\DummyMessage; +use Symfony\Component\Messenger\Bridge\AmazonSqs\Transport\AmazonSqsReceiver; use Symfony\Component\Messenger\Bridge\AmazonSqs\Transport\AmazonSqsTransport; use Symfony\Component\Messenger\Bridge\AmazonSqs\Transport\Connection; use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\Exception\TransportException; use Symfony\Component\Messenger\Transport\Receiver\MessageCountAwareInterface; +use Symfony\Component\Messenger\Transport\Receiver\ReceiverInterface; +use Symfony\Component\Messenger\Transport\Sender\SenderInterface; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; class AmazonSqsTransportTest extends TestCase { + /** + * @var MockObject|Connection + */ + private $connection; + + /** + * @var MockObject|ReceiverInterface + */ + private $receiver; + + /** + * @var MockObject|SenderInterface|MessageCountAwareInterface + */ + private $sender; + + /** + * @var AmazonSqsTransport + */ + private $transport; + + protected function setUp(): void + { + $this->connection = $this->createMock(Connection::class); + // Mocking the concrete receiver class because mocking multiple interfaces is deprecated + $this->receiver = $this->createMock(AmazonSqsReceiver::class); + $this->sender = $this->createMock(SenderInterface::class); + + $this->transport = new AmazonSqsTransport($this->connection, null, $this->receiver, $this->sender); + } + public function testItIsATransport() { $transport = $this->getTransport(); @@ -58,6 +96,77 @@ public function testTransportIsAMessageCountAware() $this->assertInstanceOf(MessageCountAwareInterface::class, $transport); } + public function testItCanGetMessagesViaTheReceiver() + { + $envelopes = [new Envelope(new \stdClass()), new Envelope(new \stdClass())]; + $this->receiver->expects($this->once())->method('get')->willReturn($envelopes); + $this->assertSame($envelopes, $this->transport->get()); + } + + public function testItCanAcknowledgeAMessageViaTheReceiver() + { + $envelope = new Envelope(new \stdClass()); + $this->receiver->expects($this->once())->method('ack')->with($envelope); + $this->transport->ack($envelope); + } + + public function testItCanRejectAMessageViaTheReceiver() + { + $envelope = new Envelope(new \stdClass()); + $this->receiver->expects($this->once())->method('reject')->with($envelope); + $this->transport->reject($envelope); + } + + public function testItCanGetMessageCountViaTheReceiver() + { + $messageCount = 15; + $this->receiver->expects($this->once())->method('getMessageCount')->willReturn($messageCount); + $this->assertSame($messageCount, $this->transport->getMessageCount()); + } + + public function testItCanSendAMessageViaTheSender() + { + $envelope = new Envelope(new \stdClass()); + $this->sender->expects($this->once())->method('send')->with($envelope)->willReturn($envelope); + $this->assertSame($envelope, $this->transport->send($envelope)); + } + + public function testItCanSetUpTheConnection() + { + $this->connection->expects($this->once())->method('setup'); + $this->transport->setup(); + } + + public function testItConvertsHttpExceptionDuringSetupIntoTransportException() + { + $this->connection + ->expects($this->once()) + ->method('setup') + ->willThrowException($this->createHttpException()); + + $this->expectException(TransportException::class); + + $this->transport->setup(); + } + + public function testItCanResetTheConnection() + { + $this->connection->expects($this->once())->method('reset'); + $this->transport->reset(); + } + + public function testItConvertsHttpExceptionDuringResetIntoTransportException() + { + $this->connection + ->expects($this->once()) + ->method('reset') + ->willThrowException($this->createHttpException()); + + $this->expectException(TransportException::class); + + $this->transport->reset(); + } + private function getTransport(SerializerInterface $serializer = null, Connection $connection = null) { $serializer = $serializer ?: $this->createMock(SerializerInterface::class); @@ -65,4 +174,9 @@ private function getTransport(SerializerInterface $serializer = null, Connection return new AmazonSqsTransport($connection, $serializer); } + + private function createHttpException(): HttpException + { + return new ServerException($this->createMock(ResponseInterface::class)); + } } diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php index 0090a343f6d56..6f3b906a5a188 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php @@ -18,6 +18,9 @@ use AsyncAws\Sqs\SqsClient; use AsyncAws\Sqs\ValueObject\Message; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Messenger\Bridge\AmazonSqs\Transport\Connection; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -179,7 +182,7 @@ public function testFromDsnWithAccountAndEndpointOption() public function testFromDsnWithInvalidQueryString() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown option found in DSN: [foo]. Allowed options are [buffer_size, wait_time, poll_timeout, visibility_timeout, auto_setup, access_key, secret_key, endpoint, region, queue_name, account, sslmode].'); + $this->expectExceptionMessageMatches('|Unknown option found in DSN: \[foo\]\. Allowed options are \[buffer_size, |'); Connection::fromDsn('sqs://default?foo=foo'); } @@ -187,7 +190,7 @@ public function testFromDsnWithInvalidQueryString() public function testFromDsnWithInvalidOption() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown option found: [bar]. Allowed options are [buffer_size, wait_time, poll_timeout, visibility_timeout, auto_setup, access_key, secret_key, endpoint, region, queue_name, account, sslmode].'); + $this->expectExceptionMessageMatches('|Unknown option found: \[bar\]\. Allowed options are \[buffer_size, |'); Connection::fromDsn('sqs://default', ['bar' => 'bar']); } @@ -195,7 +198,7 @@ public function testFromDsnWithInvalidOption() public function testFromDsnWithInvalidQueryStringAndOption() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown option found: [bar]. Allowed options are [buffer_size, wait_time, poll_timeout, visibility_timeout, auto_setup, access_key, secret_key, endpoint, region, queue_name, account, sslmode].'); + $this->expectExceptionMessageMatches('|Unknown option found: \[bar\]\. Allowed options are \[buffer_size, |'); Connection::fromDsn('sqs://default?foo=foo', ['bar' => 'bar']); } @@ -312,4 +315,83 @@ public function testGetQueueUrlNotCalled() $connection->delete('id'); } + + public function testLoggerWithoutDebugOption() + { + $client = new MockHttpClient([$this->getMockedQueueUrlResponse(), $this->getMockedReceiveMessageResponse()]); + $logger = $this->getMockBuilder(NullLogger::class) + ->disableOriginalConstructor() + ->onlyMethods(['debug']) + ->getMock(); + $logger->expects($this->never())->method('debug'); + $connection = Connection::fromDsn('sqs://default', ['access_key' => 'foo', 'secret_key' => 'bar', 'auto_setup' => false], $client, $logger); + $connection->get(); + } + + public function testLoggerWithDebugOption() + { + $client = new MockHttpClient([$this->getMockedQueueUrlResponse(), $this->getMockedReceiveMessageResponse()]); + $logger = $this->getMockBuilder(NullLogger::class) + ->disableOriginalConstructor() + ->onlyMethods(['debug']) + ->getMock(); + $logger->expects($this->exactly(4))->method('debug'); + $connection = Connection::fromDsn('sqs://default?debug=true', ['access_key' => 'foo', 'secret_key' => 'bar', 'auto_setup' => false], $client, $logger); + $connection->get(); + } + + private function getMockedQueueUrlResponse(): MockResponse + { + return new MockResponse(<< + + https://sqs.us-east-2.amazonaws.com/123456789012/MyQueue + + + 470a6f13-2ed9-4181-ad8a-2fdea142988e + + +XML + ); + } + + private function getMockedReceiveMessageResponse(): MockResponse + { + return new MockResponse(<< + + + 5fea7756-0ea4-451a-a703-a558b933e274 + + MbZj6wDWli+JvwwJaBV+3dcjk2YW2vA3+STFFljTM8tJJg6HRG6PYSasuWXPJB+Cw + Lj1FjgXUv1uSj1gUPAWV66FU/WeR4mq2OKpEGYWbnLmpRCJVAyeMjeU5ZBdtcQ+QE + auMZc8ZRv37sIW2iJKq3M9MFx1YvV11A2x/KSbkJ0= + + fafb00f5732ab283681e124bf8747ed1 + This is a test message + + SenderId + 195004372649 + + + SentTimestamp + 1238099229000 + + + ApproximateReceiveCount + 5 + + + ApproximateFirstReceiveTimestamp + 1250700979248 + + + + + b6633655-283d-45b4-aee4-4e84e0ae6afa + + +XML + ); + } } diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransport.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransport.php index cf84fce11cd9a..50c7b8ff9a7d2 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransport.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransport.php @@ -15,6 +15,8 @@ use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Exception\TransportException; use Symfony\Component\Messenger\Transport\Receiver\MessageCountAwareInterface; +use Symfony\Component\Messenger\Transport\Receiver\ReceiverInterface; +use Symfony\Component\Messenger\Transport\Sender\SenderInterface; use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\SetupableTransportInterface; @@ -31,10 +33,12 @@ class AmazonSqsTransport implements TransportInterface, SetupableTransportInterf private $receiver; private $sender; - public function __construct(Connection $connection, SerializerInterface $serializer = null) + public function __construct(Connection $connection, SerializerInterface $serializer = null, ReceiverInterface $receiver = null, SenderInterface $sender = null) { $this->connection = $connection; $this->serializer = $serializer ?? new PhpSerializer(); + $this->receiver = $receiver; + $this->sender = $sender; } /** diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php index d0424d1e9555c..0673966ba0cf5 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Messenger\Bridge\AmazonSqs\Transport; +use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\TransportFactoryInterface; use Symfony\Component\Messenger\Transport\TransportInterface; @@ -20,11 +21,18 @@ */ class AmazonSqsTransportFactory implements TransportFactoryInterface { + private $logger; + + public function __construct(LoggerInterface $logger = null) + { + $this->logger = $logger; + } + public function createTransport(string $dsn, array $options, SerializerInterface $serializer): TransportInterface { unset($options['transport_name']); - return new AmazonSqsTransport(Connection::fromDsn($dsn, $options), $serializer); + return new AmazonSqsTransport(Connection::fromDsn($dsn, $options, null, $this->logger), $serializer); } public function supports(string $dsn, array $options): bool diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php index 8925572459171..b6bc8306c9dbb 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php @@ -15,6 +15,7 @@ use AsyncAws\Sqs\Result\ReceiveMessageResult; use AsyncAws\Sqs\SqsClient; use AsyncAws\Sqs\ValueObject\MessageAttributeValue; +use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Exception\InvalidArgumentException; use Symfony\Component\Messenger\Exception\TransportException; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -45,6 +46,7 @@ class Connection 'queue_name' => 'messages', 'account' => null, 'sslmode' => null, + 'debug' => null, ]; private $configuration; @@ -96,8 +98,9 @@ public function __destruct() * * visibility_timeout: amount of seconds the message won't be visible * * sslmode: Can be "disable" to use http for a custom endpoint * * auto_setup: Whether the queue should be created automatically during send / get (Default: true) + * * debug: Log all HTTP requests and responses as LoggerInterface::DEBUG (Default: false) */ - public static function fromDsn(string $dsn, array $options = [], HttpClientInterface $client = null): self + public static function fromDsn(string $dsn, array $options = [], HttpClientInterface $client = null, LoggerInterface $logger = null): self { if (false === $parsedUrl = parse_url($dsn)) { throw new InvalidArgumentException(sprintf('The given Amazon SQS DSN "%s" is invalid.', $dsn)); @@ -126,7 +129,7 @@ public static function fromDsn(string $dsn, array $options = [], HttpClientInter 'wait_time' => (int) $options['wait_time'], 'poll_timeout' => $options['poll_timeout'], 'visibility_timeout' => $options['visibility_timeout'], - 'auto_setup' => (bool) $options['auto_setup'], + 'auto_setup' => filter_var($options['auto_setup'], \FILTER_VALIDATE_BOOLEAN), 'queue_name' => (string) $options['queue_name'], ]; @@ -135,6 +138,9 @@ public static function fromDsn(string $dsn, array $options = [], HttpClientInter 'accessKeyId' => urldecode($parsedUrl['user'] ?? '') ?: $options['access_key'] ?? self::DEFAULT_OPTIONS['access_key'], 'accessKeySecret' => urldecode($parsedUrl['pass'] ?? '') ?: $options['secret_key'] ?? self::DEFAULT_OPTIONS['secret_key'], ]; + if (isset($options['debug'])) { + $clientConfiguration['debug'] = $options['debug']; + } unset($query['region']); if ('default' !== ($parsedUrl['host'] ?? 'default')) { @@ -163,7 +169,7 @@ public static function fromDsn(string $dsn, array $options = [], HttpClientInter $queueUrl = 'https://'.$parsedUrl['host'].$parsedUrl['path']; } - return new self($configuration, new SqsClient($clientConfiguration, null, $client), $queueUrl); + return new self($configuration, new SqsClient($clientConfiguration, null, $client, $logger), $queueUrl); } public function get(): ?array diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/composer.json b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/composer.json index 3bcee938c00e5..c9902ac1c3b23 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/composer.json +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/composer.json @@ -17,9 +17,11 @@ ], "require": { "php": ">=7.2.5", + "async-aws/core": "^1.5", "async-aws/sqs": "^1.0", "symfony/messenger": "^4.3|^5.0", - "symfony/service-contracts": "^1.1|^2" + "symfony/service-contracts": "^1.1|^2", + "psr/log": "^1.0" }, "require-dev": { "symfony/http-client-contracts": "^1.0|^2.0", diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/CHANGELOG.md b/src/Symfony/Component/Messenger/Bridge/Amqp/CHANGELOG.md index 81c0100991936..34bb9547ea9d3 100644 --- a/src/Symfony/Component/Messenger/Bridge/Amqp/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/Bridge/Amqp/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * Deprecated the `prefetch_count` parameter, it has no effect and will be removed in Symfony 6.0. + * `AmqpReceiver` implements `QueueReceiverInterface` to fetch messages from a specific set of queues. + 5.2.0 ----- diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php b/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php index d1c43898a6270..543494acfb4d3 100644 --- a/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php +++ b/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php @@ -421,6 +421,27 @@ public function testItCanDisableTheSetup() $connection->publish('body'); } + public function testItSetupQueuesOnce() + { + $factory = new TestAmqpFactory( + $amqpConnection = $this->createMock(\AMQPConnection::class), + $amqpChannel = $this->createMock(\AMQPChannel::class), + $amqpQueue = $this->createMock(\AMQPQueue::class), + $amqpExchange = $this->createMock(\AMQPExchange::class) + ); + + $amqpExchange->expects($this->once())->method('declareExchange'); + $amqpQueue->expects($this->once())->method('declareQueue'); + $amqpQueue->expects($this->once())->method('bind'); + + $connection = Connection::fromDsn('amqp://localhost', ['auto_setup' => true], $factory); + $connection->publish('body'); + $connection->publish('body'); + } + + /** + * @group legacy + */ public function testSetChannelPrefetchWhenSetup() { $factory = new TestAmqpFactory( @@ -433,11 +454,11 @@ public function testSetChannelPrefetchWhenSetup() // makes sure the channel looks connected, so it's not re-created $amqpChannel->expects($this->any())->method('isConnected')->willReturn(true); - $amqpChannel->expects($this->exactly(2))->method('setPrefetchCount')->with(2); + $amqpChannel->expects($this->never())->method('setPrefetchCount'); + + $this->expectDeprecation('Since symfony/messenger 5.3: The "prefetch_count" option passed to the AMQP Messenger transport has no effect and should not be used.'); $connection = Connection::fromDsn('amqp://localhost?prefetch_count=2', [], $factory); $connection->setup(); - $connection = Connection::fromDsn('amqp://localhost', ['prefetch_count' => 2], $factory); - $connection->setup(); } public function testAutoSetupWithDelayDeclaresExchangeQueuesAndDelay() diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpReceiver.php b/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpReceiver.php index 009e7be8d55bb..84630fb28e03f 100644 --- a/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpReceiver.php +++ b/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpReceiver.php @@ -16,7 +16,7 @@ use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; use Symfony\Component\Messenger\Exception\TransportException; use Symfony\Component\Messenger\Transport\Receiver\MessageCountAwareInterface; -use Symfony\Component\Messenger\Transport\Receiver\ReceiverInterface; +use Symfony\Component\Messenger\Transport\Receiver\QueueReceiverInterface; use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; @@ -25,7 +25,7 @@ * * @author Samuel Roze */ -class AmqpReceiver implements ReceiverInterface, MessageCountAwareInterface +class AmqpReceiver implements QueueReceiverInterface, MessageCountAwareInterface { private $serializer; private $connection; @@ -41,7 +41,15 @@ public function __construct(Connection $connection, SerializerInterface $seriali */ public function get(): iterable { - foreach ($this->connection->getQueueNames() as $queueName) { + yield from $this->getFromQueues($this->connection->getQueueNames()); + } + + /** + * {@inheritdoc} + */ + public function getFromQueues(array $queueNames): iterable + { + foreach ($queueNames as $queueName) { yield from $this->getEnvelope($queueName); } } diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php b/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php index 5c7e76291060d..d2d6decf71525 100644 --- a/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php +++ b/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php @@ -79,6 +79,8 @@ class Connection private $exchangeOptions; private $queuesOptions; private $amqpFactory; + private $autoSetupExchange; + private $autoSetup; /** * @var \AMQPChannel|null @@ -112,6 +114,7 @@ public function __construct(array $connectionOptions, array $exchangeOptions, ar 'queue_name_pattern' => 'delay_%exchange_name%_%routing_key%_%delay%', ], ], $connectionOptions); + $this->autoSetupExchange = $this->autoSetup = $connectionOptions['auto_setup'] ?? true; $this->exchangeOptions = $exchangeOptions; $this->queuesOptions = $queuesOptions; $this->amqpFactory = $amqpFactory ?: new AmqpFactory(); @@ -146,7 +149,6 @@ public function __construct(array $connectionOptions, array $exchangeOptions, ar * * queue_name_pattern: Pattern to use to create the queues (Default: "delay_%exchange_name%_%routing_key%_%delay%") * * exchange_name: Name of the exchange to be used for the delayed/retried messages (Default: "delays") * * auto_setup: Enable or not the auto-setup of queues and exchanges (Default: true) - * * prefetch_count: set channel prefetch count * * * Connection tuning options (see http://www.rabbitmq.com/amqp-0-9-1-reference.html#connection.tune for details): * * channel_max: Specifies highest channel number that the server permits. 0 means standard extension limit @@ -207,6 +209,9 @@ public static function fromDsn(string $dsn, array $options = [], AmqpFactory $am $exchangeOptions = $amqpOptions['exchange']; $queuesOptions = $amqpOptions['queues']; unset($amqpOptions['queues'], $amqpOptions['exchange']); + if (isset($amqpOptions['auto_setup'])) { + $amqpOptions['auto_setup'] = filter_var($amqpOptions['auto_setup'], \FILTER_VALIDATE_BOOLEAN); + } $queuesOptions = array_map(function ($queueOptions) { if (!\is_array($queueOptions)) { @@ -232,6 +237,10 @@ private static function validateOptions(array $options): void trigger_deprecation('symfony/messenger', '5.1', 'Invalid option(s) "%s" passed to the AMQP Messenger transport. Passing invalid options is deprecated.', implode('", "', $invalidOptions)); } + if (isset($options['prefetch_count'])) { + trigger_deprecation('symfony/messenger', '5.3', 'The "prefetch_count" option passed to the AMQP Messenger transport has no effect and should not be used.'); + } + if (\is_array($options['queues'] ?? false)) { foreach ($options['queues'] as $queue) { if (!\is_array($queue)) { @@ -285,7 +294,7 @@ public function publish(string $body, array $headers = [], int $delayInMs = 0, A return; } - if ($this->shouldSetup()) { + if ($this->autoSetupExchange) { $this->setupExchangeAndQueues(); } @@ -347,7 +356,7 @@ private function publishOnExchange(\AMQPExchange $exchange, string $body, string private function setupDelay(int $delay, ?string $routingKey) { - if ($this->shouldSetup()) { + if ($this->autoSetup) { $this->setup(); // setup delay exchange and normal exchange for delay queue to DLX messages to } @@ -418,23 +427,12 @@ public function get(string $queueName): ?\AMQPEnvelope { $this->clearWhenDisconnected(); - if ($this->shouldSetup()) { + if ($this->autoSetupExchange) { $this->setupExchangeAndQueues(); } - try { - if (false !== $message = $this->queue($queueName)->get()) { - return $message; - } - } catch (\AMQPQueueException $e) { - if (404 === $e->getCode() && $this->shouldSetup()) { - // If we get a 404 for the queue, it means we need to set up the exchange & queue. - $this->setupExchangeAndQueues(); - - return $this->get($queueName); - } - - throw $e; + if (false !== $message = $this->queue($queueName)->get()) { + return $message; } return null; @@ -452,8 +450,11 @@ public function nack(\AMQPEnvelope $message, string $queueName, int $flags = \AM public function setup(): void { - $this->setupExchangeAndQueues(); + if ($this->autoSetupExchange) { + $this->setupExchangeAndQueues(); + } $this->getDelayExchange()->declareExchange(); + $this->autoSetup = false; } private function setupExchangeAndQueues(): void @@ -466,6 +467,7 @@ private function setupExchangeAndQueues(): void $this->queue($queueName)->bind($this->exchangeOptions['name'], $bindingKey, $queueConfig['binding_arguments'] ?? []); } } + $this->autoSetupExchange = false; } /** @@ -493,10 +495,6 @@ public function channel(): \AMQPChannel } $this->amqpChannel = $this->amqpFactory->createChannel($connection); - if (isset($this->connectionOptions['prefetch_count'])) { - $this->amqpChannel->setPrefetchCount($this->connectionOptions['prefetch_count']); - } - if ('' !== ($this->connectionOptions['confirm_timeout'] ?? '')) { $this->amqpChannel->confirmSelect(); $this->amqpChannel->setConfirmCallback( @@ -558,19 +556,6 @@ private function clearWhenDisconnected(): void } } - private function shouldSetup(): bool - { - if (!\array_key_exists('auto_setup', $this->connectionOptions)) { - return true; - } - - if (\in_array($this->connectionOptions['auto_setup'], [false, 'false'], true)) { - return false; - } - - return true; - } - private function getDefaultPublishRoutingKey(): ?string { return $this->exchangeOptions['default_publish_routing_key'] ?? null; diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/composer.json b/src/Symfony/Component/Messenger/Bridge/Amqp/composer.json index ee21fa3097d3a..b5a4f04132187 100644 --- a/src/Symfony/Component/Messenger/Bridge/Amqp/composer.json +++ b/src/Symfony/Component/Messenger/Bridge/Amqp/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1", - "symfony/messenger": "^5.1" + "symfony/messenger": "^5.3" }, "require-dev": { "symfony/event-dispatcher": "^4.4|^5.0", diff --git a/src/Symfony/Component/Messenger/Bridge/Redis/CHANGELOG.md b/src/Symfony/Component/Messenger/Bridge/Redis/CHANGELOG.md index d8c6240fdab92..fa858097328e4 100644 --- a/src/Symfony/Component/Messenger/Bridge/Redis/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/Bridge/Redis/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * Add `rediss://` DSN scheme support for TLS protocol + * Deprecate TLS option, use `rediss://127.0.0.1` instead of `redis://127.0.0.1?tls=1` + 5.2.0 ----- diff --git a/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php b/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php index 554b1f92cd2c7..d9eff1f9a75ef 100644 --- a/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php +++ b/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php @@ -86,6 +86,9 @@ public function testFromDsnWithOptionsAndTrailingSlash() ); } + /** + * @group legacy + */ public function testFromDsnWithTls() { $redis = $this->createMock(\Redis::class); @@ -97,6 +100,9 @@ public function testFromDsnWithTls() Connection::fromDsn('redis://127.0.0.1?tls=1', [], $redis); } + /** + * @group legacy + */ public function testFromDsnWithTlsOption() { $redis = $this->createMock(\Redis::class); @@ -108,6 +114,17 @@ public function testFromDsnWithTlsOption() Connection::fromDsn('redis://127.0.0.1', ['tls' => true], $redis); } + public function testFromDsnWithRedissScheme() + { + $redis = $this->createMock(\Redis::class); + $redis->expects($this->once()) + ->method('connect') + ->with('tls://127.0.0.1', 6379) + ->willReturn(null); + + Connection::fromDsn('rediss://127.0.0.1', [], $redis); + } + public function testFromDsnWithQueryOptions() { $this->assertEquals( diff --git a/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php b/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php index 737b24e66f751..cd4d854ffbbe4 100644 --- a/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php +++ b/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php @@ -119,9 +119,10 @@ public function __construct(array $configuration, array $connectionCredentials = public static function fromDsn(string $dsn, array $redisOptions = [], \Redis $redis = null): self { $url = $dsn; + $scheme = 0 === strpos($dsn, 'rediss:') ? 'rediss' : 'redis'; - if (preg_match('#^redis:///([^:@])+$#', $dsn)) { - $url = str_replace('redis:', 'file:', $dsn); + if (preg_match('#^'.$scheme.':///([^:@])+$#', $dsn)) { + $url = str_replace($scheme.':', 'file:', $dsn); } if (false === $parsedUrl = parse_url($url)) { @@ -164,8 +165,9 @@ public static function fromDsn(string $dsn, array $redisOptions = [], \Redis $re unset($redisOptions['dbindex']); } - $tls = false; + $tls = 'rediss' === $scheme; if (\array_key_exists('tls', $redisOptions)) { + trigger_deprecation('symfony/redis-messenger', '5.3', 'Providing "tls" parameter is deprecated, use "rediss://" DSN scheme instead'); $tls = filter_var($redisOptions['tls'], \FILTER_VALIDATE_BOOLEAN); unset($redisOptions['tls']); } diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md index a2b08cdb68efc..381e88c4b8783 100644 --- a/src/Symfony/Component/Messenger/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +5.3 +--- + + * Add the `RouterContextMiddleware` to restore the original router context when handling a message + * `InMemoryTransport` can perform message serialization through dsn `in-memory://?serialize=true`. + * Added `queues` option to `Worker` to only fetch messages from a specific queue from a receiver implementing `QueueReceiverInterface`. + 5.2.0 ----- diff --git a/src/Symfony/Component/Messenger/Command/ConsumeMessagesCommand.php b/src/Symfony/Component/Messenger/Command/ConsumeMessagesCommand.php index 03320b6f66e15..b2a4faef1f410 100644 --- a/src/Symfony/Component/Messenger/Command/ConsumeMessagesCommand.php +++ b/src/Symfony/Component/Messenger/Command/ConsumeMessagesCommand.php @@ -36,6 +36,7 @@ class ConsumeMessagesCommand extends Command { protected static $defaultName = 'messenger:consume'; + protected static $defaultDescription = 'Consumes messages'; private $routableBus; private $receiverLocator; @@ -70,8 +71,9 @@ protected function configure(): void new InputOption('time-limit', 't', InputOption::VALUE_REQUIRED, 'The time limit in seconds the worker can handle new messages'), new InputOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to sleep before asking for new messages after no messages were found', 1), new InputOption('bus', 'b', InputOption::VALUE_REQUIRED, 'Name of the bus to which received messages should be dispatched (if not passed, bus is determined automatically)'), + new InputOption('queues', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit receivers to only consume from the specified queues'), ]) - ->setDescription('Consumes messages') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command consumes messages and dispatches them to the message bus. @@ -103,6 +105,10 @@ protected function configure(): void messages didn't originate from Messenger: php %command.full_name% --bus=event_bus + +Use the --queues option to limit a receiver to only certain queues (only supported by some receivers): + + php %command.full_name% --queues=fasttrack EOF ) ; @@ -194,9 +200,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $bus = $input->getOption('bus') ? $this->routableBus->getMessageBus($input->getOption('bus')) : $this->routableBus; $worker = new Worker($receivers, $bus, $this->eventDispatcher, $this->logger); - $worker->run([ + $options = [ 'sleep' => $input->getOption('sleep') * 1000000, - ]); + ]; + if ($queues = $input->getOption('queues')) { + $options['queues'] = $queues; + } + $worker->run($options); return 0; } diff --git a/src/Symfony/Component/Messenger/Command/DebugCommand.php b/src/Symfony/Component/Messenger/Command/DebugCommand.php index 31b19d0bff948..047ceaf6ee18d 100644 --- a/src/Symfony/Component/Messenger/Command/DebugCommand.php +++ b/src/Symfony/Component/Messenger/Command/DebugCommand.php @@ -26,6 +26,7 @@ class DebugCommand extends Command { protected static $defaultName = 'debug:messenger'; + protected static $defaultDescription = 'Lists messages you can dispatch using the message buses'; private $mapping; @@ -43,7 +44,7 @@ protected function configure() { $this ->addArgument('bus', InputArgument::OPTIONAL, sprintf('The bus id (one of "%s")', implode('", "', array_keys($this->mapping)))) - ->setDescription('Lists messages you can dispatch using the message buses') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command displays all messages that can be dispatched using the message buses: diff --git a/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php b/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php index 951b7d499ed1b..df21b9bcd4f5a 100644 --- a/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php +++ b/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php @@ -27,6 +27,7 @@ class FailedMessagesRemoveCommand extends AbstractFailedMessagesCommand { protected static $defaultName = 'messenger:failed:remove'; + protected static $defaultDescription = 'Remove given messages from the failure transport'; /** * {@inheritdoc} @@ -39,7 +40,7 @@ protected function configure(): void new InputOption('force', null, InputOption::VALUE_NONE, 'Force the operation without confirmation'), new InputOption('show-messages', null, InputOption::VALUE_NONE, 'Display messages before removing it (if multiple ids are given)'), ]) - ->setDescription('Remove given messages from the failure transport') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% removes given messages that are pending in the failure transport. diff --git a/src/Symfony/Component/Messenger/Command/FailedMessagesRetryCommand.php b/src/Symfony/Component/Messenger/Command/FailedMessagesRetryCommand.php index 87426edd9dbaa..f56eeb3345b4d 100644 --- a/src/Symfony/Component/Messenger/Command/FailedMessagesRetryCommand.php +++ b/src/Symfony/Component/Messenger/Command/FailedMessagesRetryCommand.php @@ -35,6 +35,7 @@ class FailedMessagesRetryCommand extends AbstractFailedMessagesCommand { protected static $defaultName = 'messenger:failed:retry'; + protected static $defaultDescription = 'Retries one or more messages from the failure transport'; private $eventDispatcher; private $messageBus; @@ -59,7 +60,7 @@ protected function configure(): void new InputArgument('id', InputArgument::IS_ARRAY, 'Specific message id(s) to retry'), new InputOption('force', null, InputOption::VALUE_NONE, 'Force action without confirmation'), ]) - ->setDescription('Retries one or more messages from the failure transport') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% retries message in the failure transport. diff --git a/src/Symfony/Component/Messenger/Command/FailedMessagesShowCommand.php b/src/Symfony/Component/Messenger/Command/FailedMessagesShowCommand.php index bf8a72f906367..4a97159cf7539 100644 --- a/src/Symfony/Component/Messenger/Command/FailedMessagesShowCommand.php +++ b/src/Symfony/Component/Messenger/Command/FailedMessagesShowCommand.php @@ -28,6 +28,7 @@ class FailedMessagesShowCommand extends AbstractFailedMessagesCommand { protected static $defaultName = 'messenger:failed:show'; + protected static $defaultDescription = 'Shows one or more messages from the failure transport'; /** * {@inheritdoc} @@ -39,7 +40,7 @@ protected function configure(): void new InputArgument('id', InputArgument::OPTIONAL, 'Specific message id to show'), new InputOption('max', null, InputOption::VALUE_REQUIRED, 'Maximum number of messages to list', 50), ]) - ->setDescription('Shows one or more messages from the failure transport') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% shows message that are pending in the failure transport. diff --git a/src/Symfony/Component/Messenger/Command/SetupTransportsCommand.php b/src/Symfony/Component/Messenger/Command/SetupTransportsCommand.php index 84395892fdf9b..71dd7ab5fd1a8 100644 --- a/src/Symfony/Component/Messenger/Command/SetupTransportsCommand.php +++ b/src/Symfony/Component/Messenger/Command/SetupTransportsCommand.php @@ -25,6 +25,7 @@ class SetupTransportsCommand extends Command { protected static $defaultName = 'messenger:setup-transports'; + protected static $defaultDescription = 'Prepares the required infrastructure for the transport'; private $transportLocator; private $transportNames; @@ -41,7 +42,7 @@ protected function configure() { $this ->addArgument('transport', InputArgument::OPTIONAL, 'Name of the transport to setup', null) - ->setDescription('Prepares the required infrastructure for the transport') + ->setDescription(self::$defaultDescription) ->setHelp(<<%command.name% command setups the transports: diff --git a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php index 7df9b9d2014f0..c861513402b6a 100644 --- a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php +++ b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php @@ -25,6 +25,7 @@ class StopWorkersCommand extends Command { protected static $defaultName = 'messenger:stop-workers'; + protected static $defaultDescription = 'Stops workers after their current message'; private $restartSignalCachePool; @@ -42,7 +43,7 @@ protected function configure(): void { $this ->setDefinition([]) - ->setDescription('Stops workers after their current message') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command sends a signal to stop any messenger:consume processes that are running. diff --git a/src/Symfony/Component/Messenger/EventListener/SendFailedMessageForRetryListener.php b/src/Symfony/Component/Messenger/EventListener/SendFailedMessageForRetryListener.php index ff330cd38c783..bd42cebcc5f14 100644 --- a/src/Symfony/Component/Messenger/EventListener/SendFailedMessageForRetryListener.php +++ b/src/Symfony/Component/Messenger/EventListener/SendFailedMessageForRetryListener.php @@ -12,6 +12,7 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; @@ -70,7 +71,18 @@ public function onMessageFailed(WorkerMessageFailedEvent $event) $delay = $retryStrategy->getWaitingTime($envelope, $throwable); if (null !== $this->logger) { - $this->logger->error('Error thrown while handling message {class}. Sending for retry #{retryCount} using {delay} ms delay. Error: "{error}"', $context + ['retryCount' => $retryCount, 'delay' => $delay, 'error' => $throwable->getMessage(), 'exception' => $throwable]); + $logLevel = LogLevel::ERROR; + if ($throwable instanceof RecoverableExceptionInterface) { + $logLevel = LogLevel::WARNING; + } elseif ($throwable instanceof HandlerFailedException) { + foreach ($throwable->getNestedExceptions() as $nestedException) { + if ($nestedException instanceof RecoverableExceptionInterface) { + $logLevel = LogLevel::WARNING; + break; + } + } + } + $this->logger->log($logLevel, 'Error thrown while handling message {class}. Sending for retry #{retryCount} using {delay} ms delay. Error: "{error}"', $context + ['retryCount' => $retryCount, 'delay' => $delay, 'error' => $throwable->getMessage(), 'exception' => $throwable]); } // add the delay and retry stamp info diff --git a/src/Symfony/Component/Messenger/Exception/HandlerFailedException.php b/src/Symfony/Component/Messenger/Exception/HandlerFailedException.php index e577acd4bd0f8..6ac3169afaaa1 100644 --- a/src/Symfony/Component/Messenger/Exception/HandlerFailedException.php +++ b/src/Symfony/Component/Messenger/Exception/HandlerFailedException.php @@ -25,10 +25,13 @@ public function __construct(Envelope $envelope, array $exceptions) { $firstFailure = current($exceptions); + $message = sprintf('Handling "%s" failed: ', \get_class($envelope->getMessage())); + parent::__construct( - 1 === \count($exceptions) + $message.(1 === \count($exceptions) ? $firstFailure->getMessage() - : sprintf('%d handlers failed. First failure is: "%s"', \count($exceptions), $firstFailure->getMessage()), + : sprintf('%d handlers failed. First failure is: %s', \count($exceptions), $firstFailure->getMessage()) + ), (int) $firstFailure->getCode(), $firstFailure ); diff --git a/src/Symfony/Component/Messenger/Middleware/RouterContextMiddleware.php b/src/Symfony/Component/Messenger/Middleware/RouterContextMiddleware.php new file mode 100644 index 0000000000000..35a381870b858 --- /dev/null +++ b/src/Symfony/Component/Messenger/Middleware/RouterContextMiddleware.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Messenger\Middleware; + +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp; +use Symfony\Component\Messenger\Stamp\RouterContextStamp; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RequestContextAwareInterface; + +/** + * Restore the Router context when processing the message. + * + * @author Jérémy Derussé + */ +class RouterContextMiddleware implements MiddlewareInterface +{ + private $router; + + public function __construct(RequestContextAwareInterface $router) + { + $this->router = $router; + } + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + if (!$envelope->last(ConsumedByWorkerStamp::class) || !$contextStamp = $envelope->last(RouterContextStamp::class)) { + $context = $this->router->getContext(); + $envelope = $envelope->with(new RouterContextStamp( + $context->getBaseUrl(), + $context->getMethod(), + $context->getHost(), + $context->getScheme(), + $context->getHttpPort(), + $context->getHttpsPort(), + $context->getPathInfo(), + $context->getQueryString() + )); + + return $stack->next()->handle($envelope, $stack); + } + + $currentContext = $this->router->getContext(); + + /* @var RouterContextStamp $contextStamp */ + $this->router->setContext(new RequestContext( + $contextStamp->getBaseUrl(), + $contextStamp->getMethod(), + $contextStamp->getHost(), + $contextStamp->getScheme(), + $contextStamp->getHttpPort(), + $contextStamp->getHttpsPort(), + $contextStamp->getPathInfo(), + $contextStamp->getQueryString() + )); + + try { + return $stack->next()->handle($envelope, $stack); + } finally { + $this->router->setContext($currentContext); + } + } +} diff --git a/src/Symfony/Component/Messenger/Stamp/RouterContextStamp.php b/src/Symfony/Component/Messenger/Stamp/RouterContextStamp.php new file mode 100644 index 0000000000000..4906f502533b7 --- /dev/null +++ b/src/Symfony/Component/Messenger/Stamp/RouterContextStamp.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Messenger\Stamp; + +/** + * @author Jérémy Derussé + */ +class RouterContextStamp implements StampInterface +{ + private $baseUrl; + private $method; + private $host; + private $scheme; + private $httpPort; + private $httpsPort; + private $pathInfo; + private $queryString; + + public function __construct(string $baseUrl, string $method, string $host, string $scheme, int $httpPort, int $httpsPort, string $pathInfo, string $queryString) + { + $this->baseUrl = $baseUrl; + $this->method = $method; + $this->host = $host; + $this->scheme = $scheme; + $this->httpPort = $httpPort; + $this->httpsPort = $httpsPort; + $this->pathInfo = $pathInfo; + $this->queryString = $queryString; + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getHost(): string + { + return $this->host; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHttpPort(): int + { + return $this->httpPort; + } + + public function getHttpsPort(): int + { + return $this->httpsPort; + } + + public function getPathInfo(): string + { + return $this->pathInfo; + } + + public function getQueryString(): string + { + return $this->queryString; + } +} diff --git a/src/Symfony/Component/Messenger/Tests/Middleware/RouterContextMiddlewareTest.php b/src/Symfony/Component/Messenger/Tests/Middleware/RouterContextMiddlewareTest.php new file mode 100644 index 0000000000000..a183cd03fb6ab --- /dev/null +++ b/src/Symfony/Component/Messenger/Tests/Middleware/RouterContextMiddlewareTest.php @@ -0,0 +1,63 @@ +createMock(RequestContextAwareInterface::class); + $router + ->expects($this->once()) + ->method('getContext') + ->willReturn($context); + + $middleware = new RouterContextMiddleware($router); + + $envelope = new Envelope(new \stdClass()); + $envelope = $middleware->handle($envelope, $this->getStackMock()); + + $this->assertNotNull($stamp = $envelope->last(RouterContextStamp::class)); + $this->assertSame('symfony.com', $stamp->getHost()); + } + + public function testMiddlewareRestoreContext() + { + $router = $this->createMock(RequestContextAwareInterface::class); + $originalContext = new RequestContext(); + + $router + ->expects($this->once()) + ->method('getContext') + ->willReturn($originalContext); + + $router + ->expects($this->exactly(2)) + ->method('setContext') + ->withConsecutive( + [$this->callback(function ($context) { + $this->assertSame('symfony.com', $context->getHost()); + + return true; + })], + [$originalContext] + ); + + $middleware = new RouterContextMiddleware($router); + $envelope = new Envelope(new \stdClass(), [ + new ConsumedByWorkerStamp(), + new RouterContextStamp('', 'GET', 'symfony.com', 'https', 80, 443, '/', ''), + ]); + $middleware->handle($envelope, $this->getStackMock()); + } +} diff --git a/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportFactoryTest.php b/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportFactoryTest.php index 6fe95025cd583..adb089efaa533 100644 --- a/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportFactoryTest.php +++ b/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportFactoryTest.php @@ -49,6 +49,35 @@ public function testCreateTransport() $this->assertInstanceOf(InMemoryTransport::class, $this->factory->createTransport('in-memory://', [], $serializer)); } + public function testCreateTransportWithoutSerializer() + { + /** @var SerializerInterface $serializer */ + $serializer = $this->createMock(SerializerInterface::class); + $serializer + ->expects($this->never()) + ->method('encode') + ; + $transport = $this->factory->createTransport('in-memory://?serialize=false', [], $serializer); + $message = Envelope::wrap(new DummyMessage('Hello.')); + $transport->send($message); + + $this->assertSame([$message], $transport->get()); + } + + public function testCreateTransportWithSerializer() + { + /** @var SerializerInterface $serializer */ + $serializer = $this->createMock(SerializerInterface::class); + $message = Envelope::wrap(new DummyMessage('Hello.')); + $serializer + ->expects($this->once()) + ->method('encode') + ->with($this->equalTo($message)) + ; + $transport = $this->factory->createTransport('in-memory://?serialize=true', [], $serializer); + $transport->send($message); + } + public function testResetCreatedTransports() { $transport = $this->factory->createTransport('in-memory://', [], $this->createMock(SerializerInterface::class)); @@ -63,6 +92,8 @@ public function provideDSN(): array { return [ 'Supported' => ['in-memory://foo'], + 'Serialize enabled' => ['in-memory://?serialize=true'], + 'Serialize disabled' => ['in-memory://?serialize=false'], 'Unsupported' => ['amqp://bar', false], ]; } diff --git a/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportTest.php b/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportTest.php index 6fddc3fbbc3e5..733eeb97714c7 100644 --- a/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportTest.php +++ b/src/Symfony/Component/Messenger/Tests/Transport/InMemoryTransportTest.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Tests\Fixtures\AnEnvelopeStamp; +use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage; use Symfony\Component\Messenger\Transport\InMemoryTransport; +use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; /** * @author Gary PEGEOT @@ -26,9 +28,21 @@ class InMemoryTransportTest extends TestCase */ private $transport; + /** + * @var InMemoryTransport + */ + private $serializeTransport; + + /** + * @var SerializerInterface + */ + private $serializer; + protected function setUp(): void { + $this->serializer = $this->createMock(SerializerInterface::class); $this->transport = new InMemoryTransport(); + $this->serializeTransport = new InMemoryTransport($this->serializer); } public function testSend() @@ -38,6 +52,24 @@ public function testSend() $this->assertSame([$envelope], $this->transport->getSent()); } + public function testSendWithSerialization() + { + $envelope = new Envelope(new \stdClass()); + $envelopeDecoded = Envelope::wrap(new DummyMessage('Hello.')); + $this->serializer + ->method('encode') + ->with($this->equalTo($envelope)) + ->willReturn(['foo' => 'ba']) + ; + $this->serializer + ->method('decode') + ->with(['foo' => 'ba']) + ->willReturn($envelopeDecoded) + ; + $this->serializeTransport->send($envelope); + $this->assertSame([$envelopeDecoded], $this->serializeTransport->getSent()); + } + public function testQueue() { $envelope1 = new Envelope(new \stdClass()); @@ -51,6 +83,24 @@ public function testQueue() $this->assertSame([], $this->transport->get()); } + public function testQueueWithSerialization() + { + $envelope = new Envelope(new \stdClass()); + $envelopeDecoded = Envelope::wrap(new DummyMessage('Hello.')); + $this->serializer + ->method('encode') + ->with($this->equalTo($envelope)) + ->willReturn(['foo' => 'ba']) + ; + $this->serializer + ->method('decode') + ->with(['foo' => 'ba']) + ->willReturn($envelopeDecoded) + ; + $this->serializeTransport->send($envelope); + $this->assertSame([$envelopeDecoded], $this->serializeTransport->get()); + } + public function testAcknowledgeSameMessageWithDifferentStamps() { $envelope1 = new Envelope(new \stdClass(), [new AnEnvelopeStamp()]); @@ -71,6 +121,24 @@ public function testAck() $this->assertSame([$envelope], $this->transport->getAcknowledged()); } + public function testAckWithSerialization() + { + $envelope = new Envelope(new \stdClass()); + $envelopeDecoded = Envelope::wrap(new DummyMessage('Hello.')); + $this->serializer + ->method('encode') + ->with($this->equalTo($envelope)) + ->willReturn(['foo' => 'ba']) + ; + $this->serializer + ->method('decode') + ->with(['foo' => 'ba']) + ->willReturn($envelopeDecoded) + ; + $this->serializeTransport->ack($envelope); + $this->assertSame([$envelopeDecoded], $this->serializeTransport->getAcknowledged()); + } + public function testReject() { $envelope = new Envelope(new \stdClass()); @@ -78,6 +146,24 @@ public function testReject() $this->assertSame([$envelope], $this->transport->getRejected()); } + public function testRejectWithSerialization() + { + $envelope = new Envelope(new \stdClass()); + $envelopeDecoded = Envelope::wrap(new DummyMessage('Hello.')); + $this->serializer + ->method('encode') + ->with($this->equalTo($envelope)) + ->willReturn(['foo' => 'ba']) + ; + $this->serializer + ->method('decode') + ->with(['foo' => 'ba']) + ->willReturn($envelopeDecoded) + ; + $this->serializeTransport->reject($envelope); + $this->assertSame([$envelopeDecoded], $this->serializeTransport->getRejected()); + } + public function testReset() { $envelope = new Envelope(new \stdClass()); diff --git a/src/Symfony/Component/Messenger/Tests/WorkerTest.php b/src/Symfony/Component/Messenger/Tests/WorkerTest.php index c105cdad5348c..e47974610b997 100644 --- a/src/Symfony/Component/Messenger/Tests/WorkerTest.php +++ b/src/Symfony/Component/Messenger/Tests/WorkerTest.php @@ -21,12 +21,14 @@ use Symfony\Component\Messenger\Event\WorkerStartedEvent; use Symfony\Component\Messenger\Event\WorkerStoppedEvent; use Symfony\Component\Messenger\EventListener\StopWorkerOnMessageLimitListener; +use Symfony\Component\Messenger\Exception\RuntimeException; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp; use Symfony\Component\Messenger\Stamp\ReceivedStamp; use Symfony\Component\Messenger\Stamp\SentStamp; use Symfony\Component\Messenger\Stamp\StampInterface; use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage; +use Symfony\Component\Messenger\Transport\Receiver\QueueReceiverInterface; use Symfony\Component\Messenger\Transport\Receiver\ReceiverInterface; use Symfony\Component\Messenger\Worker; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -245,6 +247,41 @@ public function testWorkerWithMultipleReceivers() $this->assertSame([$envelope1, $envelope2, $envelope3, $envelope4, $envelope5, $envelope6], $processedEnvelopes); } + public function testWorkerLimitQueues() + { + $envelope = [new Envelope(new DummyMessage('message1'))]; + $receiver = $this->createMock(QueueReceiverInterface::class); + $receiver->expects($this->once()) + ->method('getFromQueues') + ->with(['foo']) + ->willReturn($envelope) + ; + $receiver->expects($this->never()) + ->method('get') + ; + + $bus = $this->getMockBuilder(MessageBusInterface::class)->getMock(); + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1)); + + $worker = new Worker(['transport' => $receiver], $bus, $dispatcher); + $worker->run(['queues' => ['foo']]); + } + + public function testWorkerLimitQueuesUnsupported() + { + $receiver1 = $this->createMock(QueueReceiverInterface::class); + $receiver2 = $this->createMock(ReceiverInterface::class); + + $bus = $this->getMockBuilder(MessageBusInterface::class)->getMock(); + + $worker = new Worker(['transport1' => $receiver1, 'transport2' => $receiver2], $bus); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Receiver for "transport2" does not implement "%s".', QueueReceiverInterface::class)); + $worker->run(['queues' => ['foo']]); + } + public function testWorkerMessageReceivedEventMutability() { $envelope = new Envelope(new DummyMessage('Hello')); diff --git a/src/Symfony/Component/Messenger/Transport/InMemoryTransport.php b/src/Symfony/Component/Messenger/Transport/InMemoryTransport.php index 09cbb31a041fd..75a0b445e4759 100644 --- a/src/Symfony/Component/Messenger/Transport/InMemoryTransport.php +++ b/src/Symfony/Component/Messenger/Transport/InMemoryTransport.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Messenger\Transport; use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Contracts\Service\ResetInterface; /** @@ -41,12 +42,22 @@ class InMemoryTransport implements TransportInterface, ResetInterface */ private $queue = []; + /** + * @var SerializerInterface|null + */ + private $serializer; + + public function __construct(SerializerInterface $serializer = null) + { + $this->serializer = $serializer; + } + /** * {@inheritdoc} */ public function get(): iterable { - return array_values($this->queue); + return array_values($this->decode($this->queue)); } /** @@ -54,7 +65,7 @@ public function get(): iterable */ public function ack(Envelope $envelope): void { - $this->acknowledged[] = $envelope; + $this->acknowledged[] = $this->encode($envelope); $id = spl_object_hash($envelope->getMessage()); unset($this->queue[$id]); } @@ -64,7 +75,7 @@ public function ack(Envelope $envelope): void */ public function reject(Envelope $envelope): void { - $this->rejected[] = $envelope; + $this->rejected[] = $this->encode($envelope); $id = spl_object_hash($envelope->getMessage()); unset($this->queue[$id]); } @@ -74,9 +85,10 @@ public function reject(Envelope $envelope): void */ public function send(Envelope $envelope): Envelope { - $this->sent[] = $envelope; + $encodedEnvelope = $this->encode($envelope); + $this->sent[] = $encodedEnvelope; $id = spl_object_hash($envelope->getMessage()); - $this->queue[$id] = $envelope; + $this->queue[$id] = $encodedEnvelope; return $envelope; } @@ -91,7 +103,7 @@ public function reset() */ public function getAcknowledged(): array { - return $this->acknowledged; + return $this->decode($this->acknowledged); } /** @@ -99,7 +111,7 @@ public function getAcknowledged(): array */ public function getRejected(): array { - return $this->rejected; + return $this->decode($this->rejected); } /** @@ -107,6 +119,35 @@ public function getRejected(): array */ public function getSent(): array { - return $this->sent; + return $this->decode($this->sent); + } + + /** + * @return Envelope|array + */ + private function encode(Envelope $envelope) + { + if (null === $this->serializer) { + return $envelope; + } + + return $this->serializer->encode($envelope); + } + + /** + * @param array $messagesEncoded + * + * @return Envelope[] + */ + private function decode(array $messagesEncoded): array + { + if (null === $this->serializer) { + return $messagesEncoded; + } + + return array_map( + [$this->serializer, 'decode'], + $messagesEncoded + ); } } diff --git a/src/Symfony/Component/Messenger/Transport/InMemoryTransportFactory.php b/src/Symfony/Component/Messenger/Transport/InMemoryTransportFactory.php index 597107341a977..5da5d5d046945 100644 --- a/src/Symfony/Component/Messenger/Transport/InMemoryTransportFactory.php +++ b/src/Symfony/Component/Messenger/Transport/InMemoryTransportFactory.php @@ -26,7 +26,9 @@ class InMemoryTransportFactory implements TransportFactoryInterface, ResetInterf public function createTransport(string $dsn, array $options, SerializerInterface $serializer): TransportInterface { - return $this->createdTransports[] = new InMemoryTransport(); + ['serialize' => $serialize] = $this->parseDsn($dsn); + + return $this->createdTransports[] = new InMemoryTransport($serialize ? $serializer : null); } public function supports(string $dsn, array $options): bool @@ -40,4 +42,16 @@ public function reset() $transport->reset(); } } + + private function parseDsn(string $dsn): array + { + $query = []; + if ($queryAsString = strstr($dsn, '?')) { + parse_str(ltrim($queryAsString, '?'), $query); + } + + return [ + 'serialize' => filter_var($query['serialize'] ?? false, \FILTER_VALIDATE_BOOLEAN), + ]; + } } diff --git a/src/Symfony/Component/Messenger/Transport/Receiver/QueueReceiverInterface.php b/src/Symfony/Component/Messenger/Transport/Receiver/QueueReceiverInterface.php new file mode 100644 index 0000000000000..0248ac621c453 --- /dev/null +++ b/src/Symfony/Component/Messenger/Transport/Receiver/QueueReceiverInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Messenger\Transport\Receiver; + +use Symfony\Component\Messenger\Envelope; + +/** + * Some transports may have multiple queues. This interface is used to read from only some queues. + * + * @author David Buchmann + * + * @experimental in 5.3 + */ +interface QueueReceiverInterface extends ReceiverInterface +{ + /** + * Get messages from the specified queue names instead of consuming from all queues. + * + * @param string[] $queueNames + * + * @return Envelope[] + */ + public function getFromQueues(array $queueNames): iterable; +} diff --git a/src/Symfony/Component/Messenger/Transport/TransportFactory.php b/src/Symfony/Component/Messenger/Transport/TransportFactory.php index 37e7b114bbd10..ee57dd5adff7a 100644 --- a/src/Symfony/Component/Messenger/Transport/TransportFactory.php +++ b/src/Symfony/Component/Messenger/Transport/TransportFactory.php @@ -43,7 +43,7 @@ public function createTransport(string $dsn, array $options, SerializerInterface $packageSuggestion = ' Run "composer require symfony/amqp-messenger" to install AMQP transport.'; } elseif (0 === strpos($dsn, 'doctrine://')) { $packageSuggestion = ' Run "composer require symfony/doctrine-messenger" to install Doctrine transport.'; - } elseif (0 === strpos($dsn, 'redis://')) { + } elseif (0 === strpos($dsn, 'redis://') || 0 === strpos($dsn, 'rediss://')) { $packageSuggestion = ' Run "composer require symfony/redis-messenger" to install Redis transport.'; } elseif (0 === strpos($dsn, 'sqs://') || preg_match('#^https://sqs\.[\w\-]+\.amazonaws\.com/.+#', $dsn)) { $packageSuggestion = ' Run "composer require symfony/amazon-sqs-messenger" to install Amazon SQS transport.'; diff --git a/src/Symfony/Component/Messenger/Worker.php b/src/Symfony/Component/Messenger/Worker.php index 6f8d34b1cb2cf..f13edcc2f5a05 100644 --- a/src/Symfony/Component/Messenger/Worker.php +++ b/src/Symfony/Component/Messenger/Worker.php @@ -22,8 +22,10 @@ use Symfony\Component\Messenger\Event\WorkerStoppedEvent; use Symfony\Component\Messenger\Exception\HandlerFailedException; use Symfony\Component\Messenger\Exception\RejectRedeliveredMessageException; +use Symfony\Component\Messenger\Exception\RuntimeException; use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp; use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Messenger\Transport\Receiver\QueueReceiverInterface; use Symfony\Component\Messenger\Transport\Receiver\ReceiverInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -57,6 +59,7 @@ public function __construct(array $receivers, MessageBusInterface $bus, EventDis * * Valid options are: * * sleep (default: 1000000): Time in microseconds to sleep after no messages are found + * * queues: The queue names to consume from, instead of consuming from all queues. When this is used, all receivers must implement the QueueReceiverInterface */ public function run(array $options = []): void { @@ -65,11 +68,25 @@ public function run(array $options = []): void $options = array_merge([ 'sleep' => 1000000, ], $options); + $queueNames = $options['queues'] ?? false; + + if ($queueNames) { + // if queue names are specified, all receivers must implement the QueueReceiverInterface + foreach ($this->receivers as $transportName => $receiver) { + if (!$receiver instanceof QueueReceiverInterface) { + throw new RuntimeException(sprintf('Receiver for "%s" does not implement "%s".', $transportName, QueueReceiverInterface::class)); + } + } + } while (false === $this->shouldStop) { $envelopeHandled = false; foreach ($this->receivers as $transportName => $receiver) { - $envelopes = $receiver->get(); + if ($queueNames) { + $envelopes = $receiver->getFromQueues($queueNames); + } else { + $envelopes = $receiver->get(); + } foreach ($envelopes as $envelope) { $envelopeHandled = true; diff --git a/src/Symfony/Component/Messenger/composer.json b/src/Symfony/Component/Messenger/composer.json index 704583c0df4ce..0a5593cc7c694 100644 --- a/src/Symfony/Component/Messenger/composer.json +++ b/src/Symfony/Component/Messenger/composer.json @@ -32,6 +32,7 @@ "symfony/http-kernel": "^4.4|^5.0", "symfony/process": "^4.4|^5.0", "symfony/property-access": "^4.4|^5.0", + "symfony/routing": "^4.4|^5.0", "symfony/serializer": "^4.4|^5.0", "symfony/service-contracts": "^1.1|^2", "symfony/stopwatch": "^4.4|^5.0", diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/.gitattributes b/src/Symfony/Component/Notifier/Bridge/AllMySms/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/AllMySmsTransport.php b/src/Symfony/Component/Notifier/Bridge/AllMySms/AllMySmsTransport.php new file mode 100644 index 0000000000000..524ff0ce3db08 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/AllMySmsTransport.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\AllMySms; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Quentin Dequippe + */ +final class AllMySmsTransport extends AbstractTransport +{ + protected const HOST = 'api.allmysms.com'; + + private $login; + private $apiKey; + private $from; + + public function __construct(string $login, string $apiKey, string $from = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->login = $login; + $this->apiKey = $apiKey; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + if (null !== $this->from) { + return sprintf('allmysms://%s?from=%s', $this->getEndpoint(), $this->from); + } + + return sprintf('allmysms://%s', $this->getEndpoint()); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $endpoint = sprintf('https://%s/sms/send/', $this->getEndpoint()); + $response = $this->client->request('POST', $endpoint, [ + 'auth_basic' => $this->login.':'.$this->apiKey, + 'body' => [ + 'from' => $this->from, + 'to' => $message->getPhone(), + 'text' => $message->getSubject(), + ], + ]); + + if (201 !== $response->getStatusCode()) { + $error = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send the SMS: "%s" (%s).', $error['description'], $error['code']), $response); + } + + $success = $response->toArray(false); + + if (false === isset($success['smsId'])) { + throw new TransportException(sprintf('Unable to send the SMS: "%s" (%s).', $success['description'], $success['code']), $response); + } + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($success['smsId']); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/AllMySmsTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/AllMySms/AllMySmsTransportFactory.php new file mode 100644 index 0000000000000..a9d06ff8a91b1 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/AllMySmsTransportFactory.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\AllMySms; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Quentin Dequippe + */ +final class AllMySmsTransportFactory extends AbstractTransportFactory +{ + /** + * @return AllMySmsTransport + */ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('allmysms' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'allmysms', $this->getSupportedSchemes()); + } + + $login = $this->getUser($dsn); + $apiKey = $this->getPassword($dsn); + $from = $dsn->getOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new AllMySmsTransport($login, $apiKey, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['allmysms']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/AllMySms/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE b/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/AllMySms/README.md b/src/Symfony/Component/Notifier/Bridge/AllMySms/README.md new file mode 100644 index 0000000000000..0a3beb9313d06 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/README.md @@ -0,0 +1,24 @@ +AllMySms Notifier +================= + +Provides [AllMySms](https://www.allmysms.com/) integration for Symfony Notifier. + +DSN example +----------- + +``` +ALLMYSMS_DSN=allmysms://LOGIN:APIKEY@default?from=FROM +``` + +where: +- `LOGIN` is your user ID +- `APIKEY` is your AllMySms API key +- `FROM` is your sender (optional, default: 36180) + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/Tests/AllMySmsTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/AllMySms/Tests/AllMySmsTransportFactoryTest.php new file mode 100644 index 0000000000000..5f8d5a7b07665 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/Tests/AllMySmsTransportFactoryTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\AllMySms\Tests; + +use Symfony\Component\Notifier\Bridge\AllMySms\AllMySmsTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +final class AllMySmsTransportFactoryTest extends TransportFactoryTestCase +{ + /** + * @return AllMySmsTransportFactory + */ + public function createFactory(): TransportFactoryInterface + { + return new AllMySmsTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'allmysms://host.test', + 'allmysms://login:apiKey@host.test', + ]; + + yield [ + 'allmysms://host.test?from=TEST', + 'allmysms://login:apiKey@host.test?from=TEST', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'allmysms://login:apiKey@default']; + yield [false, 'somethingElse://login:apiKey@default']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://login:apiKey@default']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/Tests/AllMySmsTransportTest.php b/src/Symfony/Component/Notifier/Bridge/AllMySms/Tests/AllMySmsTransportTest.php new file mode 100644 index 0000000000000..8e4873bf2443e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/Tests/AllMySmsTransportTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\AllMySms\Tests; + +use Symfony\Component\Notifier\Bridge\AllMySms\AllMySmsTransport; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class AllMySmsTransportTest extends TransportTestCase +{ + /** + * @return AllMySmsTransport + */ + public function createTransport(?HttpClientInterface $client = null, string $from = null): TransportInterface + { + return new AllMySmsTransport('login', 'apiKey', $from, $client ?: $this->createMock(HttpClientInterface::class)); + } + + public function toStringProvider(): iterable + { + yield ['allmysms://api.allmysms.com', $this->createTransport()]; + yield ['allmysms://api.allmysms.com?from=TEST', $this->createTransport(null, 'TEST')]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/composer.json b/src/Symfony/Component/Notifier/Bridge/AllMySms/composer.json new file mode 100644 index 0000000000000..79704353acf41 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/allmysms-notifier", + "type": "symfony-bridge", + "description": "Symfony AllMySms Notifier Bridge", + "keywords": ["sms", "allMySms", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Quentin Dequippe", + "email": "quentin@dequippe.tech" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\AllMySms\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/AllMySms/phpunit.xml.dist new file mode 100644 index 0000000000000..91d190f562055 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Clickatell/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/ClickatellTransport.php b/src/Symfony/Component/Notifier/Bridge/Clickatell/ClickatellTransport.php new file mode 100644 index 0000000000000..5584e9d3ba7f8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/ClickatellTransport.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Clickatell; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Kevin Auvinet + */ +final class ClickatellTransport extends AbstractTransport +{ + protected const HOST = 'api.clickatell.com'; + + private $authToken; + private $from; + + public function __construct(string $authToken, string $from = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->authToken = $authToken; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + if (null === $this->from) { + return sprintf('clickatell://%s', $this->getEndpoint()); + } + + return sprintf('clickatell://%s?from=%s', $this->getEndpoint(), $this->from); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $endpoint = sprintf('https://%s/rest/message', $this->getEndpoint()); + + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$this->authToken, + 'Content-Type' => 'application/json', + 'X-Version' => 1, + ], + 'json' => [ + 'from' => $this->from ?? '', + 'to' => [$message->getPhone()], + 'text' => $message->getSubject(), + ], + ]); + + if (202 === $response->getStatusCode()) { + $result = $response->toArray(); + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($result['data']['message'][0]['apiMessageId']); + + return $sentMessage; + } + + $content = $response->toArray(false); + $errorCode = $content['error']['code'] ?? ''; + $errorInfo = $content['error']['description'] ?? ''; + $errorDocumentation = $content['error']['documentation'] ?? ''; + + throw new TransportException(sprintf('Unable to send SMS with Clickatell: Error code %d with message "%s" (%s).', $errorCode, $errorInfo, $errorDocumentation), $response); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/ClickatellTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Clickatell/ClickatellTransportFactory.php new file mode 100644 index 0000000000000..f280233c4c625 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/ClickatellTransportFactory.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Clickatell; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Kevin Auvinet + */ +final class ClickatellTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('clickatell' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'clickatell', $this->getSupportedSchemes()); + } + + $authToken = $this->getUser($dsn); + $from = $dsn->getOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new ClickatellTransport($authToken, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['clickatell']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE b/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/Clickatell/README.md b/src/Symfony/Component/Notifier/Bridge/Clickatell/README.md new file mode 100644 index 0000000000000..ff5eb95c4f4d2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/README.md @@ -0,0 +1,23 @@ +Clickatell Notifier +=================== + +Provides [Clickatell](https://www.clickatell.com) integration for Symfony Notifier. + +DSN example +----------- + +``` +CLICKATELL_DSN=clickatell://ACCESS_TOKEN@default?from=FROM +``` + +where: + - `ACCESS_TOKEN` is your Clickatell auth access token + - `FROM` is the sender + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/Tests/ClickatellTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Clickatell/Tests/ClickatellTransportFactoryTest.php new file mode 100644 index 0000000000000..0ad7cb7ec22ca --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/Tests/ClickatellTransportFactoryTest.php @@ -0,0 +1,43 @@ + ['clickatell://host?from=FROM']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://authtoken@default?from=FROM']; + yield ['somethingElse://authtoken@default']; // missing "from" option + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/Tests/ClickatellTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Clickatell/Tests/ClickatellTransportTest.php new file mode 100644 index 0000000000000..c3d2db0b6f30c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/Tests/ClickatellTransportTest.php @@ -0,0 +1,65 @@ +createMock(HttpClientInterface::class)); + $transport->setHost('clickatellHost'); + + $this->assertSame('clickatell://clickatellHost?from=fromValue', (string) $transport); + } + + public function testSupports() + { + $transport = new ClickatellTransport('authToken', 'fromValue', $this->createMock(HttpClientInterface::class)); + + $this->assertTrue($transport->supports(new SmsMessage('+33612345678', 'testSmsMessage'))); + $this->assertFalse($transport->supports($this->createMock(MessageInterface::class))); + } + + public function testExceptionIsThrownWhenNonMessageIsSend() + { + $transport = new ClickatellTransport('authToken', 'fromValue', $this->createMock(HttpClientInterface::class)); + + $this->expectException(LogicException::class); + $transport->send($this->createMock(MessageInterface::class)); + } + + public function testExceptionIsThrownWhenHttpSendFailed() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(500); + $response->expects($this->once()) + ->method('getContent') + ->willReturn(json_encode([ + 'error' => [ + 'code' => 105, + 'description' => 'Invalid Account Reference EX0000000', + 'documentation' => 'https://documentation-page', + ], + ])); + + $client = new MockHttpClient($response); + + $transport = new ClickatellTransport('authToken', 'fromValue', $client); + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Unable to send SMS with Clickatell: Error code 105 with message "Invalid Account Reference EX0000000" (https://documentation-page).'); + + $transport->send(new SmsMessage('+33612345678', 'testSmsMessage')); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/composer.json b/src/Symfony/Component/Notifier/Bridge/Clickatell/composer.json new file mode 100644 index 0000000000000..404589753a52b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/clickatell-notifier", + "type": "symfony-bridge", + "description": "Symfony Clickatell Notifier Bridge", + "keywords": ["sms", "clickatell", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Kevin Auvinet", + "email": "k.auvinet@gmail.com" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.3" + }, + "require-dev": { + "symfony/event-dispatcher": "^4.3|^5.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Clickatell\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Clickatell/phpunit.xml.dist new file mode 100644 index 0000000000000..8becf1513481a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md index 0d994e934e55a..91b7e5fb62ef8 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php index ac591ea67c464..8cc74e3b50b41 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php @@ -17,8 +17,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ final class DiscordOptions implements MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransport.php b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransport.php index 61b5442c07555..f4c223e1eb1b9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransport.php @@ -11,8 +11,9 @@ namespace Symfony\Component\Notifier\Bridge\Discord; -use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\LengthException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -22,8 +23,6 @@ /** * @author Mathieu Piot - * - * @experimental in 5.2 */ final class DiscordTransport extends AbstractTransport { @@ -59,7 +58,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } $messageOptions = $message->getOptions(); @@ -68,7 +67,7 @@ protected function doSend(MessageInterface $message): SentMessage $content = $message->getSubject(); if (mb_strlen($content, 'UTF-8') > self::SUBJECT_LIMIT) { - throw new LogicException(sprintf('The subject length of a Discord message must not exceed %d characters.', self::SUBJECT_LIMIT)); + throw new LengthException(sprintf('The subject length of a Discord message must not exceed %d characters.', self::SUBJECT_LIMIT)); } $endpoint = sprintf('https://%s/api/webhooks/%s/%s', $this->getEndpoint(), $this->webhookId, $this->token); diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php index a192832f56552..9cdefdd4b3c8f 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Discord; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Mathieu Piot - * - * @experimental in 5.2 */ final class DiscordTransportFactory extends AbstractTransportFactory { @@ -36,12 +33,7 @@ public function create(Dsn $dsn): TransportInterface } $token = $this->getUser($dsn); - $webhookId = $dsn->getOption('webhook_id'); - - if (!$webhookId) { - throw new IncompleteDsnException('Missing webhook_id.', $dsn->getOriginalDsn()); - } - + $webhookId = $dsn->getRequiredOption('webhook_id'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbed.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbed.php index db4fa581dd78b..57ef5a9c9ade0 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbed.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbed.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ abstract class AbstractDiscordEmbed implements DiscordEmbedInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbedObject.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbedObject.php index 12c5d5ee2b0ef..6fa50370380e7 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbedObject.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/AbstractDiscordEmbedObject.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ abstract class AbstractDiscordEmbedObject implements DiscordEmbedObjectInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordAuthorEmbedObject.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordAuthorEmbedObject.php index c357e61edd5ea..04987ef9f75c8 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordAuthorEmbedObject.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordAuthorEmbedObject.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ final class DiscordAuthorEmbedObject extends AbstractDiscordEmbedObject { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbed.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbed.php index c2565a721f678..71c2e783a4148 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbed.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbed.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ final class DiscordEmbed extends AbstractDiscordEmbed { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbedObjectInterface.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbedObjectInterface.php index ce1abdeb3526f..3baac40bc71cc 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbedObjectInterface.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordEmbedObjectInterface.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ interface DiscordEmbedObjectInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFieldEmbedObject.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFieldEmbedObject.php index ac8b215bb6e8b..01761080ba52d 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFieldEmbedObject.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFieldEmbedObject.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ final class DiscordFieldEmbedObject extends AbstractDiscordEmbedObject { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFooterEmbedObject.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFooterEmbedObject.php index d2dfa0b56c3b7..42320e35169c9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFooterEmbedObject.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordFooterEmbedObject.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ final class DiscordFooterEmbedObject extends AbstractDiscordEmbedObject { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordMediaEmbedObject.php b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordMediaEmbedObject.php index a2e4437783c2a..fdddf21ed2388 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordMediaEmbedObject.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Embeds/DiscordMediaEmbedObject.php @@ -13,8 +13,6 @@ /** * @author Karoly Gossler - * - * @experimental in 5.2 */ class DiscordMediaEmbedObject extends AbstractDiscordEmbedObject { diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php index 48d620d49b525..a6e5f786aa22c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php @@ -42,6 +42,10 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { yield 'missing token' => ['discord://host.test?webhook_id=testWebhookId']; + } + + public function missingRequiredOptionProvider(): iterable + { yield 'missing option: webhook_id' => ['discord://token@host']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportTest.php index 70dd0f46eaac8..88c8fd617b258 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportTest.php @@ -13,7 +13,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransport; -use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\LengthException; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; @@ -53,7 +53,7 @@ public function testSendChatMessageWithMoreThan2000CharsThrowsLogicException() { $transport = $this->createTransport(); - $this->expectException(LogicException::class); + $this->expectException(LengthException::class); $this->expectExceptionMessage('The subject length of a Discord message must not exceed 2000 characters.'); $transport->send(new ChatMessage(str_repeat('囍', 2001))); diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/composer.json b/src/Symfony/Component/Notifier/Bridge/Discord/composer.json index 67b9c03270134..1e4d1254a8560 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Discord/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2", + "symfony/notifier": "^5.3", "symfony/polyfill-mbstring": "^1.0" }, "require-dev": { diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Esendex/CHANGELOG.md index 0d994e934e55a..af024be8e4364 100644 --- a/src/Symfony/Component/Notifier/Bridge/Esendex/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Esendex/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * [BC BREAK] Change signature of `EsendexTransport::__construct()` method from: + `public function __construct(string $token, string $accountReference, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)` + to: + `public function __construct(string $email, string $password, string $accountReference, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)` + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransport.php b/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransport.php index e4a944779e136..7be72d8264849 100644 --- a/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransport.php @@ -13,8 +13,8 @@ use Symfony\Component\HttpClient\Exception\JsonException; use Symfony\Component\HttpClient\Exception\TransportException as HttpClientTransportException; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,20 +22,19 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -/** - * @experimental in 5.2 - */ final class EsendexTransport extends AbstractTransport { protected const HOST = 'api.esendex.com'; - private $token; + private $email; + private $password; private $accountReference; private $from; - public function __construct(string $token, string $accountReference, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + public function __construct(string $email, string $password, string $accountReference, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) { - $this->token = $token; + $this->email = $email; + $this->password = $password; $this->accountReference = $accountReference; $this->from = $from; @@ -55,7 +54,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $messageData = [ @@ -68,18 +67,20 @@ protected function doSend(MessageInterface $message): SentMessage } $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/v1.0/messagedispatcher', [ - 'auth_basic' => $this->token, + 'auth_basic' => sprintf('%s:%s', $this->email, $this->password), 'json' => [ 'accountreference' => $this->accountReference, 'messages' => [$messageData], ], ]); - if (200 === $response->getStatusCode()) { + $statusCode = $response->getStatusCode(); + + if (200 === $statusCode) { return new SentMessage($message, (string) $this); } - $message = sprintf('Unable to send the SMS: error %d.', $response->getStatusCode()); + $message = sprintf('Unable to send the SMS: error %d.', $statusCode); try { $result = $response->toArray(false); diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransportFactory.php index 2edf0788b3fbb..57d3a6237e9f9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Esendex/EsendexTransportFactory.php @@ -11,15 +11,11 @@ namespace Symfony\Component\Notifier\Bridge\Esendex; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; use Symfony\Component\Notifier\Transport\TransportInterface; -/** - * @experimental in 5.2 - */ final class EsendexTransportFactory extends AbstractTransportFactory { /** @@ -33,23 +29,14 @@ public function create(Dsn $dsn): TransportInterface throw new UnsupportedSchemeException($dsn, 'esendex', $this->getSupportedSchemes()); } - $token = $this->getUser($dsn).':'.$this->getPassword($dsn); - $accountReference = $dsn->getOption('accountreference'); - - if (!$accountReference) { - throw new IncompleteDsnException('Missing accountreference.', $dsn->getOriginalDsn()); - } - - $from = $dsn->getOption('from'); - - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - + $email = $this->getUser($dsn); + $password = $this->getPassword($dsn); + $accountReference = $dsn->getRequiredOption('accountreference'); + $from = $dsn->getRequiredOption('from'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); - return (new EsendexTransport($token, $accountReference, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + return (new EsendexTransport($email, $password, $accountReference, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); } protected function getSupportedSchemes(): array diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportFactoryTest.php index c05f3cfaab1a6..7a55380c6b836 100644 --- a/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportFactoryTest.php @@ -40,6 +40,13 @@ public function supportsProvider(): iterable } public function incompleteDsnProvider(): iterable + { + yield 'missing credentials' => ['esendex://host?accountreference=ACCOUNTREFERENCE&from=FROM']; + yield 'missing email' => ['esendex://:password@host?accountreference=ACCOUNTREFERENCE&from=FROM']; + yield 'missing password' => ['esendex://email:@host?accountreference=ACCOUNTREFERENCE&from=FROM']; + } + + public function missingRequiredOptionProvider(): iterable { yield 'missing option: from' => ['esendex://email:password@host?accountreference=ACCOUNTREFERENCE']; yield 'missing option: accountreference' => ['esendex://email:password@host?from=FROM']; diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportTest.php index 4076ad49884af..bf73c7cb81ca3 100644 --- a/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Esendex/Tests/EsendexTransportTest.php @@ -29,7 +29,7 @@ final class EsendexTransportTest extends TransportTestCase */ public function createTransport(?HttpClientInterface $client = null): TransportInterface { - return (new EsendexTransport('testToken', 'testAccountReference', 'testFrom', $client ?: $this->createMock(HttpClientInterface::class)))->setHost('host.test'); + return (new EsendexTransport('email', 'password', 'testAccountReference', 'testFrom', $client ?: $this->createMock(HttpClientInterface::class)))->setHost('host.test'); } public function toStringProvider(): iterable diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/composer.json b/src/Symfony/Component/Notifier/Bridge/Esendex/composer.json index 6ce8ca54a2653..6358037b60394 100644 --- a/src/Symfony/Component/Notifier/Bridge/Esendex/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Esendex/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.4|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Esendex\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md index 7bd5e9a57fd19..5b5417f3c604a 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * Add `data` field to options + 5.1.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php index e0e86ad23ea1e..0194effb95dd9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php @@ -17,8 +17,6 @@ * @author Jeroen Spee * * @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html - * - * @experimental in 5.2 */ abstract class FirebaseOptions implements MessageOptionsInterface { @@ -29,10 +27,13 @@ abstract class FirebaseOptions implements MessageOptionsInterface */ protected $options; - public function __construct(string $to, array $options) + private $data; + + public function __construct(string $to, array $options, array $data = []) { $this->to = $to; $this->options = $options; + $this->data = $data; } public function toArray(): array @@ -40,6 +41,7 @@ public function toArray(): array return [ 'to' => $this->to, 'notification' => $this->options, + 'data' => $this->data, ]; } @@ -61,4 +63,11 @@ public function body(string $body): self return $this; } + + public function data(array $data): self + { + $this->data = $data; + + return $this; + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php index e1e1935568d2c..a2c3739b0bd2e 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php @@ -12,8 +12,8 @@ namespace Symfony\Component\Notifier\Bridge\Firebase; use Symfony\Component\Notifier\Exception\InvalidArgumentException; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -23,8 +23,6 @@ /** * @author Jeroen Spee - * - * @experimental in 5.2 */ final class FirebaseTransport extends AbstractTransport { @@ -54,7 +52,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } $endpoint = sprintf('https://%s', $this->getEndpoint()); @@ -67,6 +65,8 @@ protected function doSend(MessageInterface $message): SentMessage } $options['notification'] = $options['notification'] ?? []; $options['notification']['body'] = $message->getSubject(); + $options['data'] = $options['data'] ?? []; + $response = $this->client->request('POST', $endpoint, [ 'headers' => [ 'Authorization' => sprintf('key=%s', $this->token), diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php index 4c604504fba1f..962978b1d24e7 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php @@ -18,8 +18,6 @@ /** * @author Jeroen Spee - * - * @experimental in 5.2 */ final class FirebaseTransportFactory extends AbstractTransportFactory { diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php index 670ca171d58b7..add6a8e4b4125 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php @@ -13,9 +13,6 @@ use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; -/** - * @experimental in 5.2 - */ final class AndroidNotification extends FirebaseOptions { public function channelId(string $channelId): self diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php index a20b8cf22de3b..23f44f9182153 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php @@ -13,9 +13,6 @@ use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; -/** - * @experimental in 5.2 - */ final class IOSNotification extends FirebaseOptions { public function sound(string $sound): self diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php index 05664abcc3a75..89d0e742b7e49 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php @@ -13,9 +13,6 @@ use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; -/** - * @experimental in 5.2 - */ final class WebNotification extends FirebaseOptions { public function icon(string $icon): self diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json index 61278fb42e52d..782c5239be47b 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Firebase\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/FreeMobile/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/FreeMobile/CHANGELOG.md index 7bd5e9a57fd19..815ac2a0fed81 100644 --- a/src/Symfony/Component/Notifier/Bridge/FreeMobile/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/FreeMobile/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.1.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransport.php b/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransport.php index cd7e96337ba67..c63abcecaf532 100644 --- a/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\FreeMobile; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Antoine Makdessi - * - * @experimental in 5.2 */ final class FreeMobileTransport extends AbstractTransport { @@ -55,7 +53,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$this->supports($message)) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given) and configured with your phone number.', __CLASS__, SmsMessage::class, \get_class($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $endpoint = sprintf('https://%s', $this->getEndpoint()); diff --git a/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransportFactory.php index 661913c2b8b04..eda2c888df5cb 100644 --- a/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/FreeMobile/FreeMobileTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\FreeMobile; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Antoine Makdessi - * - * @experimental in 5.2 */ final class FreeMobileTransportFactory extends AbstractTransportFactory { @@ -37,11 +34,7 @@ public function create(Dsn $dsn): TransportInterface $login = $this->getUser($dsn); $password = $this->getPassword($dsn); - $phone = $dsn->getOption('phone'); - - if (!$phone) { - throw new IncompleteDsnException('Missing phone.', $dsn->getOriginalDsn()); - } + $phone = $dsn->getRequiredOption('phone'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/FreeMobile/Tests/FreeMobileTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/FreeMobile/Tests/FreeMobileTransportFactoryTest.php index bfc9921591d0e..fa554180160b2 100644 --- a/src/Symfony/Component/Notifier/Bridge/FreeMobile/Tests/FreeMobileTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/FreeMobile/Tests/FreeMobileTransportFactoryTest.php @@ -39,7 +39,7 @@ public function supportsProvider(): iterable yield [false, 'somethingElse://login:pass@default?phone=0611223344']; } - public function incompleteDsnProvider(): iterable + public function missingRequiredOptionProvider(): iterable { yield 'missing option: phone' => ['freemobile://login:pass@default']; } diff --git a/src/Symfony/Component/Notifier/Bridge/FreeMobile/composer.json b/src/Symfony/Component/Notifier/Bridge/FreeMobile/composer.json index a6c02a0dcac7e..36d7277c44f80 100644 --- a/src/Symfony/Component/Notifier/Bridge/FreeMobile/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/FreeMobile/composer.json @@ -19,7 +19,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.1", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\FreeMobile\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/.gitattributes b/src/Symfony/Component/Notifier/Bridge/GatewayApi/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/GatewayApi/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/GatewayApiTransport.php b/src/Symfony/Component/Notifier/Bridge/GatewayApi/GatewayApiTransport.php new file mode 100644 index 0000000000000..c8e4393dfa461 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/GatewayApiTransport.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\GatewayApi; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Piergiuseppe Longo + */ +final class GatewayApiTransport extends AbstractTransport +{ + protected const HOST = 'gatewayapi.com'; + + private $authToken; + private $from; + + public function __construct(string $authToken, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->authToken = $authToken; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('gatewayapi://%s?from=%s', $this->getEndpoint(), $this->from); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $endpoint = sprintf('https://%s/rest/mtsms', $this->getEndpoint()); + + $response = $this->client->request('POST', $endpoint, [ + 'auth_basic' => [$this->authToken, ''], + 'json' => [ + 'sender' => $this->from, + 'recipients' => [['msisdn' => $message->getPhone()]], + 'message' => $message->getSubject(), + ], + ]); + + $statusCode = $response->getStatusCode(); + if (200 !== $statusCode) { + throw new TransportException(sprintf('Unable to send the SMS: error %d.', $statusCode), $response); + } + + $content = $response->toArray(false); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId((string) $content['ids'][0]); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/GatewayApiTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/GatewayApi/GatewayApiTransportFactory.php new file mode 100644 index 0000000000000..282baea5fb0b7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/GatewayApiTransportFactory.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\GatewayApi; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Piergiuseppe Longo + */ +final class GatewayApiTransportFactory extends AbstractTransportFactory +{ + /** + * @return GatewayApiTransport + */ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('gatewayapi' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'gatewayapi', $this->getSupportedSchemes()); + } + + $authToken = $this->getUser($dsn); + $from = $dsn->getRequiredOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new GatewayApiTransport($authToken, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['gatewayapi']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE b/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/GatewayApi/README.md b/src/Symfony/Component/Notifier/Bridge/GatewayApi/README.md new file mode 100644 index 0000000000000..d01f2df87fd75 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/README.md @@ -0,0 +1,25 @@ +GatewayApi Notifier +=================== + +Provides GatewayApi integration for Symfony Notifier. + +DSN example +----------- + +``` +GATEWAYAPI_DSN=gatewayapi://TOKEN@default?from=FROM +``` + +where: +- `TOKEN` is API Token (OAuth) +- `FROM` is sender name + +See your account info at https://gatewayapi.com + +Resources +--------- + +* [Contributing](https://symfony.com/doc/current/contributing/index.html) +* [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/Tests/GatewayApiTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/GatewayApi/Tests/GatewayApiTransportFactoryTest.php new file mode 100644 index 0000000000000..4aff0f314087c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/Tests/GatewayApiTransportFactoryTest.php @@ -0,0 +1,46 @@ + + * @author Oskar Stark + */ +final class GatewayApiTransportFactoryTest extends TransportFactoryTestCase +{ + /** + * @return GatewayApiTransportFactory + */ + public function createFactory(): TransportFactoryInterface + { + return new GatewayApiTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'gatewayapi://gatewayapi.com?from=Symfony', + 'gatewayapi://token@default?from=Symfony', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'gatewayapi://token@host.test?from=Symfony']; + yield [false, 'somethingElse://token@default?from=Symfony']; + } + + public function incompleteDsnProvider(): iterable + { + yield 'missing token' => ['gatewayapi://host.test?from=Symfony']; + } + + public function missingRequiredOptionProvider(): iterable + { + yield 'missing option: from' => ['gatewayapi://token@host.test']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/Tests/GatewayApiTransportTest.php b/src/Symfony/Component/Notifier/Bridge/GatewayApi/Tests/GatewayApiTransportTest.php new file mode 100644 index 0000000000000..7671814d8e352 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/Tests/GatewayApiTransportTest.php @@ -0,0 +1,68 @@ + + * @author Oskar Stark + */ +final class GatewayApiTransportTest extends TransportTestCase +{ + /** + * @return GatewayApiTransport + */ + public function createTransport(?HttpClientInterface $client = null): TransportInterface + { + return new GatewayApiTransport('authtoken', 'Symfony', $client ?: $this->createMock(HttpClientInterface::class)); + } + + public function toStringProvider(): iterable + { + yield ['gatewayapi://gatewayapi.com?from=Symfony', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } + + public function testSend() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + $response->expects($this->once()) + ->method('getContent') + ->willReturn(json_encode(['ids' => [42]])); + + $client = new MockHttpClient(static function () use ($response): ResponseInterface { + return $response; + }); + + $message = new SmsMessage('3333333333', 'Hello!'); + + $transport = $this->createTransport($client); + $sentMessage = $transport->send($message); + + $this->assertInstanceOf(SentMessage::class, $sentMessage); + $this->assertSame('42', $sentMessage->getMessageId()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/composer.json b/src/Symfony/Component/Notifier/Bridge/GatewayApi/composer.json new file mode 100644 index 0000000000000..d7bfaf53c3973 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/gatewayapi-notifier", + "type": "symfony-bridge", + "description": "Symfony GatewayApi Notifier Bridge", + "keywords": ["sms", "gatewayapi", "notifier"], + "homepage": "https://gatewayapi.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Piergiuseppe Longo", + "email": "piergiuseppe.longo@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\GatewayApi\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/GatewayApi/phpunit.xml.dist new file mode 100644 index 0000000000000..23919e750d98b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Gitter/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Gitter/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/GitterTransport.php b/src/Symfony/Component/Notifier/Bridge/Gitter/GitterTransport.php new file mode 100644 index 0000000000000..4b04fa4d26c41 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/GitterTransport.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Gitter; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christin Gruber + */ +final class GitterTransport extends AbstractTransport +{ + protected const HOST = 'api.gitter.im'; + + private $token; + private $roomId; + + public function __construct(string $token, string $roomId, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->token = $token; + $this->roomId = $roomId; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('gitter://%s?room_id=%s', $this->getEndpoint(), $this->roomId); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage; + } + + /** + * @see https://developer.gitter.im/docs/rest-api + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); + } + + $endpoint = sprintf('https://%s/v1/rooms/%s/chatMessages', $this->getEndpoint(), $this->roomId); + + $response = $this->client->request('POST', $endpoint, [ + 'auth_bearer' => $this->token, + 'json' => [ + 'text' => $message->getSubject(), + ], + ]); + + $result = $response->toArray(false); + + if (Response::HTTP_OK !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to post the Gitter message: "%s".', $result['error']), $response); + } + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($result['id']); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/GitterTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Gitter/GitterTransportFactory.php new file mode 100644 index 0000000000000..c86f29d6b9594 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/GitterTransportFactory.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Gitter; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Christin Gruber + */ +final class GitterTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('gitter' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'gitter', $this->getSupportedSchemes()); + } + + $token = $this->getUser($dsn); + $roomId = $dsn->getRequiredOption('room_id'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new GitterTransport($token, $roomId, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['gitter']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE b/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/Gitter/README.md b/src/Symfony/Component/Notifier/Bridge/Gitter/README.md new file mode 100644 index 0000000000000..bd318be6ede02 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/README.md @@ -0,0 +1,23 @@ +Gitter Notifier +=============== + +Provides [Gitter](https://gitter.im) integration for Symfony Notifier. + +DSN example +----------- + +``` +GITTER_DSN=gitter://TOKEN@default?room_id=ROOM_ID +``` + +where: +- `TOKEN` is your Gitter token +- `ROOM_ID` is your Gitter room id + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/Tests/GitterTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Gitter/Tests/GitterTransportFactoryTest.php new file mode 100644 index 0000000000000..24f1038ecfc37 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/Tests/GitterTransportFactoryTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Gitter\Tests; + +use Symfony\Component\Notifier\Bridge\Gitter\GitterTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +/** + * @author Christin Gruber + */ +final class GitterTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): TransportFactoryInterface + { + return new GitterTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'gitter://api.gitter.im?room_id=5539a3ee5etest0d3255bfef', + 'gitter://token@api.gitter.im?room_id=5539a3ee5etest0d3255bfef', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'gitter://token@host?room_id=5539a3ee5etest0d3255bfef']; + yield [false, 'somethingElse://token@host?room_id=5539a3ee5etest0d3255bfef']; + } + + public function incompleteDsnProvider(): iterable + { + yield 'missing token' => ['gitter://api.gitter.im?room_id=5539a3ee5etest0d3255bfef']; + } + + public function missingRequiredOptionProvider(): iterable + { + yield 'missing option: room_id' => ['gitter://token@host']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://token@host?room_id=5539a3ee5etest0d3255bfef']; + yield ['somethingElse://token@host']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/Tests/GitterTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Gitter/Tests/GitterTransportTest.php new file mode 100644 index 0000000000000..b21311671d066 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/Tests/GitterTransportTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Gitter\Tests; + +use Symfony\Component\Notifier\Bridge\Gitter\GitterTransport; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christin Gruber + */ +final class GitterTransportTest extends TransportTestCase +{ + public function createTransport(?HttpClientInterface $client = null): TransportInterface + { + return (new GitterTransport('token', '5539a3ee5etest0d3255bfef', $client ?: $this->createMock(HttpClientInterface::class)))->setHost('api.gitter.im'); + } + + public function toStringProvider(): iterable + { + yield ['gitter://api.gitter.im?room_id=5539a3ee5etest0d3255bfef', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/composer.json b/src/Symfony/Component/Notifier/Bridge/Gitter/composer.json new file mode 100644 index 0000000000000..95c67862fa3fc --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/composer.json @@ -0,0 +1,31 @@ +{ + "name": "symfony/gitter-notifier", + "type": "symfony-bridge", + "description": "Symfony Gitter Notifier Bridge", + "keywords": ["chat", "gitter", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Christin Gruber", + "email": "c.gruber@touchdesign.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "ext-json": "*", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Gitter\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Gitter/phpunit.xml.dist new file mode 100644 index 0000000000000..65976bac0c796 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Gitter/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/GoogleChat/CHANGELOG.md index 0d994e934e55a..5759f578770fe 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/CHANGELOG.md @@ -1,6 +1,17 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * [BC BREAK] Remove `GoogleChatTransport::setThreadKey()` method, this parameter should now be provided via the constructor, + which has changed from: + `__construct(string $space, string $accessKey, string $accessToken, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)` + to: + `__construct(string $space, string $accessKey, string $accessToken, string $threadKey = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)` + * [BC BREAK] Rename the parameter `threadKey` to `thread_key` in DSN + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatOptions.php b/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatOptions.php index 407728ab49223..77372cd5102c6 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatOptions.php @@ -18,8 +18,6 @@ /** * @author Jérôme Tamarelle - * - * @experimental in 5.2 */ final class GoogleChatOptions implements MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransport.php b/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransport.php index 059ddee54f091..01d6e7d335347 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransport.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpClient\Exception\JsonException; use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -23,8 +24,6 @@ /** * @author Jérôme Tamarelle - * - * @experimental in 5.2 */ final class GoogleChatTransport extends AbstractTransport { @@ -33,38 +32,25 @@ final class GoogleChatTransport extends AbstractTransport private $space; private $accessKey; private $accessToken; - - /** - * @var ?string - */ private $threadKey; /** - * @param string $space The space name the the webhook url "/v1/spaces//messages" - * @param string $accessKey The "key" parameter of the webhook url - * @param string $accessToken The "token" parameter of the webhook url + * @param string $space The space name the the webhook url "/v1/spaces//messages" + * @param string $accessKey The "key" parameter of the webhook url + * @param string $accessToken The "token" parameter of the webhook url + * @param string|null $threadKey Opaque thread identifier string that can be specified to group messages into a single thread. + * If this is the first message with a given thread identifier, a new thread is created. + * Subsequent messages with the same thread identifier will be posted into the same thread. + * {@see https://developers.google.com/hangouts/chat/reference/rest/v1/spaces.messages/create#query-parameters} */ - public function __construct(string $space, string $accessKey, string $accessToken, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + public function __construct(string $space, string $accessKey, string $accessToken, string $threadKey = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) { $this->space = $space; $this->accessKey = $accessKey; $this->accessToken = $accessToken; - - parent::__construct($client, $dispatcher); - } - - /** - * Opaque thread identifier string that can be specified to group messages into a single thread. - * If this is the first message with a given thread identifier, a new thread is created. - * Subsequent messages with the same thread identifier will be posted into the same thread. - * - * @see https://developers.google.com/hangouts/chat/reference/rest/v1/spaces.messages/create#query-parameters - */ - public function setThreadKey(?string $threadKey): self - { $this->threadKey = $threadKey; - return $this; + parent::__construct($client, $dispatcher); } public function __toString(): string @@ -72,7 +58,7 @@ public function __toString(): string return sprintf('googlechat://%s/%s%s', $this->getEndpoint(), $this->space, - $this->threadKey ? '?threadKey='.urlencode($this->threadKey) : '' + $this->threadKey ? '?thread_key='.urlencode($this->threadKey) : '' ); } @@ -87,7 +73,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, \get_class($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } if ($message->getOptions() && !$message->getOptions() instanceof GoogleChatOptions) { diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransportFactory.php index 94cbc786f2532..3a35195d09cc4 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/GoogleChatTransportFactory.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Notifier\Bridge\GoogleChat; +use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -18,13 +19,11 @@ /** * @author Jérôme Tamarelle - * - * @experimental in 5.2 */ final class GoogleChatTransportFactory extends AbstractTransportFactory { /** - * @param Dsn $dsn Format: googlechat://:@default/?threadKey= + * @param Dsn $dsn Format: googlechat://:@default/?thread_key= * * @return GoogleChatTransport */ @@ -39,11 +38,20 @@ public function create(Dsn $dsn): TransportInterface $space = explode('/', $dsn->getPath())[1]; $accessKey = $this->getUser($dsn); $accessToken = $this->getPassword($dsn); - $threadKey = $dsn->getOption('threadKey'); + + $threadKey = $dsn->getOption('thread_key'); + + /* + * Remove this check for 5.4 + */ + if (null === $threadKey && null !== $dsn->getOption('threadKey')) { + throw new IncompleteDsnException('GoogleChat DSN has changed since 5.3, use "thread_key" instead of "threadKey" parameter.'); + } + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); - return (new GoogleChatTransport($space, $accessKey, $accessToken, $this->client, $this->dispatcher))->setThreadKey($threadKey)->setHost($host)->setPort($port); + return (new GoogleChatTransport($space, $accessKey, $accessToken, $threadKey, $this->client, $this->dispatcher))->setHost($host)->setPort($port); } protected function getSupportedSchemes(): array diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/README.md b/src/Symfony/Component/Notifier/Bridge/GoogleChat/README.md index 53da1e80609a6..fe18aa06dcf01 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/README.md +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/README.md @@ -7,7 +7,7 @@ DSN example ----------- ``` -GOOGLE_CHAT_DSN=googlechat://ACCESS_KEY:ACCESS_TOKEN@default/SPACE?threadKey=THREAD_KEY +GOOGLE_CHAT_DSN=googlechat://ACCESS_KEY:ACCESS_TOKEN@default/SPACE?thread_key=THREAD_KEY ``` where: diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportFactoryTest.php index 5676bfecd35f7..cc55001c4771d 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportFactoryTest.php @@ -33,8 +33,8 @@ public function createProvider(): iterable ]; yield [ - 'googlechat://chat.googleapis.com/AAAAA_YYYYY?threadKey=abcdefg', - 'googlechat://abcde-fghij:kl_mnopqrstwxyz%3D@chat.googleapis.com/AAAAA_YYYYY?threadKey=abcdefg', + 'googlechat://chat.googleapis.com/AAAAA_YYYYY?thread_key=abcdefg', + 'googlechat://abcde-fghij:kl_mnopqrstwxyz%3D@chat.googleapis.com/AAAAA_YYYYY?thread_key=abcdefg', ]; } @@ -46,7 +46,8 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { - yield 'missing credentials' => ['googlechat://chat.googleapis.com/AAAAA_YYYYY']; + yield 'missing credentials' => ['googlechat://chat.googleapis.com/v1/spaces/AAAAA_YYYYY/messages']; + yield 'using old option: threadKey' => ['googlechat://abcde-fghij:kl_mnopqrstwxyz%3D@chat.googleapis.com/AAAAA_YYYYY?threadKey=abcdefg', 'GoogleChat DSN has changed since 5.3, use "thread_key" instead of "threadKey" parameter.']; // can be removed in Symfony 5.4 } public function unsupportedSchemeProvider(): iterable diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportTest.php b/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportTest.php index 9fd6a7c7d3bbd..0c73758bfabc3 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/Tests/GoogleChatTransportTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\GoogleChat\Tests; -use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\Notifier\Bridge\GoogleChat\GoogleChatOptions; use Symfony\Component\Notifier\Bridge\GoogleChat\GoogleChatTransport; @@ -22,23 +21,25 @@ use Symfony\Component\Notifier\Message\MessageOptionsInterface; use Symfony\Component\Notifier\Message\SmsMessage; use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Tests\TransportTestCase; use Symfony\Component\Notifier\Transport\TransportInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; -final class GoogleChatTransportTest extends TestCase +final class GoogleChatTransportTest extends TransportTestCase { /** * @return GoogleChatTransport */ - public function createTransport(?HttpClientInterface $client = null): TransportInterface + public function createTransport(?HttpClientInterface $client = null, string $threadKey = null): TransportInterface { - return new GoogleChatTransport('My-Space', 'theAccessKey', 'theAccessToken=', $client ?: $this->createMock(HttpClientInterface::class)); + return new GoogleChatTransport('My-Space', 'theAccessKey', 'theAccessToken=', $threadKey, $client ?: $this->createMock(HttpClientInterface::class)); } public function toStringProvider(): iterable { yield ['googlechat://chat.googleapis.com/My-Space', $this->createTransport()]; + yield ['googlechat://chat.googleapis.com/My-Space?thread_key=abcdefg', $this->createTransport(null, 'abcdefg')]; } public function supportedMessagesProvider(): iterable @@ -125,8 +126,7 @@ public function testSendWithOptions() return $response; }); - $transport = $this->createTransport($client); - $transport->setThreadKey('My-Thread'); + $transport = $this->createTransport($client, 'My-Thread'); $sentMessage = $transport->send(new ChatMessage('testMessage')); diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/composer.json b/src/Symfony/Component/Notifier/Bridge/GoogleChat/composer.json index befd79b58daf1..cb24cfc52fd6a 100644 --- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\GoogleChat\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Infobip/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Infobip/CHANGELOG.md index 0d994e934e55a..91b7e5fb62ef8 100644 --- a/src/Symfony/Component/Notifier/Bridge/Infobip/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Infobip/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransport.php b/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransport.php index cbeae8b57413f..32984b3c2f0dc 100644 --- a/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Infobip; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -23,8 +23,6 @@ /** * @author Fabien Potencier * @author Jérémy Romey - * - * @experimental in 5.2 */ final class InfobipTransport extends AbstractTransport { @@ -52,7 +50,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $endpoint = sprintf('https://%s/sms/2/text/advanced', $this->getEndpoint()); @@ -87,7 +85,7 @@ protected function doSend(MessageInterface $message): SentMessage return new SentMessage($message, (string) $this); } - protected function getEndpoint(): ?string + protected function getEndpoint(): string { return $this->host.($this->port ? ':'.$this->port : ''); } diff --git a/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransportFactory.php index 6b1d6ca745b66..b4ed0f9fe4c55 100644 --- a/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Infobip/InfobipTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Infobip; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -20,8 +19,6 @@ /** * @author Fabien Potencier * @author Jérémy Romey - * - * @experimental in 5.2 */ final class InfobipTransportFactory extends AbstractTransportFactory { @@ -37,14 +34,10 @@ public function create(Dsn $dsn): TransportInterface } $authToken = $this->getUser($dsn); - $from = $dsn->getOption('from'); + $from = $dsn->getRequiredOption('from'); $host = $dsn->getHost(); $port = $dsn->getPort(); - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - return (new InfobipTransport($authToken, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); } diff --git a/src/Symfony/Component/Notifier/Bridge/Infobip/Tests/InfobipTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Infobip/Tests/InfobipTransportFactoryTest.php index 64dab3d0a736c..c1c2aa2c53a69 100644 --- a/src/Symfony/Component/Notifier/Bridge/Infobip/Tests/InfobipTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Infobip/Tests/InfobipTransportFactoryTest.php @@ -39,7 +39,7 @@ public function supportsProvider(): iterable yield [false, 'somethingElse://authtoken@default?from=0611223344']; } - public function incompleteDsnProvider(): iterable + public function missingRequiredOptionProvider(): iterable { yield 'missing option: from' => ['infobip://authtoken@default']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Infobip/composer.json b/src/Symfony/Component/Notifier/Bridge/Infobip/composer.json index 09e2c4443da97..00d7e4386b7ed 100644 --- a/src/Symfony/Component/Notifier/Bridge/Infobip/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Infobip/composer.json @@ -22,7 +22,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Infobip\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Iqsms/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Iqsms/CHANGELOG.md new file mode 100644 index 0000000000000..bb2aae9f68d08 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/IqsmsTransport.php b/src/Symfony/Component/Notifier/Bridge/Iqsms/IqsmsTransport.php new file mode 100644 index 0000000000000..a83aee83ebb50 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/IqsmsTransport.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Iqsms; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Oleksandr Barabolia + */ +final class IqsmsTransport extends AbstractTransport +{ + protected const HOST = 'api.iqsms.ru'; + + private $login; + private $password; + private $from; + + public function __construct(string $login, string $password, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->login = $login; + $this->password = $password; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('iqsms://%s?from=%s', $this->getEndpoint(), $this->from); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/messages/v2/send.json', [ + 'json' => [ + 'messages' => [ + [ + 'phone' => $message->getPhone(), + 'text' => $message->getSubject(), + 'sender' => $this->from, + 'clientId' => uniqid(), + ], + ], + 'login' => $this->login, + 'password' => $this->password, + ], + ]); + + $result = $response->toArray(false); + foreach ($result['messages'] as $msg) { + if ('accepted' !== $msg['status']) { + throw new TransportException(sprintf('Unable to send the SMS: "%s".', $msg['status']), $response); + } + } + + $message = new SentMessage($message, (string) $this); + $message->setMessageId($result['messages'][0]['smscId']); + + return $message; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/IqsmsTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Iqsms/IqsmsTransportFactory.php new file mode 100644 index 0000000000000..480416e07b875 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/IqsmsTransportFactory.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Iqsms; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Oleksandr Barabolia + */ +final class IqsmsTransportFactory extends AbstractTransportFactory +{ + /** + * @return IqsmsTransport + */ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('iqsms' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'iqsms', $this->getSupportedSchemes()); + } + + $login = $this->getUser($dsn); + $password = $this->getPassword($dsn); + $from = $dsn->getRequiredOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new IqsmsTransport($login, $password, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['iqsms']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE b/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE new file mode 100644 index 0000000000000..ad85e1737485d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/Iqsms/README.md b/src/Symfony/Component/Notifier/Bridge/Iqsms/README.md new file mode 100644 index 0000000000000..1cd32c9e2947a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/README.md @@ -0,0 +1,24 @@ +Iqsms Notifier +============== + +Provides [Iqsms](https://iqsms.ru) integration for Symfony Notifier. + +DSN example +----------- + +``` +IQSMS_DSN=iqsms://LOGIN:PASSWORD@default?from=FROM +``` + +where: + - `LOGIN` is your IQSMS login + - `PASSWORD` is your IQSMS password + - `FROM` is the sender + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/Tests/IqsmsTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Iqsms/Tests/IqsmsTransportFactoryTest.php new file mode 100644 index 0000000000000..f187b035fdbdc --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/Tests/IqsmsTransportFactoryTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Iqsms\Tests; + +use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +final class IqsmsTransportFactoryTest extends TransportFactoryTestCase +{ + /** + * @return IqsmsTransportFactory + */ + public function createFactory(): TransportFactoryInterface + { + return new IqsmsTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'iqsms://host.test?from=FROM', + 'iqsms://login:password@host.test?from=FROM', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'iqsms://login:password@default?from=FROM']; + yield [false, 'somethingElse://login:password@default?from=FROM']; + } + + public function incompleteDsnProvider(): iterable + { + yield 'missing login' => ['iqsms://:password@host.test?from=FROM']; + yield 'missing password' => ['iqsms://login:@host.test?from=FROM']; + yield 'missing credentials' => ['iqsms://@host.test?from=FROM']; + } + + public function missingRequiredOptionProvider(): iterable + { + yield 'missing option: from' => ['iqsms://login:password@default']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://login:password@default?from=FROM']; + yield ['somethingElse://login:password@default']; // missing "from" option + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/Tests/IqsmsTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Iqsms/Tests/IqsmsTransportTest.php new file mode 100644 index 0000000000000..e251a7fb26985 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/Tests/IqsmsTransportTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Iqsms\Tests; + +use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransport; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class IqsmsTransportTest extends TransportTestCase +{ + /** + * @return IqsmsTransport + */ + public function createTransport(?HttpClientInterface $client = null): TransportInterface + { + return new IqsmsTransport('login', 'password', 'sender', $client ?: $this->createMock(HttpClientInterface::class)); + } + + public function toStringProvider(): iterable + { + yield ['iqsms://api.iqsms.ru?from=sender', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/composer.json b/src/Symfony/Component/Notifier/Bridge/Iqsms/composer.json new file mode 100644 index 0000000000000..bcc6f74e43153 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/iqsms-notifier", + "type": "symfony-bridge", + "description": "Symfony Iqsms Notifier Bridge", + "keywords": ["sms", "iqsms", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Oleksandr Barabolia", + "email": "alexandrbarabolya@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Iqsms\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Iqsms/phpunit.xml.dist new file mode 100644 index 0000000000000..d73fef8d446f6 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md index 0d994e934e55a..53ce882015c82 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * [BC BREAK] `LinkedInTransportFactory` is now final + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php index 7e915be886945..630265645334a 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php @@ -20,8 +20,6 @@ /** * @author Smaïne Milianni - * - * @experimental in 5.2 */ final class LinkedInOptions implements MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php index 1e724846ef705..bb281df6f7f69 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php @@ -14,6 +14,7 @@ use Symfony\Component\Notifier\Bridge\LinkedIn\Share\AuthorShare; use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -24,8 +25,6 @@ /** * @author Smaïne Milianni * - * @experimental in 5.2 - * * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#sharecontent */ final class LinkedInTransport extends AbstractTransport @@ -61,7 +60,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, \get_class($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } if ($message->getOptions() && !$message->getOptions() instanceof LinkedInOptions) { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php index d6f1a8523b772..f052e28e29075 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php @@ -18,10 +18,8 @@ /** * @author Smaïne Milianni - * - * @experimental in 5.2 */ -class LinkedInTransportFactory extends AbstractTransportFactory +final class LinkedInTransportFactory extends AbstractTransportFactory { public function create(Dsn $dsn): TransportInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php index 6e6d2d56920c6..8a02cc734df17 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php @@ -13,8 +13,6 @@ /** * @author Smaïne Milianni - * - * @experimental in 5.2 */ abstract class AbstractLinkedInShare { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php index 9161eb1b4513b..9558031a71409 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php @@ -13,8 +13,6 @@ /** * @author Smaïne Milianni - * - * @experimental in 5.2 */ final class AuthorShare extends AbstractLinkedInShare { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php index 98fd1a83e6262..cf51d1835d88b 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php @@ -17,8 +17,6 @@ * @author Smaïne Milianni * * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#schema lifecycleState section - * - * @experimental in 5.2 */ final class LifecycleStateShare extends AbstractLinkedInShare { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php index 2efbb08f65640..1c70a6cde9273 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php @@ -17,8 +17,6 @@ * @author Smaïne Milianni * * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#sharecontent - * - * @experimental in 5.2 */ final class ShareContentShare extends AbstractLinkedInShare { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php index f277d13b6be70..f41fb85d45e3c 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php @@ -17,8 +17,6 @@ * @author Smaïne Milianni * * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#sharemedia - * - * @experimental in 5.2 */ class ShareMediaShare extends AbstractLinkedInShare { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php index 15883c5a3a44a..03ca05bde25ba 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php @@ -15,8 +15,6 @@ /** * @author Smaïne Milianni - * - * @experimental in 5.2 */ final class VisibilityShare extends AbstractLinkedInShare { diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json b/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json index 6ebc829c25406..36e10ccf5699b 100644 --- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\LinkedIn\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Mattermost/CHANGELOG.md index 7bd5e9a57fd19..8e154d13f0b85 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mattermost/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * [BC BREAK] Change signature of `MattermostTransport::__construct()` method from: + `public function __construct(string $token, string $channel, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, string $path = null)` + to: + `public function __construct(string $token, string $channel, ?string $path = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)` + 5.1.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransport.php b/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransport.php index 18db05cc7a87d..fd5b892071480 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Mattermost; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -22,8 +22,6 @@ /** * @author Emanuele Panzeri - * - * @experimental in 5.2 */ final class MattermostTransport extends AbstractTransport { @@ -31,7 +29,7 @@ final class MattermostTransport extends AbstractTransport private $channel; private $path; - public function __construct(string $token, string $channel, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, string $path = null) + public function __construct(string $token, string $channel, ?string $path = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) { $this->token = $token; $this->channel = $channel; @@ -56,7 +54,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } $options = ($opts = $message->getOptions()) ? $opts->toArray() : []; @@ -87,7 +85,7 @@ protected function doSend(MessageInterface $message): SentMessage return $sentMessage; } - protected function getEndpoint(): ?string + protected function getEndpoint(): string { return rtrim($this->host.($this->port ? ':'.$this->port : '').($this->path ?? ''), '/'); } diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransportFactory.php index a1fd8464e2dd6..aa68de27dbebd 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/MattermostTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Mattermost; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Emanuele Panzeri - * - * @experimental in 5.2 */ final class MattermostTransportFactory extends AbstractTransportFactory { @@ -34,16 +31,11 @@ public function create(Dsn $dsn): TransportInterface $path = $dsn->getPath(); $token = $this->getUser($dsn); - $channel = $dsn->getOption('channel'); - - if (!$channel) { - throw new IncompleteDsnException('Missing channel.', $dsn->getOriginalDsn()); - } - + $channel = $dsn->getRequiredOption('channel'); $host = $dsn->getHost(); $port = $dsn->getPort(); - return (new MattermostTransport($token, $channel, $this->client, $this->dispatcher, $path))->setHost($host)->setPort($port); + return (new MattermostTransport($token, $channel, $path, $this->client, $this->dispatcher))->setHost($host)->setPort($port); } protected function getSupportedSchemes(): array diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportFactoryTest.php index 6de18a24391c7..8474f636b2f82 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportFactoryTest.php @@ -59,7 +59,11 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { - yield 'missing option: token' => ['mattermost://host.test?channel=testChannel']; + yield 'missing token' => ['mattermost://host.test?channel=testChannel']; + } + + public function missingRequiredOptionProvider(): iterable + { yield 'missing option: channel' => ['mattermost://token@host']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportTest.php index 53f91afd46cbd..c5ebe44db40fd 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/Tests/MattermostTransportTest.php @@ -29,7 +29,7 @@ final class MattermostTransportTest extends TransportTestCase */ public function createTransport(?HttpClientInterface $client = null): TransportInterface { - return (new MattermostTransport('testAccessToken', 'testChannel', $client ?: $this->createMock(HttpClientInterface::class)))->setHost('host.test'); + return (new MattermostTransport('testAccessToken', 'testChannel', null, $client ?: $this->createMock(HttpClientInterface::class)))->setHost('host.test'); } public function toStringProvider(): iterable diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/composer.json b/src/Symfony/Component/Notifier/Bridge/Mattermost/composer.json index 369036feed6e8..9f94864b3bc12 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mattermost/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Mattermost\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE b/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php new file mode 100644 index 0000000000000..c6fee89c05cde --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure; + +use Symfony\Component\Notifier\Message\MessageOptionsInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureOptions implements MessageOptionsInterface +{ + private $topics; + private $private; + private $id; + private $type; + private $retry; + + /** + * @param string|string[]|null $topics + */ + public function __construct($topics = null, bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null) + { + if (null !== $topics && !\is_array($topics) && !\is_string($topics)) { + throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an array of strings, a string or null, "%s" given.', __METHOD__, get_debug_type($topics))); + } + + $this->topics = null !== $topics ? (array) $topics : null; + $this->private = $private; + $this->id = $id; + $this->type = $type; + $this->retry = $retry; + } + + /** + * @return string[]|null + */ + public function getTopics(): ?array + { + return $this->topics; + } + + public function isPrivate(): bool + { + return $this->private; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getType(): ?string + { + return $this->type; + } + + public function getRetry(): ?int + { + return $this->retry; + } + + public function toArray(): array + { + return [ + 'topics' => $this->topics, + 'private' => $this->private, + 'id' => $this->id, + 'type' => $this->type, + 'retry' => $this->retry, + ]; + } + + public function getRecipientId(): ?string + { + return null; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php new file mode 100644 index 0000000000000..bbdb663f4a971 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure; + +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\RuntimeException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureTransport extends AbstractTransport +{ + private $publisher; + private $publisherId; + private $topics; + + /** + * @param string|string[]|null $topics + */ + public function __construct(PublisherInterface $publisher, string $publisherId, $topics = null, ?HttpClientInterface $client = null, ?EventDispatcherInterface $dispatcher = null) + { + if (null !== $topics && !\is_array($topics) && !\is_string($topics)) { + throw new \TypeError(sprintf('"%s()" expects parameter 3 to be an array of strings, a string or null, "%s" given.', __METHOD__, get_debug_type($topics))); + } + + $this->publisher = $publisher; + $this->publisherId = $publisherId; + $this->topics = $topics ?? 'https://symfony.com/notifier'; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('mercure://%s?%s', $this->publisherId, http_build_query(['topic' => $this->topics])); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof MercureOptions); + } + + /** + * @see https://symfony.com/doc/current/mercure.html#publishing + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); + } + + if (($options = $message->getOptions()) && !$options instanceof MercureOptions) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" for options.', __CLASS__, MercureOptions::class)); + } + + if (null === $options) { + $options = new MercureOptions($this->topics); + } + + // @see https://www.w3.org/TR/activitystreams-core/#jsonld + $update = new Update($options->getTopics() ?? $this->topics, json_encode([ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Announce', + 'summary' => $message->getSubject(), + ]), $options->isPrivate(), $options->getId(), $options->getType(), $options->getRetry()); + + try { + $messageId = ($this->publisher)($update); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($messageId); + + return $sentMessage; + } catch (HttpExceptionInterface $e) { + throw new TransportException('Unable to post the Mercure message: '.$e->getResponse()->getContent(false), $e->getResponse(), $e->getCode(), $e); + } catch (ExceptionInterface $e) { + throw new RuntimeException('Unable to post the Mercure message: '.$e->getMessage(), $e->getCode(), $e); + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException('Unable to post the Mercure message: '.$e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php new file mode 100644 index 0000000000000..a7411479d1441 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure; + +use Symfony\Bundle\MercureBundle\MercureBundle; +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureTransportFactory extends AbstractTransportFactory +{ + private $publisherLocator; + + /** + * @param ServiceProviderInterface $publisherLocator A container that holds {@see PublisherInterface} instances + */ + public function __construct(ServiceProviderInterface $publisherLocator) + { + parent::__construct(); + + $this->publisherLocator = $publisherLocator; + } + + /** + * @return MercureTransport + */ + public function create(Dsn $dsn): TransportInterface + { + if ('mercure' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'mercure', $this->getSupportedSchemes()); + } + + $publisherId = $dsn->getHost(); + if (!$this->publisherLocator->has($publisherId)) { + if (!class_exists(MercureBundle::class) && !$this->publisherLocator->getProvidedServices()) { + throw new LogicException('No publishers found. Did you forget to install the MercureBundle? Try running "composer require symfony/mercure-bundle".'); + } + + throw new LogicException(sprintf('"%s" not found. Did you mean one of: %s?', $publisherId, implode(', ', array_keys($this->publisherLocator->getProvidedServices())))); + } + + $topic = $dsn->getOption('topic'); + + return new MercureTransport($this->publisherLocator->get($publisherId), $publisherId, $topic); + } + + protected function getSupportedSchemes(): array + { + return ['mercure']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/README.md b/src/Symfony/Component/Notifier/Bridge/Mercure/README.md new file mode 100644 index 0000000000000..7906b15a9960f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/README.md @@ -0,0 +1,23 @@ +Mercure Notifier +================ + +Provides [Mercure](https://github.com/symfony/mercure) integration for Symfony Notifier. + +DSN example +----------- + +``` +MERCURE_DSN=mercure://PUBLISHER_SERVICE_ID?topic=TOPIC +``` + +where: + - `PUBLISHER_SERVICE_ID` is the Mercure publisher service id + - `TOPIC` is the topic IRI (optional, default: `https://symfony.com/notifier`. Could be either a single topic: `topic=https://foo` or multiple topics: `topic[]=/foo/1&topic[]=https://bar`) + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php new file mode 100644 index 0000000000000..184e5e5db8d63 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Mercure\MercureOptions; +use TypeError; + +final class MercureOptionsTest extends TestCase +{ + public function testConstructWithDefaults() + { + $this->assertSame((new MercureOptions())->toArray(), [ + 'topics' => null, + 'private' => false, + 'id' => null, + 'type' => null, + 'retry' => null, + ]); + } + + public function testConstructWithParameters() + { + $options = (new MercureOptions('/topic/1', true, 'id', 'type', 1)); + + $this->assertSame($options->toArray(), [ + 'topics' => ['/topic/1'], + 'private' => true, + 'id' => 'id', + 'type' => 'type', + 'retry' => 1, + ]); + } + + public function testConstructWithWrongTopicsThrows() + { + $this->expectException(TypeError::class); + new MercureOptions(1); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php new file mode 100644 index 0000000000000..a1bf585128c72 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure\Tests; + +use LogicException; +use Symfony\Bridge\PhpUnit\ClassExistsMock; +use Symfony\Bundle\MercureBundle\MercureBundle; +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): TransportFactoryInterface + { + $publisherLocator = $this->createMock(ServiceProviderInterface::class); + $publisherLocator->method('has')->willReturn(true); + $publisherLocator->method('get')->willReturn($this->createMock(PublisherInterface::class)); + + return new MercureTransportFactory($publisherLocator); + } + + public function supportsProvider(): iterable + { + yield [true, 'mercure://publisherId?topic=topic']; + yield [false, 'somethingElse://publisherId?topic=topic']; + } + + public function createProvider(): iterable + { + yield [ + 'mercure://publisherId?topic=%2Ftopic%2F1', + 'mercure://publisherId?topic=/topic/1', + ]; + + yield [ + 'mercure://publisherId?topic%5B0%5D=%2Ftopic%2F1&topic%5B1%5D=%2Ftopic%2F2', + 'mercure://publisherId?topic[]=/topic/1&topic[]=/topic/2', + ]; + + yield [ + 'mercure://publisherId?topic=https%3A%2F%2Fsymfony.com%2Fnotifier', + 'mercure://publisherId', + ]; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://publisherId?topic=topic']; + } + + public function testCreateWithEmptyServiceProviderAndWithoutMercureBundleThrows() + { + ClassExistsMock::register(MercureTransportFactory::class); + ClassExistsMock::withMockedClasses([MercureBundle::class => false]); + + $publisherLocator = $this->createMock(ServiceProviderInterface::class); + $publisherLocator->method('has')->willReturn(false); + $publisherLocator->method('getProvidedServices')->willReturn([]); + + $factory = new MercureTransportFactory($publisherLocator); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No publishers found. Did you forget to install the MercureBundle? Try running "composer require symfony/mercure-bundle".'); + + try { + $factory->create(new Dsn('mercure://publisherId')); + } finally { + ClassExistsMock::withMockedClasses([MercureBundle::class => true]); + } + } + + public function testNotFoundPublisherThrows() + { + $publisherLocator = $this->createMock(ServiceProviderInterface::class); + $publisherLocator->method('has')->willReturn(false); + $publisherLocator->method('getProvidedServices')->willReturn(['fooPublisher' => 'fooFqcn', 'barPublisher' => 'barFqcn']); + + $factory = new MercureTransportFactory($publisherLocator); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('"publisherId" not found. Did you mean one of: fooPublisher, barPublisher?'); + $factory->create(new Dsn('mercure://publisherId')); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php new file mode 100644 index 0000000000000..360f3c932f97b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure\Tests; + +use Symfony\Component\HttpClient\Exception\TransportException as HttpClientTransportException; +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Notifier\Bridge\Mercure\MercureOptions; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransport; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\RuntimeException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use TypeError; + +/** + * @author Mathias Arlaud + */ +final class MercureTransportTest extends TransportTestCase +{ + public function createTransport(?HttpClientInterface $client = null, ?PublisherInterface $publisher = null, string $publisherId = 'publisherId', $topics = null): TransportInterface + { + $publisher = $publisher ?? $this->createMock(PublisherInterface::class); + + return new MercureTransport($publisher, $publisherId, $topics); + } + + public function toStringProvider(): iterable + { + yield ['mercure://publisherId?topic=https%3A%2F%2Fsymfony.com%2Fnotifier', $this->createTransport()]; + yield ['mercure://customPublisherId?topic=%2Ftopic', $this->createTransport(null, null, 'customPublisherId', '/topic')]; + yield ['mercure://customPublisherId?topic%5B0%5D=%2Ftopic%2F1&topic%5B1%5D%5B0%5D=%2Ftopic%2F2', $this->createTransport(null, null, 'customPublisherId', ['/topic/1', ['/topic/2']])]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } + + public function testCanSetCustomPort() + { + $this->markTestSkipped("Mercure transport doesn't use a regular HTTP Dsn"); + } + + public function testCanSetCustomHost() + { + $this->markTestSkipped("Mercure transport doesn't use a regular HTTP Dsn"); + } + + public function testCanSetCustomHostAndPort() + { + $this->markTestSkipped("Mercure transport doesn't use a regular HTTP Dsn"); + } + + public function testConstructWithWrongTopicsThrows() + { + $this->expectException(TypeError::class); + $this->createTransport(null, null, 'publisherId', 1); + } + + public function testSendWithNonMercureOptionsThrows() + { + $this->expectException(LogicException::class); + $this->createTransport()->send(new ChatMessage('testMessage', $this->createMock(MessageOptionsInterface::class))); + } + + public function testSendWithTransportFailureThrows() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willThrowException(new HttpClientTransportException('Cannot connect to mercure')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to post the Mercure message: Cannot connect to mercure'); + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendWithWrongResponseThrows() + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getContent')->willReturn('Service Unavailable'); + + $httpException = $this->createMock(ServerExceptionInterface::class); + $httpException->method('getResponse')->willReturn($response); + + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willThrowException($httpException); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Unable to post the Mercure message: Service Unavailable'); + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendWithWrongTokenThrows() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willThrowException(new \InvalidArgumentException('The provided JWT is not valid')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to post the Mercure message: The provided JWT is not valid'); + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendWithMercureOptions() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher + ->expects($this->once()) + ->method('__invoke') + ->with(new Update(['/topic/1', '/topic/2'], '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","type":"Announce","summary":"subject"}', true, 'id', 'type', 1)) + ; + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject', new MercureOptions(['/topic/1', '/topic/2'], true, 'id', 'type', 1))); + } + + public function testSendWithMercureOptionsButWithoutOptionTopic() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher + ->expects($this->once()) + ->method('__invoke') + ->with(new Update(['https://symfony.com/notifier'], '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","type":"Announce","summary":"subject"}', true, 'id', 'type', 1)) + ; + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject', new MercureOptions(null, true, 'id', 'type', 1))); + } + + public function testSendWithoutMercureOptions() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher + ->expects($this->once()) + ->method('__invoke') + ->with(new Update(['https://symfony.com/notifier'], '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","type":"Announce","summary":"subject"}')) + ; + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendSuccessfully() + { + $messageId = 'urn:uuid:a7045be0-a75d-4d40-8bd2-29fa4e5dd10b'; + + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willReturn($messageId); + + $sentMessage = $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + $this->assertSame($messageId, $sentMessage->getMessageId()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/composer.json b/src/Symfony/Component/Notifier/Bridge/Mercure/composer.json new file mode 100644 index 0000000000000..da85334a000ae --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/composer.json @@ -0,0 +1,32 @@ +{ + "name": "symfony/mercure-notifier", + "type": "symfony-bridge", + "description": "Symfony Mercure Notifier Bridge", + "keywords": ["mercure", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "ext-json": "*", + "symfony/mercure": "^0.4", + "symfony/notifier": "^5.3", + "symfony/service-contracts": "^1.10|^2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Mercure\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist new file mode 100644 index 0000000000000..0820c4f905beb --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Mobyt/CHANGELOG.md index 0d994e934e55a..e63a4859e5433 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * Validate message types + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytOptions.php b/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytOptions.php index 81b74003702bb..426990dff5fa0 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytOptions.php @@ -11,13 +11,12 @@ namespace Symfony\Component\Notifier\Bridge\Mobyt; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; use Symfony\Component\Notifier\Message\MessageOptionsInterface; use Symfony\Component\Notifier\Notification\Notification; /** * @author Bastien Durand - * - * @experimental in 5.2 */ final class MobytOptions implements MessageOptionsInterface { @@ -29,6 +28,10 @@ final class MobytOptions implements MessageOptionsInterface public function __construct(array $options = []) { + if (isset($options['message_type'])) { + self::validateMessageType($options['message_type']); + } + $this->options = $options; } @@ -68,6 +71,17 @@ public function getRecipientId(): ?string public function messageType(string $type) { + self::validateMessageType($type); + $this->options['message_type'] = $type; } + + public static function validateMessageType($type): string + { + if (!\in_array($type, $supported = [self::MESSAGE_TYPE_QUALITY_HIGH, self::MESSAGE_TYPE_QUALITY_MEDIUM, self::MESSAGE_TYPE_QUALITY_LOW], true)) { + throw new InvalidArgumentException(sprintf('The message type "%s" is not supported; supported message types are: "%s"', $type, implode('", "', $supported))); + } + + return $type; + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransport.php b/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransport.php index 18b37e50065f5..ee0d4efa4a902 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransport.php @@ -13,6 +13,7 @@ use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +23,6 @@ /** * @author Basien Durand - * - * @experimental in 5.2 */ final class MobytTransport extends AbstractTransport { @@ -34,11 +33,15 @@ final class MobytTransport extends AbstractTransport private $from; private $typeQuality; - public function __construct(string $accountSid, string $authToken, string $from, string $typeQuality, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + public function __construct(string $accountSid, string $authToken, string $from, string $typeQuality = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) { $this->accountSid = $accountSid; $this->authToken = $authToken; $this->from = $from; + + $typeQuality = $typeQuality ?? MobytOptions::MESSAGE_TYPE_QUALITY_LOW; + MobytOptions::validateMessageType($typeQuality); + $this->typeQuality = $typeQuality; parent::__construct($client, $dispatcher); @@ -57,7 +60,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, \get_class($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } if ($message->getOptions() && !$message->getOptions() instanceof MobytOptions) { @@ -78,14 +81,16 @@ protected function doSend(MessageInterface $message): SentMessage 'user_key: '.$this->accountSid, 'Access_token: '.$this->authToken, ], - 'body' => json_encode(array_filter($options)), + 'json' => array_filter($options), ]); - if (401 === $response->getStatusCode() || 404 === $response->getStatusCode()) { + $statusCode = $response->getStatusCode(); + + if (401 === $statusCode || 404 === $statusCode) { throw new TransportException(sprintf('Unable to send the SMS: "%s". Check your credentials.', $message->getSubject()), $response); } - if (201 !== $response->getStatusCode()) { + if (201 !== $statusCode) { $error = $response->toArray(false); throw new TransportException(sprintf('Unable to send the SMS: "%s".', $error['result']), $response); diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransportFactory.php index ae86675ee681a..9f352f8f44873 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/MobytTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Mobyt; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Bastien Durand - * - * @experimental in 5.2 */ final class MobytTransportFactory extends AbstractTransportFactory { @@ -37,13 +34,8 @@ public function create(Dsn $dsn): TransportInterface $accountSid = $this->getUser($dsn); $authToken = $this->getPassword($dsn); - $from = $dsn->getOption('from'); - - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - - $typeQuality = $dsn->getOption('type_quality', MobytOptions::MESSAGE_TYPE_QUALITY_LOW); + $from = $dsn->getRequiredOption('from'); + $typeQuality = $dsn->getOption('type_quality'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/README.md b/src/Symfony/Component/Notifier/Bridge/Mobyt/README.md index 0e556c1047103..60f696b23ad48 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/README.md +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/README.md @@ -14,7 +14,7 @@ where: - `USER_KEY` is your Mobyt user key - `ACCESS_TOKEN` is your Mobyt access token - `FROM` is the sender - - `TYPE_QUALITY` is the quality : `N` for high, `L` for medium, `LL` for low (default: `L`) + - `TYPE_QUALITY` is the quality of your message: `N` for high, `L` for medium, `LL` for low (default: `L`) Resources --------- diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/Tests/MobytOptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Mobyt/Tests/MobytOptionsTest.php index a56f587324e0f..9d193ebf7100f 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/Tests/MobytOptionsTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/Tests/MobytOptionsTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Notifier\Bridge\Mobyt\MobytOptions; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; use Symfony\Component\Notifier\Notification\Notification; final class MobytOptionsTest extends TestCase @@ -64,11 +65,43 @@ public function testToArray() $this->assertEmpty($mobytOptions->toArray()); } - public function testMessageType() + /** + * @dataProvider validMessageTypes + */ + public function testMessageType(string $type) + { + $mobytOptions = new MobytOptions(); + $mobytOptions->messageType($type); + + $this->assertSame(['message_type' => $type], $mobytOptions->toArray()); + } + + public function validMessageTypes(): iterable + { + yield [MobytOptions::MESSAGE_TYPE_QUALITY_HIGH]; + yield [MobytOptions::MESSAGE_TYPE_QUALITY_MEDIUM]; + yield [MobytOptions::MESSAGE_TYPE_QUALITY_LOW]; + } + + public function testCallingMessageTypeMethodWithUnknownTypeThrowsInvalidArgumentException() { $mobytOptions = new MobytOptions(); - $mobytOptions->messageType('foo'); - $this->assertSame(['message_type' => 'foo'], $mobytOptions->toArray()); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The message type "foo-bar" is not supported; supported message types are: "N", "L", "LL"'); + + $mobytOptions->messageType('foo-bar'); + } + + public function testSettingMessageTypeViaConstructorWithUnknownTypeThrowsInvalidArgumentException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The message type "foo-bar" is not supported; supported message types are: "N", "L", "LL"' + ); + + new MobytOptions([ + 'message_type' => 'foo-bar', + ]); } } diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/composer.json b/src/Symfony/Component/Notifier/Bridge/Mobyt/composer.json index 1d56cc197e8ea..93217a5b9828f 100644 --- a/src/Symfony/Component/Notifier/Bridge/Mobyt/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/composer.json @@ -19,7 +19,7 @@ "php": ">=7.2.5", "ext-json": "*", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Mobyt\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md index 10f7e1ea8506e..d0d4723934749 100644 --- a/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.0.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.php b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.php index d0faded96e894..25dcb991d9f74 100644 --- a/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Nexmo; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class NexmoTransport extends AbstractTransport { @@ -55,7 +53,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/sms/json', [ diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php index 53dbd15ee3985..6c7287b398658 100644 --- a/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Nexmo; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class NexmoTransportFactory extends AbstractTransportFactory { @@ -37,12 +34,7 @@ public function create(Dsn $dsn): TransportInterface $apiKey = $this->getUser($dsn); $apiSecret = $this->getPassword($dsn); - $from = $dsn->getOption('from'); - - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - + $from = $dsn->getRequiredOption('from'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/Tests/NexmoTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Nexmo/Tests/NexmoTransportFactoryTest.php index 7daeb275367ae..a8753cd5affcd 100644 --- a/src/Symfony/Component/Notifier/Bridge/Nexmo/Tests/NexmoTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/Tests/NexmoTransportFactoryTest.php @@ -39,7 +39,7 @@ public function supportsProvider(): iterable yield [false, 'somethingElse://apiKey:apiSecret@default?from=0611223344']; } - public function incompleteDsnProvider(): iterable + public function missingRequiredOptionProvider(): iterable { yield 'missing option: from' => ['nexmo://apiKey:apiSecret@default']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json b/src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json index c9b9f9d92ff84..74cf98d8cc1fb 100644 --- a/src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Nexmo\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Octopush/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Octopush/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE b/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +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/src/Symfony/Component/Notifier/Bridge/Octopush/OctopushTransport.php b/src/Symfony/Component/Notifier/Bridge/Octopush/OctopushTransport.php new file mode 100644 index 0000000000000..8bba30f197a85 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/OctopushTransport.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Octopush; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Aurélien Martin + */ +final class OctopushTransport extends AbstractTransport +{ + protected const HOST = 'www.octopush-dm.com'; + + private $userLogin; + private $apiKey; + private $from; + private $type; + + public function __construct(string $userLogin, string $apiKey, string $from, string $type, ?HttpClientInterface $client = null, ?EventDispatcherInterface $dispatcher = null) + { + $this->userLogin = $userLogin; + $this->apiKey = $apiKey; + $this->from = $from; + $this->type = $type; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('octopush://%s?from=%s&type=%s', $this->getEndpoint(), $this->from, $this->type); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $endpoint = sprintf('https://%s/api/sms/json', $this->getEndpoint()); + + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'content_type' => 'multipart/form-data', + ], + 'body' => [ + 'user_login' => $this->userLogin, + 'api_key' => $this->apiKey, + 'sms_text' => $message->getSubject(), + 'sms_recipients' => $message->getPhone(), + 'sms_sender' => $this->from, + 'sms_type' => $this->type, + ], + ]); + + if (200 !== $response->getStatusCode()) { + $error = $response->toArray(false); + + throw new TransportException('Unable to send the SMS: '.$error['error_code'], $response); + } + + $success = $response->toArray(false); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($success['ticket']); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/OctopushTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Octopush/OctopushTransportFactory.php new file mode 100644 index 0000000000000..0d93ef6909c2a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/OctopushTransportFactory.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Octopush; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Aurélien Martin + */ +final class OctopushTransportFactory extends AbstractTransportFactory +{ + /** + * @return OctopushTransport + */ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('octopush' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'octopush', $this->getSupportedSchemes()); + } + + $userLogin = urlencode($this->getUser($dsn)); + $apiKey = $this->getPassword($dsn); + $from = $dsn->getRequiredOption('from'); + $type = $dsn->getRequiredOption('type'); + + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new OctopushTransport($userLogin, $apiKey, $from, $type, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['octopush']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/README.md b/src/Symfony/Component/Notifier/Bridge/Octopush/README.md new file mode 100644 index 0000000000000..e025aed7470c7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/README.md @@ -0,0 +1,24 @@ +Octopush Notifier +================= + +Provides [Octopush](https://www.octopush.com) integration for Symfony Notifier. + +DSN example +----------- + +``` +OCTOPUSH_DSN=octopush://USERLOGIN:APIKEY@default?from=FROM&type=TYPE +``` + +where: +- `USERLOGIN` is your Octopush email +- `APIKEY` is your Octopush token +- `FROM` is your sender +- `TYPE` is Octopush sms type (`XXX` = SMS LowCost; `FR` = SMS Premium; `WWW` = SMS World) +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/Tests/OctopushTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Octopush/Tests/OctopushTransportFactoryTest.php new file mode 100644 index 0000000000000..1fcebf6c9a9f2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/Tests/OctopushTransportFactoryTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Octopush\Tests; + +use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +final class OctopushTransportFactoryTest extends TransportFactoryTestCase +{ + /** + * @return OctopushTransportFactory + */ + public function createFactory(): TransportFactoryInterface + { + return new OctopushTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'octopush://host.test?from=Heyliot&type=FR', + 'octopush://userLogin:apiKey@host.test?from=Heyliot&type=FR', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'octopush://userLogin:apiKey@default?from=Heyliot&type=FR']; + yield [false, 'somethingElse://userLogin:apiKet@default?from=Heyliot&type=FR']; + } + + public function missingRequiredOptionProvider(): iterable + { + yield 'missing option: from' => ['octopush://userLogin:apiKey@default?type=FR']; + yield 'missing option: type' => ['octopush://userLogin:apiKey@default?from=Heyliot']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://userLogin:apiKey@default?from=0611223344']; + yield ['somethingElse://userLogin:apiKey@default']; // missing "from" option + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/Tests/OctopushTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Octopush/Tests/OctopushTransportTest.php new file mode 100644 index 0000000000000..713eaeedb8278 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/Tests/OctopushTransportTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Octopush\Tests; + +use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransport; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class OctopushTransportTest extends TransportTestCase +{ + /** + * @return OctopushTransport + */ + public function createTransport(?HttpClientInterface $client = null): TransportInterface + { + return new OctopushTransport('userLogin', 'apiKey', 'from', 'type', $client ?: $this->createMock(HttpClientInterface::class)); + } + + public function toStringProvider(): iterable + { + yield ['octopush://www.octopush-dm.com?from=from&type=type', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('33611223344', 'Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/composer.json b/src/Symfony/Component/Notifier/Bridge/Octopush/composer.json new file mode 100644 index 0000000000000..4bd901677399d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/octopush-notifier", + "type": "symfony-bridge", + "description": "Symfony Octopush Notifier Bridge", + "keywords": ["sms", "octopush", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Aurélien Martin", + "email": "pro@aurelienmartin.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Octopush\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Octopush/phpunit.xml.dist new file mode 100644 index 0000000000000..baaeecd31cfd8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Octopush/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/OvhCloud/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/OvhCloud/CHANGELOG.md index 7bd5e9a57fd19..815ac2a0fed81 100644 --- a/src/Symfony/Component/Notifier/Bridge/OvhCloud/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/OvhCloud/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.1.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransport.php b/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransport.php index 435ec50d5131d..e884e39d7e688 100644 --- a/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\OvhCloud; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Thomas Ferney - * - * @experimental in 5.2 */ final class OvhCloudTransport extends AbstractTransport { @@ -57,7 +55,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $endpoint = sprintf('https://%s/1.0/sms/%s/jobs', $this->getEndpoint(), $this->serviceName); diff --git a/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransportFactory.php index 73438410c034b..eacf9efcad14e 100644 --- a/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/OvhCloud/OvhCloudTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\OvhCloud; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Thomas Ferney - * - * @experimental in 5.2 */ final class OvhCloudTransportFactory extends AbstractTransportFactory { @@ -34,18 +31,8 @@ public function create(Dsn $dsn): TransportInterface $applicationKey = $this->getUser($dsn); $applicationSecret = $this->getPassword($dsn); - $consumerKey = $dsn->getOption('consumer_key'); - - if (!$consumerKey) { - throw new IncompleteDsnException('Missing consumer_key.', $dsn->getOriginalDsn()); - } - - $serviceName = $dsn->getOption('service_name'); - - if (!$serviceName) { - throw new IncompleteDsnException('Missing service_name.', $dsn->getOriginalDsn()); - } - + $consumerKey = $dsn->getRequiredOption('consumer_key'); + $serviceName = $dsn->getRequiredOption('service_name'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/OvhCloud/Tests/OvhCloudTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/OvhCloud/Tests/OvhCloudTransportFactoryTest.php index b775defdd83b7..918758f5deb22 100644 --- a/src/Symfony/Component/Notifier/Bridge/OvhCloud/Tests/OvhCloudTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/OvhCloud/Tests/OvhCloudTransportFactoryTest.php @@ -39,7 +39,7 @@ public function supportsProvider(): iterable yield [false, 'somethingElse://key:secret@default?consumer_key=consumerKey&service_name=serviceName']; } - public function incompleteDsnProvider(): iterable + public function missingRequiredOptionProvider(): iterable { yield 'missing option: consumer_key' => ['ovhcloud://key:secret@default?service_name=serviceName']; yield 'missing option: service_name' => ['ovhcloud://key:secret@default?consumer_key=consumerKey']; diff --git a/src/Symfony/Component/Notifier/Bridge/OvhCloud/composer.json b/src/Symfony/Component/Notifier/Bridge/OvhCloud/composer.json index 67c0ddfe4ceb6..7030cb30143d0 100644 --- a/src/Symfony/Component/Notifier/Bridge/OvhCloud/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/OvhCloud/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\OvhCloud\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/RocketChat/CHANGELOG.md index 7bd5e9a57fd19..815ac2a0fed81 100644 --- a/src/Symfony/Component/Notifier/Bridge/RocketChat/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.1.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatOptions.php b/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatOptions.php index 06ab1fc7194a6..d7f5d63bfb979 100644 --- a/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatOptions.php @@ -16,8 +16,6 @@ /** * @author Jeroen Spee * - * @experimental in 5.2 - * * @see https://rocket.chat/docs/administrator-guides/integrations/ */ final class RocketChatOptions implements MessageOptionsInterface diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransport.php b/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransport.php index fdf3eafd4a9c9..abd8ce6b86a91 100644 --- a/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransport.php @@ -13,6 +13,7 @@ use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -22,8 +23,6 @@ /** * @author Jeroen Spee - * - * @experimental in 5.2 */ final class RocketChatTransport extends AbstractTransport { @@ -61,7 +60,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } if ($message->getOptions() && !$message->getOptions() instanceof RocketChatOptions) { throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" for options.', __CLASS__, RocketChatOptions::class)); diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransportFactory.php index 39fca9aa55b89..abba7062baa06 100644 --- a/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/RocketChatTransportFactory.php @@ -18,8 +18,6 @@ /** * @author Jeroen Spee - * - * @experimental in 5.2 */ final class RocketChatTransportFactory extends AbstractTransportFactory { diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/Tests/RocketChatTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/RocketChat/Tests/RocketChatTransportFactoryTest.php index 81fee6e9958a4..2c83d04ff48a2 100644 --- a/src/Symfony/Component/Notifier/Bridge/RocketChat/Tests/RocketChatTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/Tests/RocketChatTransportFactoryTest.php @@ -44,7 +44,7 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { - yield 'missing option: token' => ['rocketchat://host.test?channel=testChannel']; + yield 'missing token' => ['rocketchat://host.test?channel=testChannel']; } public function unsupportedSchemeProvider(): iterable diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/composer.json b/src/Symfony/Component/Notifier/Bridge/RocketChat/composer.json index 53974c32a20a9..823f51d02cef6 100644 --- a/src/Symfony/Component/Notifier/Bridge/RocketChat/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\RocketChat\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Sendinblue/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Sendinblue/CHANGELOG.md index 0d994e934e55a..91b7e5fb62ef8 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sendinblue/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Sendinblue/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransport.php b/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransport.php index 7964d4e20cdfb..0e5994ae31a37 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Sendinblue; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Pierre Tondereau - * - * @experimental in 5.2 */ final class SendinblueTransport extends AbstractTransport { @@ -53,7 +51,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/v3/transactionalSMS/sms', [ diff --git a/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransportFactory.php index daed3ed3d6e61..bd38a43762bdb 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Sendinblue/SendinblueTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Sendinblue; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Pierre Tondereau - * - * @experimental in 5.2 */ final class SendinblueTransportFactory extends AbstractTransportFactory { @@ -36,12 +33,7 @@ public function create(Dsn $dsn): TransportInterface } $apiKey = $this->getUser($dsn); - $sender = $dsn->getOption('sender'); - - if (!$sender) { - throw new IncompleteDsnException('Missing sender.', $dsn->getOriginalDsn()); - } - + $sender = $dsn->getRequiredOption('sender'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Sendinblue/Tests/SendinblueTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Sendinblue/Tests/SendinblueTransportFactoryTest.php index 4e573f33e864f..3ff841d9399e0 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sendinblue/Tests/SendinblueTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Sendinblue/Tests/SendinblueTransportFactoryTest.php @@ -42,6 +42,10 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { yield 'missing api_key' => ['sendinblue://default?sender=0611223344']; + } + + public function missingRequiredOptionProvider(): iterable + { yield 'missing option: sender' => ['sendinblue://apiKey@host.test']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Sendinblue/composer.json b/src/Symfony/Component/Notifier/Bridge/Sendinblue/composer.json index 02a0217a13727..68e44aa84be8f 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sendinblue/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Sendinblue/composer.json @@ -19,7 +19,7 @@ "php": ">=7.2.5", "ext-json": "*", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Sendinblue\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Sinch/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Sinch/CHANGELOG.md index abf66cd8cac35..a62f037244a9d 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sinch/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Sinch/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.1 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransport.php b/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransport.php index 10f272db40258..55ff922db4b43 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Sinch; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Iliya Miroslavov Iliev - * - * @experimental in 5.2 */ final class SinchTransport extends AbstractTransport { @@ -55,7 +53,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $endpoint = sprintf('https://%s/xms/v1/%s/batches', $this->getEndpoint(), $this->accountSid); diff --git a/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransportFactory.php index 9bcae92579eb0..30ac8a929942a 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Sinch/SinchTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Sinch; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Iliya Miroslavov Iliev - * - * @experimental in 5.2 */ final class SinchTransportFactory extends AbstractTransportFactory { @@ -34,12 +31,7 @@ public function create(Dsn $dsn): TransportInterface $accountSid = $this->getUser($dsn); $authToken = $this->getPassword($dsn); - $from = $dsn->getOption('from'); - - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - + $from = $dsn->getRequiredOption('from'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Sinch/Tests/SinchTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Sinch/Tests/SinchTransportFactoryTest.php index 367342e8a636f..1ed1dd779c587 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sinch/Tests/SinchTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Sinch/Tests/SinchTransportFactoryTest.php @@ -39,7 +39,7 @@ public function supportsProvider(): iterable yield [false, 'somethingElse://accountSid:authToken@default?from=0611223344']; } - public function incompleteDsnProvider(): iterable + public function missingRequiredOptionProvider(): iterable { yield 'missing option: from' => ['sinch://accountSid:authToken@default']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Sinch/composer.json b/src/Symfony/Component/Notifier/Bridge/Sinch/composer.json index fab21f3dc4146..6ff9d8e282ea0 100644 --- a/src/Symfony/Component/Notifier/Bridge/Sinch/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Sinch/composer.json @@ -19,7 +19,7 @@ "php": ">=7.2.5", "ext-json": "*", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Sinch\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackActionsBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackActionsBlock.php index 85b63d2109d71..25c4c06338bea 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackActionsBlock.php +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackActionsBlock.php @@ -26,6 +26,10 @@ public function __construct() */ public function button(string $text, string $url, string $style = null): self { + if (25 === \count($this->options['elements'] ?? [])) { + throw new \LogicException('Maximum number of buttons should not exceed 25.'); + } + $element = [ 'type' => 'button', 'text' => [ diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackContextBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackContextBlock.php new file mode 100644 index 0000000000000..e3ece805e2421 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackContextBlock.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +final class SlackContextBlock extends AbstractSlackBlock +{ + private const ELEMENT_LIMIT = 10; + + public function __construct() + { + $this->options['type'] = 'context'; + } + + public function text(string $text, bool $markdown = true, bool $emoji = true, bool $verbatim = false): self + { + if (self::ELEMENT_LIMIT === \count($this->options['elements'] ?? [])) { + throw new \LogicException(sprintf('Maximum number of elements should not exceed %d.', self::ELEMENT_LIMIT)); + } + + $element = [ + 'type' => $markdown ? 'mrkdwn' : 'plain_text', + 'text' => $text, + ]; + if ($markdown) { + $element['verbatim'] = $verbatim; + } else { + $element['emoji'] = $emoji; + } + $this->options['elements'][] = $element; + + return $this; + } + + public function image(string $url, string $text): self + { + if (self::ELEMENT_LIMIT === \count($this->options['elements'] ?? [])) { + throw new \LogicException(sprintf('Maximum number of elements should not exceed %d.', self::ELEMENT_LIMIT)); + } + + $this->options['elements'][] = [ + 'type' => 'image', + 'image_url' => $url, + 'alt_text' => $text, + ]; + + return $this; + } + + public function id(string $id): self + { + $this->options['block_id'] = $id; + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackHeaderBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackHeaderBlock.php new file mode 100644 index 0000000000000..bbcb19e7ed2f8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackHeaderBlock.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +use Symfony\Component\Notifier\Exception\LengthException; + +/** + * @author Tomas Norkūnas + */ +final class SlackHeaderBlock extends AbstractSlackBlock +{ + private const TEXT_LIMIT = 150; + private const ID_LIMIT = 255; + + public function __construct(string $text) + { + if (\strlen($text) > self::TEXT_LIMIT) { + throw new LengthException(sprintf('Maximum length for the text is %d characters.', self::TEXT_LIMIT)); + } + + $this->options = [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => $text, + ], + ]; + } + + public function id(string $id): self + { + if (\strlen($id) > self::ID_LIMIT) { + throw new LengthException(sprintf('Maximum length for the block id is %d characters.', self::ID_LIMIT)); + } + + $this->options['block_id'] = $id; + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md index fcc1eb79f8319..290723a59d6c9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * Check for maximum number of buttons in Slack action block + * Add HeaderBlock + * Slack access tokens needs to start with "xox" (see https://api.slack.com/authentication/token-types) + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php b/src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php index 6f5dbd47eb693..ec5e6a1b2ea3c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php @@ -19,8 +19,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class SlackOptions implements MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php index 5a98638ab30d5..b25db1ebc7d65 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php @@ -11,8 +11,10 @@ namespace Symfony\Component\Notifier\Bridge\Slack; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -22,8 +24,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class SlackTransport extends AbstractTransport { @@ -34,6 +34,10 @@ final class SlackTransport extends AbstractTransport public function __construct(string $accessToken, string $channel = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) { + if (!preg_match('/^xox(b-|p-|a-2)/', $accessToken)) { + throw new InvalidArgumentException('A valid Slack token needs to start with "xoxb-", "xoxp-" or "xoxa-2". See https://api.slack.com/authentication/token-types for further information.'); + } + $this->accessToken = $accessToken; $this->chatChannel = $channel; $this->client = $client; @@ -61,7 +65,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } if ($message->getOptions() && !$message->getOptions() instanceof SlackOptions) { diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php index 6e97fc42ed1a2..ea724d0003019 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php @@ -19,8 +19,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class SlackTransportFactory extends AbstractTransportFactory { diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackActionsBlockTest.php b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackActionsBlockTest.php new file mode 100644 index 0000000000000..2a21a39133c1f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackActionsBlockTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Tests\Block; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackActionsBlock; + +final class SlackActionsBlockTest extends TestCase +{ + public function testCanBeInstantiated() + { + $actions = new SlackActionsBlock(); + $actions->button('first button text', 'https://example.org') + ->button('second button text', 'https://example.org/slack', 'danger') + ; + + $this->assertSame([ + 'type' => 'actions', + 'elements' => [ + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'first button text', + ], + 'url' => 'https://example.org', + ], + [ + 'type' => 'button', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'second button text', + ], + 'url' => 'https://example.org/slack', + 'style' => 'danger', + ], + ], + ], $actions->toArray()); + } + + public function testThrowsWhenFieldsLimitReached() + { + $section = new SlackActionsBlock(); + for ($i = 0; $i < 25; ++$i) { + $section->button($i, $i); + } + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Maximum number of buttons should not exceed 25.'); + + $section->button('fail', 'fail'); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackContextBlockTest.php b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackContextBlockTest.php new file mode 100644 index 0000000000000..ebdced0a41d09 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackContextBlockTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Tests\Block; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackContextBlock; + +final class SlackContextBlockTest extends TestCase +{ + public function testCanBeInstantiated() + { + $context = new SlackContextBlock(); + $context->text('context text without emoji', false, false); + $context->text('context text verbatim', true, false, true); + $context->image('https://example.com/image.jpg', 'an image'); + $context->id('context_id'); + + $this->assertSame([ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'plain_text', + 'text' => 'context text without emoji', + 'emoji' => false, + ], + [ + 'type' => 'mrkdwn', + 'text' => 'context text verbatim', + 'verbatim' => true, + ], + [ + 'type' => 'image', + 'image_url' => 'https://example.com/image.jpg', + 'alt_text' => 'an image', + ], + ], + 'block_id' => 'context_id', + ], $context->toArray()); + } + + public function testThrowsWhenElementsLimitReached() + { + $context = new SlackContextBlock(); + for ($i = 0; $i < 10; ++$i) { + if (0 === $i % 2) { + $context->text($i); + } else { + $context->image($i, $i); + } + } + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Maximum number of elements should not exceed 10.'); + + $context->text('fail'); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackHeaderBlockTest.php b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackHeaderBlockTest.php new file mode 100644 index 0000000000000..5eb398f3c0d53 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/Block/SlackHeaderBlockTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Tests\Block; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackHeaderBlock; +use Symfony\Component\Notifier\Exception\LengthException; + +final class SlackHeaderBlockTest extends TestCase +{ + public function testCanBeInstantiated() + { + $header = new SlackHeaderBlock('header text'); + $header->id('header_id'); + + $this->assertSame([ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'header text', + ], + 'block_id' => 'header_id', + ], $header->toArray()); + } + + public function testThrowsWhenTextExceedsCharacterLimit() + { + $this->expectException(LengthException::class); + $this->expectExceptionMessage('Maximum length for the text is 150 characters.'); + + new SlackHeaderBlock(str_repeat('h', 151)); + } + + public function testThrowsWhenBlockIdExceedsCharacterLimit() + { + $this->expectException(LengthException::class); + $this->expectExceptionMessage('Maximum length for the block id is 255 characters.'); + + $header = new SlackHeaderBlock('header'); + $header->id(str_repeat('h', 256)); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportFactoryTest.php index 8b7db84bb8aa3..3841ebbb51687 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportFactoryTest.php @@ -31,17 +31,17 @@ public function createProvider(): iterable { yield [ 'slack://host.test', - 'slack://testUser@host.test', + 'slack://xoxb-TestToken@host.test', ]; yield 'with path' => [ 'slack://host.test?channel=testChannel', - 'slack://testUser@host.test/?channel=testChannel', + 'slack://xoxb-TestToken@host.test/?channel=testChannel', ]; yield 'without path' => [ 'slack://host.test?channel=testChannel', - 'slack://testUser@host.test?channel=testChannel', + 'slack://xoxb-TestToken@host.test?channel=testChannel', ]; } @@ -52,13 +52,13 @@ public function testCreateWithDeprecatedDsn() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Support for Slack webhook DSN has been dropped since 5.2 (maybe you haven\'t updated the DSN when upgrading from 5.1).'); - $factory->create(Dsn::fromString('slack://default/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX')); + $factory->create(new Dsn('slack://default/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX')); } public function supportsProvider(): iterable { - yield [true, 'slack://host?channel=testChannel']; - yield [false, 'somethingElse://host?channel=testChannel']; + yield [true, 'slack://xoxb-TestToken@host?channel=testChannel']; + yield [false, 'somethingElse://xoxb-TestToken@host?channel=testChannel']; } public function incompleteDsnProvider(): iterable @@ -68,6 +68,6 @@ public function incompleteDsnProvider(): iterable public function unsupportedSchemeProvider(): iterable { - yield ['somethingElse://host?channel=testChannel']; + yield ['somethingElse://xoxb-TestToken@host?channel=testChannel']; } } diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportTest.php index 3b7a3c6f8ff64..12dacf80b08b9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Tests/SlackTransportTest.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\Notifier\Bridge\Slack\SlackOptions; use Symfony\Component\Notifier\Bridge\Slack\SlackTransport; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Message\ChatMessage; @@ -33,7 +34,7 @@ final class SlackTransportTest extends TransportTestCase */ public function createTransport(?HttpClientInterface $client = null, string $channel = null): TransportInterface { - return new SlackTransport('testToken', $channel, $client ?: $this->createMock(HttpClientInterface::class)); + return new SlackTransport('xoxb-TestToken', $channel, $client ?: $this->createMock(HttpClientInterface::class)); } public function toStringProvider(): iterable @@ -53,6 +54,14 @@ public function unsupportedMessagesProvider(): iterable yield [$this->createMock(MessageInterface::class)]; } + public function testInstatiatingWithAnInvalidSlackTokenThrowsInvalidArgumentException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A valid Slack token needs to start with "xoxb-", "xoxp-" or "xoxa-2". See https://api.slack.com/authentication/token-types for further information.'); + + new SlackTransport('token', 'testChannel', $this->createMock(HttpClientInterface::class)); + } + public function testSendWithEmptyArrayResponseThrowsTransportException() { $this->expectException(TransportException::class); diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/composer.json b/src/Symfony/Component/Notifier/Bridge/Slack/composer.json index a4bae48a27fb4..ae69796774d9b 100644 --- a/src/Symfony/Component/Notifier/Bridge/Slack/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Slack/composer.json @@ -19,7 +19,7 @@ "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "require-dev": { "symfony/event-dispatcher": "^4.3|^5.0" diff --git a/src/Symfony/Component/Notifier/Bridge/Smsapi/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Smsapi/CHANGELOG.md new file mode 100644 index 0000000000000..bb2aae9f68d08 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsapi/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransport.php b/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransport.php index bab366232bfe3..79df322bf1ae9 100644 --- a/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Smsapi; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Marcin Szepczynski - * - * @experimental in 5.2 */ final class SmsapiTransport extends AbstractTransport { @@ -53,7 +51,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $endpoint = sprintf('https://%s/sms.do', $this->getEndpoint()); diff --git a/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransportFactory.php index cbc31818688ff..0234850b0cd6d 100644 --- a/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Smsapi/SmsapiTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Smsapi; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,10 +18,8 @@ /** * @author Marcin Szepczynski - * - * @experimental in 5.2 */ -class SmsapiTransportFactory extends AbstractTransportFactory +final class SmsapiTransportFactory extends AbstractTransportFactory { /** * @return SmsapiTransport @@ -36,12 +33,7 @@ public function create(Dsn $dsn): TransportInterface } $authToken = $this->getUser($dsn); - $from = $dsn->getOption('from'); - - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - + $from = $dsn->getRequiredOption('from'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Smsapi/Tests/SmsapiTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Smsapi/Tests/SmsapiTransportFactoryTest.php index ed7af22dbc16f..927a885360606 100644 --- a/src/Symfony/Component/Notifier/Bridge/Smsapi/Tests/SmsapiTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Smsapi/Tests/SmsapiTransportFactoryTest.php @@ -42,6 +42,10 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { yield 'missing token' => ['smsapi://host.test?from=testFrom']; + } + + public function missingRequiredOptionProvider(): iterable + { yield 'missing option: from' => ['smsapi://token@host']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Smsapi/composer.json b/src/Symfony/Component/Notifier/Bridge/Smsapi/composer.json index 9e1e4db894491..28564eaa91c96 100644 --- a/src/Symfony/Component/Notifier/Bridge/Smsapi/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Smsapi/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Smsapi\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/SpotHit/CHANGELOG.md new file mode 100644 index 0000000000000..1f2b652ac20ea --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/README.md b/src/Symfony/Component/Notifier/Bridge/SpotHit/README.md new file mode 100644 index 0000000000000..b3c8fcaa72a9e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/README.md @@ -0,0 +1,23 @@ +Spot-Hit Notifier +================= + +Provides [Spot-Hit](https://www.spot-hit.fr/) integration for Symfony Notifier. + +#### DSN example + +``` +SPOTHIT_DSN=spothit://TOKEN@default?from=FROM +``` + +where: + - `TOKEN` is your Spot-Hit API key + - `FROM` is the custom sender (3-11 letters, default is a 5 digits phone number) + +Resources +--------- + + * [Spot-Hit API doc](https://www.spot-hit.fr/documentation-api). + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/SpotHitTransport.php b/src/Symfony/Component/Notifier/Bridge/SpotHit/SpotHitTransport.php new file mode 100644 index 0000000000000..1e3f362562ff0 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/SpotHitTransport.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\SpotHit; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author James Hemery + */ +final class SpotHitTransport extends AbstractTransport +{ + protected const HOST = 'spot-hit.fr'; + + private $token; + private $from; + + public function __construct(string $token, ?string $from = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->token = $token; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + if (!$this->from) { + return sprintf('spothit://%s', $this->getEndpoint()); + } + + return sprintf('spothit://%s?from=%s', $this->getEndpoint(), $this->from); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + /** + * @param MessageInterface|SmsMessage $message + * + * @throws TransportExceptionInterface + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$this->supports($message)) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $endpoint = sprintf('https://www.%s/api/envoyer/sms', $this->getEndpoint()); + $response = $this->client->request('POST', $endpoint, [ + 'body' => [ + 'key' => $this->token, + 'destinataires' => $message->getPhone(), + 'type' => 'premium', + 'message' => $message->getSubject(), + 'expediteur' => $this->from, + ], + ]); + + $data = json_decode($response->getContent(), true); + + if (!$data['resultat']) { + $errors = \is_array($data['erreurs']) ? implode(',', $data['erreurs']) : $data['erreurs']; + throw new TransportException(sprintf('[HTTP %d] Unable to send the SMS: error(s) "%s".', $response->getStatusCode(), $errors), $response); + } + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($data['id']); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/SpotHitTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/SpotHit/SpotHitTransportFactory.php new file mode 100644 index 0000000000000..912b4e2e0f1ec --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/SpotHitTransportFactory.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\SpotHit; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author James Hemery + */ +final class SpotHitTransportFactory extends AbstractTransportFactory +{ + /** + * @return SpotHitTransport + */ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('spothit' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'spothit', $this->getSupportedSchemes()); + } + + $token = $this->getUser($dsn); + $from = $dsn->getOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new SpotHitTransport($token, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['spothit']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/Tests/SpotHitTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/SpotHit/Tests/SpotHitTransportFactoryTest.php new file mode 100644 index 0000000000000..1463c845bf8bf --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/Tests/SpotHitTransportFactoryTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\SpotHit\Tests; + +use Symfony\Component\Notifier\Bridge\SpotHit\SpotHitTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +final class SpotHitTransportFactoryTest extends TransportFactoryTestCase +{ + /** + * @return SpotHitTransportFactory + */ + public function createFactory(): TransportFactoryInterface + { + return new SpotHitTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'spothit://spot-hit.fr', + 'spothit://api_token@default', + ]; + yield [ + 'spothit://spot-hit.fr?from=MyCompany', + 'spothit://api_token@default?from=MyCompany', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'spothit://api_token@default?from=MyCompany']; + yield [false, 'somethingElse://api_token@default?from=MyCompany']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['foobar://api_token@default?from=MyCompany']; + yield ['foobar://api_token@default']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/Tests/SpotHitTransportTest.php b/src/Symfony/Component/Notifier/Bridge/SpotHit/Tests/SpotHitTransportTest.php new file mode 100644 index 0000000000000..755e9ec2f3988 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/Tests/SpotHitTransportTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\SpotHit\Tests; + +use Symfony\Component\Notifier\Bridge\SpotHit\SpotHitTransport; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class SpotHitTransportTest extends TransportTestCase +{ + /** + * @return SpotHitTransport + */ + public function createTransport(?HttpClientInterface $client = null): TransportInterface + { + return (new SpotHitTransport('api_token', 'MyCompany', $client ?: $this->createMock(HttpClientInterface::class)))->setHost('host.test'); + } + + public function toStringProvider(): iterable + { + yield ['spothit://host.test?from=MyCompany', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + yield [new SmsMessage('+33611223344', 'Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/composer.json b/src/Symfony/Component/Notifier/Bridge/SpotHit/composer.json new file mode 100644 index 0000000000000..32ec5f8b01283 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/spot-hit-notifier", + "type": "symfony-bridge", + "description": "Symfony Spot-Hit Notifier Bridge", + "keywords": ["sms", "spot-hit", "notifier", "symfony"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "James Hemery", + "homepage": "https://github.com/JamesHemery" + }, + { + "name": "Yield Studio", + "homepage": "https://github.com/YieldStudio" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.1", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\SpotHit\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/SpotHit/phpunit.xml.dist new file mode 100644 index 0000000000000..d51b53d4240c9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md index 10f7e1ea8506e..d0d4723934749 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.0.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/AbstractTelegramReplyMarkup.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/AbstractTelegramReplyMarkup.php index 27de874725f2d..8bf4bb0cd3db6 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/AbstractTelegramReplyMarkup.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/AbstractTelegramReplyMarkup.php @@ -13,8 +13,6 @@ /** * @author Mihail Krasilnikov - * - * @experimental in 5.2 */ abstract class AbstractTelegramReplyMarkup { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/AbstractKeyboardButton.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/AbstractKeyboardButton.php index 29c2ef54b4e3c..03d05ab4f4679 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/AbstractKeyboardButton.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/AbstractKeyboardButton.php @@ -13,8 +13,6 @@ /** * @author Mihail Krasilnikov - * - * @experimental in 5.2 */ abstract class AbstractKeyboardButton { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/InlineKeyboardButton.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/InlineKeyboardButton.php index 63e05a6772e68..89e4c4f1ecf3f 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/InlineKeyboardButton.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/InlineKeyboardButton.php @@ -15,8 +15,6 @@ * @author Mihail Krasilnikov * * @see https://core.telegram.org/bots/api#inlinekeyboardbutton - * - * @experimental in 5.2 */ final class InlineKeyboardButton extends AbstractKeyboardButton { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/KeyboardButton.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/KeyboardButton.php index c2b19ed97cce8..3e8240baa2e7f 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/KeyboardButton.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/Button/KeyboardButton.php @@ -15,8 +15,6 @@ * @author Mihail Krasilnikov * * @see https://core.telegram.org/bots/api#keyboardbutton - * - * @experimental in 5.2 */ final class KeyboardButton extends AbstractKeyboardButton { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ForceReply.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ForceReply.php index 43ca3bae3e9de..63779c4165491 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ForceReply.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ForceReply.php @@ -15,8 +15,6 @@ * @author Mihail Krasilnikov * * @see https://core.telegram.org/bots/api#forcereply - * - * @experimental in 5.2 */ final class ForceReply extends AbstractTelegramReplyMarkup { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/InlineKeyboardMarkup.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/InlineKeyboardMarkup.php index cbf35d724c29c..c7cc371ea7c08 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/InlineKeyboardMarkup.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/InlineKeyboardMarkup.php @@ -17,8 +17,6 @@ * @author Mihail Krasilnikov * * @see https://core.telegram.org/bots/api#inlinekeyboardmarkup - * - * @experimental in 5.2 */ final class InlineKeyboardMarkup extends AbstractTelegramReplyMarkup { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardMarkup.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardMarkup.php index 9070c67720c38..a5b8ef600d69c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardMarkup.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardMarkup.php @@ -17,8 +17,6 @@ * @author Mihail Krasilnikov * * @see https://core.telegram.org/bots/api#replykeyboardmarkup - * - * @experimental in 5.2 */ final class ReplyKeyboardMarkup extends AbstractTelegramReplyMarkup { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardRemove.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardRemove.php index 81cbe57199730..b4a2daee10484 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardRemove.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Reply/Markup/ReplyKeyboardRemove.php @@ -15,8 +15,6 @@ * @author Mihail Krasilnikov * * @see https://core.telegram.org/bots/api#replykeyboardremove - * - * @experimental in 5.2 */ final class ReplyKeyboardRemove extends AbstractTelegramReplyMarkup { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php index 5e6d637de9efe..9794d8744ee0d 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php @@ -16,8 +16,6 @@ /** * @author Mihail Krasilnikov - * - * @experimental in 5.2 */ final class TelegramOptions implements MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php index 9b5623170caa0..a31a394c518b7 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php @@ -13,6 +13,7 @@ use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -26,8 +27,6 @@ * command. * * @author Fabien Potencier - * - * @experimental in 5.2 */ final class TelegramTransport extends AbstractTransport { @@ -65,7 +64,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } if ($message->getOptions() && !$message->getOptions() instanceof TelegramOptions) { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php index 5a2ffd7785b5e..490fe1d26e9b7 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php @@ -19,8 +19,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class TelegramTransportFactory extends AbstractTransportFactory { diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json b/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json index 344a93d2df4b4..ffca90186448a 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "require-dev": { "symfony/event-dispatcher": "^4.3|^5.0" diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md index 10f7e1ea8506e..d0d4723934749 100644 --- a/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + 5.0.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/Tests/TwilioTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Twilio/Tests/TwilioTransportFactoryTest.php index b7b3bab9f2814..f5cf9a29ade25 100644 --- a/src/Symfony/Component/Notifier/Bridge/Twilio/Tests/TwilioTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/Tests/TwilioTransportFactoryTest.php @@ -39,7 +39,7 @@ public function supportsProvider(): iterable yield [false, 'somethingElse://accountSid:authToken@default?from=0611223344']; } - public function incompleteDsnProvider(): iterable + public function missingRequiredOptionProvider(): iterable { yield 'missing option: from' => ['twilio://accountSid:authToken@default']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php index 3f7e73d1367da..f7d7f186eb689 100644 --- a/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php @@ -11,8 +11,8 @@ namespace Symfony\Component\Notifier\Bridge\Twilio; -use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -22,8 +22,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class TwilioTransport extends AbstractTransport { @@ -55,7 +53,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof SmsMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); } $endpoint = sprintf('https://%s/2010-04-01/Accounts/%s/Messages.json', $this->getEndpoint(), $this->accountSid); diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php index 169e0bb2397ef..2009d63dc4ea5 100644 --- a/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Twilio; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,8 +18,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class TwilioTransportFactory extends AbstractTransportFactory { @@ -37,12 +34,7 @@ public function create(Dsn $dsn): TransportInterface $accountSid = $this->getUser($dsn); $authToken = $this->getPassword($dsn); - $from = $dsn->getOption('from'); - - if (!$from) { - throw new IncompleteDsnException('Missing from.', $dsn->getOriginalDsn()); - } - + $from = $dsn->getRequiredOption('from'); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json b/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json index 1e2f55bb96113..04bc30f7306ca 100644 --- a/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Twilio\\": "" }, diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Zulip/CHANGELOG.md index 0d994e934e55a..c31a4cede0a54 100644 --- a/src/Symfony/Component/Notifier/Bridge/Zulip/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Zulip/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +5.3 +--- + + * The bridge is not marked as `@experimental` anymore + * [BC BREAK] `ZulipTransport` is now final + * [BC BREAK] `ZulipTransportFactory` is now final + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/Tests/ZulipTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Zulip/Tests/ZulipTransportFactoryTest.php index e5514604355c0..ffca3cda0c603 100644 --- a/src/Symfony/Component/Notifier/Bridge/Zulip/Tests/ZulipTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Zulip/Tests/ZulipTransportFactoryTest.php @@ -42,6 +42,10 @@ public function supportsProvider(): iterable public function incompleteDsnProvider(): iterable { yield 'missing email or token' => ['zulip://testOneOfEmailOrToken@host.test?channel=testChannel']; + } + + public function missingRequiredOptionProvider(): iterable + { yield 'missing option: channel' => ['zulip://email:token@host']; } diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipOptions.php b/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipOptions.php index 963131d382ca5..49059384fc76c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipOptions.php @@ -15,8 +15,6 @@ /** * @author Mohammad Emran Hasan - * - * @experimental in 5.2 */ final class ZulipOptions implements MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransport.php b/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransport.php index 9e1315485edc2..82290188ea91a 100644 --- a/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransport.php @@ -13,6 +13,7 @@ use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Message\SentMessage; @@ -22,10 +23,8 @@ /** * @author Mohammad Emran Hasan - * - * @experimental in 5.2 */ -class ZulipTransport extends AbstractTransport +final class ZulipTransport extends AbstractTransport { private $email; private $token; @@ -56,7 +55,7 @@ public function supports(MessageInterface $message): bool protected function doSend(MessageInterface $message): SentMessage { if (!$message instanceof ChatMessage) { - throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message))); + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } if (null !== $message->getOptions() && !($message->getOptions() instanceof ZulipOptions)) { diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransportFactory.php index 3d2c870cf67cd..8d54924c06f62 100644 --- a/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Zulip/ZulipTransportFactory.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Notifier\Bridge\Zulip; -use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; @@ -19,10 +18,8 @@ /** * @author Mohammad Emran Hasan - * - * @experimental in 5.2 */ -class ZulipTransportFactory extends AbstractTransportFactory +final class ZulipTransportFactory extends AbstractTransportFactory { /** * @return ZulipTransport @@ -37,12 +34,7 @@ public function create(Dsn $dsn): TransportInterface $email = $this->getUser($dsn); $token = $this->getPassword($dsn); - $channel = $dsn->getOption('channel'); - - if (!$channel) { - throw new IncompleteDsnException('Missing channel.', $dsn->getOriginalDsn()); - } - + $channel = $dsn->getRequiredOption('channel'); $host = $dsn->getHost(); $port = $dsn->getPort(); diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/composer.json b/src/Symfony/Component/Notifier/Bridge/Zulip/composer.json index 7c2354159873a..b96a349288591 100644 --- a/src/Symfony/Component/Notifier/Bridge/Zulip/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Zulip/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/http-client": "^4.3|^5.0", - "symfony/notifier": "~5.2.2" + "symfony/notifier": "^5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Zulip\\": "" }, diff --git a/src/Symfony/Component/Notifier/CHANGELOG.md b/src/Symfony/Component/Notifier/CHANGELOG.md index da11d12c3e110..84079ddd7ea94 100644 --- a/src/Symfony/Component/Notifier/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/CHANGELOG.md @@ -1,6 +1,18 @@ CHANGELOG ========= +5.3 +--- + + * The component is not marked as `@experimental` anymore + * [BC BREAK] Change signature of `Dsn::__construct()` method from: + `public function __construct(string $scheme, string $host, ?string $user = null, ?string $password = null, ?int $port = null, array $options = [], ?string $path = null)` + to: + `public function __construct(string $dsn)` + * [BC BREAK] Remove `Dsn::fromString()` method + * [BC BREAK] Changed the return type of `AbstractTransportFactory::getEndpoint()` from `?string` to `string` + * Added `DSN::getRequiredOption` method which throws a new `MissingRequiredOptionException`. + 5.2.0 ----- diff --git a/src/Symfony/Component/Notifier/Channel/AbstractChannel.php b/src/Symfony/Component/Notifier/Channel/AbstractChannel.php index c97a191636b68..a83a51da4115c 100644 --- a/src/Symfony/Component/Notifier/Channel/AbstractChannel.php +++ b/src/Symfony/Component/Notifier/Channel/AbstractChannel.php @@ -17,8 +17,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ abstract class AbstractChannel implements ChannelInterface { diff --git a/src/Symfony/Component/Notifier/Channel/BrowserChannel.php b/src/Symfony/Component/Notifier/Channel/BrowserChannel.php index 6026d0fd0de40..0201e0f1382b0 100644 --- a/src/Symfony/Component/Notifier/Channel/BrowserChannel.php +++ b/src/Symfony/Component/Notifier/Channel/BrowserChannel.php @@ -17,8 +17,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class BrowserChannel implements ChannelInterface { diff --git a/src/Symfony/Component/Notifier/Channel/ChannelInterface.php b/src/Symfony/Component/Notifier/Channel/ChannelInterface.php index 0ac9f44c19311..ab3115230d79d 100644 --- a/src/Symfony/Component/Notifier/Channel/ChannelInterface.php +++ b/src/Symfony/Component/Notifier/Channel/ChannelInterface.php @@ -16,8 +16,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface ChannelInterface { diff --git a/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php b/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php index 7d525850fd4ac..b333950183213 100644 --- a/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php +++ b/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php @@ -15,8 +15,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class ChannelPolicy implements ChannelPolicyInterface { diff --git a/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php b/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php index 5559ec59c7df2..df236d229cd44 100644 --- a/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php +++ b/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface ChannelPolicyInterface { diff --git a/src/Symfony/Component/Notifier/Channel/ChatChannel.php b/src/Symfony/Component/Notifier/Channel/ChatChannel.php index aaf2ded1175e2..ea41c2e4fa443 100644 --- a/src/Symfony/Component/Notifier/Channel/ChatChannel.php +++ b/src/Symfony/Component/Notifier/Channel/ChatChannel.php @@ -18,8 +18,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class ChatChannel extends AbstractChannel { diff --git a/src/Symfony/Component/Notifier/Channel/EmailChannel.php b/src/Symfony/Component/Notifier/Channel/EmailChannel.php index 25382dea32b34..691cdc5de6ee8 100644 --- a/src/Symfony/Component/Notifier/Channel/EmailChannel.php +++ b/src/Symfony/Component/Notifier/Channel/EmailChannel.php @@ -25,8 +25,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class EmailChannel implements ChannelInterface { diff --git a/src/Symfony/Component/Notifier/Channel/SmsChannel.php b/src/Symfony/Component/Notifier/Channel/SmsChannel.php index 16e4ff4933839..ebed8010d5334 100644 --- a/src/Symfony/Component/Notifier/Channel/SmsChannel.php +++ b/src/Symfony/Component/Notifier/Channel/SmsChannel.php @@ -19,8 +19,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class SmsChannel extends AbstractChannel { diff --git a/src/Symfony/Component/Notifier/Chatter.php b/src/Symfony/Component/Notifier/Chatter.php index 81e3984715144..7b3c9d566e4a4 100644 --- a/src/Symfony/Component/Notifier/Chatter.php +++ b/src/Symfony/Component/Notifier/Chatter.php @@ -22,8 +22,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class Chatter implements ChatterInterface { diff --git a/src/Symfony/Component/Notifier/ChatterInterface.php b/src/Symfony/Component/Notifier/ChatterInterface.php index 7c54621b8a7e8..915190e623aaa 100644 --- a/src/Symfony/Component/Notifier/ChatterInterface.php +++ b/src/Symfony/Component/Notifier/ChatterInterface.php @@ -17,8 +17,6 @@ * Interface for classes able to send chat messages synchronous and/or asynchronous. * * @author Fabien Potencier - * - * @experimental in 5.2 */ interface ChatterInterface extends TransportInterface { diff --git a/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php b/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php index 5b6e1bf10ad2e..0bd03e919b2ab 100644 --- a/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php +++ b/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php @@ -19,8 +19,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class NotificationDataCollector extends DataCollector { diff --git a/src/Symfony/Component/Notifier/Event/MessageEvent.php b/src/Symfony/Component/Notifier/Event/MessageEvent.php index cad444a9513b9..2fdba8c69c6f2 100644 --- a/src/Symfony/Component/Notifier/Event/MessageEvent.php +++ b/src/Symfony/Component/Notifier/Event/MessageEvent.php @@ -16,8 +16,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class MessageEvent extends Event { diff --git a/src/Symfony/Component/Notifier/Event/NotificationEvents.php b/src/Symfony/Component/Notifier/Event/NotificationEvents.php index 90f37933c3376..19d698b61f3a1 100644 --- a/src/Symfony/Component/Notifier/Event/NotificationEvents.php +++ b/src/Symfony/Component/Notifier/Event/NotificationEvents.php @@ -15,8 +15,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class NotificationEvents { diff --git a/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php b/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php index 59cad7ec14fd7..29d295529209c 100644 --- a/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php +++ b/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php @@ -18,8 +18,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class NotificationLoggerListener implements EventSubscriberInterface, ResetInterface { diff --git a/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php b/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php index c4150c2b4719f..b6807f1091594 100644 --- a/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php +++ b/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php @@ -20,8 +20,6 @@ * Sends a rejected message to the notifier. * * @author Fabien Potencier - * - * @experimental in 5.2 */ class SendFailedMessageToNotifierListener implements EventSubscriberInterface { diff --git a/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php b/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php index 218c41013c555..457ed613606db 100644 --- a/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php +++ b/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php @@ -15,8 +15,6 @@ * Exception interface for all exceptions thrown by the component. * * @author Fabien Potencier - * - * @experimental in 5.2 */ interface ExceptionInterface extends \Throwable { diff --git a/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php b/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php index c72d390ec9467..55fc0438b356e 100644 --- a/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php +++ b/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class IncompleteDsnException extends InvalidArgumentException { diff --git a/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php b/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php index f8229138363ee..1130f8bd861f2 100644 --- a/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php +++ b/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface { diff --git a/src/Symfony/Component/Notifier/Exception/LengthException.php b/src/Symfony/Component/Notifier/Exception/LengthException.php new file mode 100644 index 0000000000000..7feed7ba66172 --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/LengthException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +/** + * @author Oskar Stark + */ +class LengthException extends LogicException +{ +} diff --git a/src/Symfony/Component/Notifier/Exception/LogicException.php b/src/Symfony/Component/Notifier/Exception/LogicException.php index d35f2c2847243..8a67897df0e04 100644 --- a/src/Symfony/Component/Notifier/Exception/LogicException.php +++ b/src/Symfony/Component/Notifier/Exception/LogicException.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class LogicException extends \LogicException implements ExceptionInterface { diff --git a/src/Symfony/Component/Notifier/Exception/MissingRequiredOptionException.php b/src/Symfony/Component/Notifier/Exception/MissingRequiredOptionException.php new file mode 100644 index 0000000000000..337e21783918a --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/MissingRequiredOptionException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +/** + * @author Oskar Stark + */ +class MissingRequiredOptionException extends IncompleteDsnException +{ + public function __construct(string $option, string $dsn = null, ?\Throwable $previous = null) + { + $message = sprintf('The option "%s" is required but missing.', $option); + + parent::__construct($message, $dsn, $previous); + } +} diff --git a/src/Symfony/Component/Notifier/Exception/RuntimeException.php b/src/Symfony/Component/Notifier/Exception/RuntimeException.php index 87b39eeafd2c8..2e61a3109a675 100644 --- a/src/Symfony/Component/Notifier/Exception/RuntimeException.php +++ b/src/Symfony/Component/Notifier/Exception/RuntimeException.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class RuntimeException extends \RuntimeException implements ExceptionInterface { diff --git a/src/Symfony/Component/Notifier/Exception/TransportException.php b/src/Symfony/Component/Notifier/Exception/TransportException.php index fade7275fa5fd..acf6ec7b36d82 100644 --- a/src/Symfony/Component/Notifier/Exception/TransportException.php +++ b/src/Symfony/Component/Notifier/Exception/TransportException.php @@ -15,8 +15,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class TransportException extends RuntimeException implements TransportExceptionInterface { diff --git a/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php b/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php index 321238c5e29a9..294f79bd9e19f 100644 --- a/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php +++ b/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface TransportExceptionInterface extends ExceptionInterface { diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedMessageTypeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedMessageTypeException.php new file mode 100644 index 0000000000000..59207de540fce --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedMessageTypeException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * @author Oskar Stark + */ +class UnsupportedMessageTypeException extends LogicException +{ + public function __construct(string $transport, string $supported, MessageInterface $given) + { + $message = sprintf( + 'The "%s" transport only supports instances of "%s" (instance of "%s" given).', + $transport, + $supported, + get_debug_type($given) + ); + + parent::__construct($message); + } +} diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index 2168d0b11dba4..06bb494d5f86c 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -16,8 +16,6 @@ /** * @author Konstantin Myakshin - * - * @experimental in 5.2 */ class UnsupportedSchemeException extends LogicException { @@ -42,6 +40,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Nexmo\NexmoTransportFactory::class, 'package' => 'symfony/nexmo-notifier', ], + 'iqsms' => [ + 'class' => Bridge\Iqsms\IqsmsTransportFactory::class, + 'package' => 'symfony/iqsms-notifier', + ], 'rocketchat' => [ 'class' => Bridge\RocketChat\RocketChatTransportFactory::class, 'package' => 'symfony/rocket-chat-notifier', @@ -50,6 +52,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Twilio\TwilioTransportFactory::class, 'package' => 'symfony/twilio-notifier', ], + 'allmysms' => [ + 'class' => Bridge\AllMySms\AllMySmsTransportFactory::class, + 'package' => 'symfony/allmysms-notifier', + ], 'infobip' => [ 'class' => Bridge\Infobip\InfobipTransportFactory::class, 'package' => 'symfony/infobip-notifier', @@ -62,6 +68,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\FreeMobile\FreeMobileTransportFactory::class, 'package' => 'symfony/free-mobile-notifier', ], + 'spothit' => [ + 'class' => Bridge\SpotHit\SpotHitTransportFactory::class, + 'package' => 'symfony/spot-hit-notifier', + ], 'ovhcloud' => [ 'class' => Bridge\OvhCloud\OvhCloudTransportFactory::class, 'package' => 'symfony/ovh-cloud-notifier', @@ -86,6 +96,26 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Discord\DiscordTransportFactory::class, 'package' => 'symfony/discord-notifier', ], + 'gatewayapi' => [ + 'class' => Bridge\GatewayApi\GatewayApiTransportFactory::class, + 'package' => 'symfony/gatewayapi-notifier', + ], + 'octopush' => [ + 'class' => Bridge\Octopush\OctopushTransportFactory::class, + 'package' => 'symfony/octopush-notifier', + ], + 'mercure' => [ + 'class' => Bridge\Mercure\MercureTransportFactory::class, + 'package' => 'symfony/mercure-notifier', + ], + 'gitter' => [ + 'class' => Bridge\Gitter\GitterTransportFactory::class, + 'package' => 'symfony/gitter-notifier', + ], + 'clickatell' => [ + 'class' => Bridge\Clickatell\ClickatellTransportFactory::class, + 'package' => 'symfony/clickatell-notifier', + ], ]; /** diff --git a/src/Symfony/Component/Notifier/Message/ChatMessage.php b/src/Symfony/Component/Notifier/Message/ChatMessage.php index 43a0c4f69cfb3..d7c028a7c90a3 100644 --- a/src/Symfony/Component/Notifier/Message/ChatMessage.php +++ b/src/Symfony/Component/Notifier/Message/ChatMessage.php @@ -15,8 +15,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class ChatMessage implements MessageInterface { diff --git a/src/Symfony/Component/Notifier/Message/EmailMessage.php b/src/Symfony/Component/Notifier/Message/EmailMessage.php index 2f83a408813b4..07ac6f2d10c61 100644 --- a/src/Symfony/Component/Notifier/Message/EmailMessage.php +++ b/src/Symfony/Component/Notifier/Message/EmailMessage.php @@ -22,8 +22,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class EmailMessage implements MessageInterface { diff --git a/src/Symfony/Component/Notifier/Message/MessageInterface.php b/src/Symfony/Component/Notifier/Message/MessageInterface.php index 814ec0187a447..05c4266d93068 100644 --- a/src/Symfony/Component/Notifier/Message/MessageInterface.php +++ b/src/Symfony/Component/Notifier/Message/MessageInterface.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface MessageInterface { diff --git a/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php b/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php index 22ce5606ea353..992a1cec96f10 100644 --- a/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php +++ b/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface MessageOptionsInterface { diff --git a/src/Symfony/Component/Notifier/Message/NullMessage.php b/src/Symfony/Component/Notifier/Message/NullMessage.php index 3fd59de62e84c..b6fd6696d1aa8 100644 --- a/src/Symfony/Component/Notifier/Message/NullMessage.php +++ b/src/Symfony/Component/Notifier/Message/NullMessage.php @@ -13,8 +13,6 @@ /** * @author Jan Schädlich - * - * @experimental in 5.2 */ final class NullMessage implements MessageInterface { diff --git a/src/Symfony/Component/Notifier/Message/SentMessage.php b/src/Symfony/Component/Notifier/Message/SentMessage.php index d3eb75bba445e..91343d7e42dbb 100644 --- a/src/Symfony/Component/Notifier/Message/SentMessage.php +++ b/src/Symfony/Component/Notifier/Message/SentMessage.php @@ -13,8 +13,6 @@ /** * @author Jérémy Romey - * - * @experimental in 5.2 */ final class SentMessage { diff --git a/src/Symfony/Component/Notifier/Message/SmsMessage.php b/src/Symfony/Component/Notifier/Message/SmsMessage.php index 2ea49a4dcf3c4..717c160cd5ded 100644 --- a/src/Symfony/Component/Notifier/Message/SmsMessage.php +++ b/src/Symfony/Component/Notifier/Message/SmsMessage.php @@ -17,8 +17,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class SmsMessage implements MessageInterface { diff --git a/src/Symfony/Component/Notifier/Messenger/MessageHandler.php b/src/Symfony/Component/Notifier/Messenger/MessageHandler.php index 88e921afd9ab2..e7c8d12068b2b 100644 --- a/src/Symfony/Component/Notifier/Messenger/MessageHandler.php +++ b/src/Symfony/Component/Notifier/Messenger/MessageHandler.php @@ -17,8 +17,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class MessageHandler { diff --git a/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php b/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php index 6c605ed677292..7b42fac5fe8c1 100644 --- a/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php +++ b/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php @@ -16,8 +16,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface ChatNotificationInterface { diff --git a/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php b/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php index 8fcf874761c14..55660427ab01e 100644 --- a/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php +++ b/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php @@ -16,8 +16,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface EmailNotificationInterface { diff --git a/src/Symfony/Component/Notifier/Notification/Notification.php b/src/Symfony/Component/Notifier/Notification/Notification.php index 5b85481c15123..f03bf42a757d5 100644 --- a/src/Symfony/Component/Notifier/Notification/Notification.php +++ b/src/Symfony/Component/Notifier/Notification/Notification.php @@ -17,8 +17,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class Notification { diff --git a/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php b/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php index a9f7bb14a0823..02db67ac2a6f3 100644 --- a/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php +++ b/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php @@ -16,8 +16,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface SmsNotificationInterface { diff --git a/src/Symfony/Component/Notifier/Notifier.php b/src/Symfony/Component/Notifier/Notifier.php index 481694372df07..426829a74c1c0 100644 --- a/src/Symfony/Component/Notifier/Notifier.php +++ b/src/Symfony/Component/Notifier/Notifier.php @@ -22,8 +22,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class Notifier implements NotifierInterface { diff --git a/src/Symfony/Component/Notifier/NotifierInterface.php b/src/Symfony/Component/Notifier/NotifierInterface.php index edba2d8b37fdf..2e62b8b87b48a 100644 --- a/src/Symfony/Component/Notifier/NotifierInterface.php +++ b/src/Symfony/Component/Notifier/NotifierInterface.php @@ -18,8 +18,6 @@ * Interface for the Notifier system. * * @author Fabien Potencier - * - * @experimental in 5.2 */ interface NotifierInterface { diff --git a/src/Symfony/Component/Notifier/README.md b/src/Symfony/Component/Notifier/README.md index cde2108ff702e..92ce150079641 100644 --- a/src/Symfony/Component/Notifier/README.md +++ b/src/Symfony/Component/Notifier/README.md @@ -3,11 +3,6 @@ Notifier Component The Notifier component sends notifications via one or more channels (email, SMS, ...). -**This Component is experimental**. -[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) -are not covered by Symfony's -[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). - Resources --------- diff --git a/src/Symfony/Component/Notifier/Recipient/EmailRecipientInterface.php b/src/Symfony/Component/Notifier/Recipient/EmailRecipientInterface.php index 5a7c3d4cb89fb..6f8fb9de9c4cb 100644 --- a/src/Symfony/Component/Notifier/Recipient/EmailRecipientInterface.php +++ b/src/Symfony/Component/Notifier/Recipient/EmailRecipientInterface.php @@ -13,8 +13,6 @@ /** * @author Jan Schädlich - * - * @experimental in 5.2 */ interface EmailRecipientInterface extends RecipientInterface { diff --git a/src/Symfony/Component/Notifier/Recipient/EmailRecipientTrait.php b/src/Symfony/Component/Notifier/Recipient/EmailRecipientTrait.php index 73e5bd98e9c9c..eaee0b1f26bd6 100644 --- a/src/Symfony/Component/Notifier/Recipient/EmailRecipientTrait.php +++ b/src/Symfony/Component/Notifier/Recipient/EmailRecipientTrait.php @@ -13,8 +13,6 @@ /** * @author Jan Schädlich - * - * @experimental in 5.2 */ trait EmailRecipientTrait { diff --git a/src/Symfony/Component/Notifier/Recipient/NoRecipient.php b/src/Symfony/Component/Notifier/Recipient/NoRecipient.php index 3419a61ec80cf..ebf21939a5bcd 100644 --- a/src/Symfony/Component/Notifier/Recipient/NoRecipient.php +++ b/src/Symfony/Component/Notifier/Recipient/NoRecipient.php @@ -13,8 +13,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class NoRecipient implements RecipientInterface { diff --git a/src/Symfony/Component/Notifier/Recipient/Recipient.php b/src/Symfony/Component/Notifier/Recipient/Recipient.php index db50550ac183c..f8cc625c90a34 100644 --- a/src/Symfony/Component/Notifier/Recipient/Recipient.php +++ b/src/Symfony/Component/Notifier/Recipient/Recipient.php @@ -16,8 +16,6 @@ /** * @author Fabien Potencier * @author Jan Schädlich - * - * @experimental in 5.2 */ class Recipient implements EmailRecipientInterface, SmsRecipientInterface { diff --git a/src/Symfony/Component/Notifier/Recipient/RecipientInterface.php b/src/Symfony/Component/Notifier/Recipient/RecipientInterface.php index 53e4f60a3ef44..14cc075760680 100644 --- a/src/Symfony/Component/Notifier/Recipient/RecipientInterface.php +++ b/src/Symfony/Component/Notifier/Recipient/RecipientInterface.php @@ -13,8 +13,6 @@ /** * @author Jan Schädlich - * - * @experimental in 5.2 */ interface RecipientInterface { diff --git a/src/Symfony/Component/Notifier/Recipient/SmsRecipientInterface.php b/src/Symfony/Component/Notifier/Recipient/SmsRecipientInterface.php index 3b34c1b802c6a..cb513847b1032 100644 --- a/src/Symfony/Component/Notifier/Recipient/SmsRecipientInterface.php +++ b/src/Symfony/Component/Notifier/Recipient/SmsRecipientInterface.php @@ -14,8 +14,6 @@ /** * @author Fabien Potencier * @author Jan Schädlich - * - * @experimental in 5.2 */ interface SmsRecipientInterface extends RecipientInterface { diff --git a/src/Symfony/Component/Notifier/Recipient/SmsRecipientTrait.php b/src/Symfony/Component/Notifier/Recipient/SmsRecipientTrait.php index 9f1ad1651805f..15e3c1b869adb 100644 --- a/src/Symfony/Component/Notifier/Recipient/SmsRecipientTrait.php +++ b/src/Symfony/Component/Notifier/Recipient/SmsRecipientTrait.php @@ -13,8 +13,6 @@ /** * @author Jan Schädlich - * - * @experimental in 5.2 */ trait SmsRecipientTrait { diff --git a/src/Symfony/Component/Notifier/Tests/Transport/DsnTest.php b/src/Symfony/Component/Notifier/Tests/Transport/DsnTest.php index fc7072dee68ec..7dccb3398a9fe 100644 --- a/src/Symfony/Component/Notifier/Tests/Transport/DsnTest.php +++ b/src/Symfony/Component/Notifier/Tests/Transport/DsnTest.php @@ -13,84 +13,142 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\MissingRequiredOptionException; use Symfony\Component\Notifier\Transport\Dsn; final class DsnTest extends TestCase { /** - * @dataProvider fromStringProvider + * @dataProvider constructProvider */ - public function testFromString(string $string, Dsn $expectedDsn) + public function testConstruct(string $dsnString, string $scheme, string $host, ?string $user = null, ?string $password = null, ?int $port = null, array $options = [], ?string $path = null) { - $actualDsn = Dsn::fromString($string); + $dsn = new Dsn($dsnString); + $this->assertSame($dsnString, $dsn->getOriginalDsn()); - $this->assertSame($expectedDsn->getScheme(), $actualDsn->getScheme()); - $this->assertSame($expectedDsn->getHost(), $actualDsn->getHost()); - $this->assertSame($expectedDsn->getPort(), $actualDsn->getPort()); - $this->assertSame($expectedDsn->getUser(), $actualDsn->getUser()); - $this->assertSame($expectedDsn->getPassword(), $actualDsn->getPassword()); - $this->assertSame($expectedDsn->getPath(), $actualDsn->getPath()); - $this->assertSame($expectedDsn->getOption('from'), $actualDsn->getOption('from')); - - $this->assertSame($string, $actualDsn->getOriginalDsn()); + $this->assertSame($scheme, $dsn->getScheme()); + $this->assertSame($host, $dsn->getHost()); + $this->assertSame($user, $dsn->getUser()); + $this->assertSame($password, $dsn->getPassword()); + $this->assertSame($port, $dsn->getPort()); + $this->assertSame($path, $dsn->getPath()); + $this->assertSame($options, $dsn->getOptions()); } - public function fromStringProvider(): iterable + public function constructProvider(): iterable { yield 'simple dsn' => [ 'scheme://localhost', - new Dsn('scheme', 'localhost', null, null, null, [], null), + 'scheme', + 'localhost', ]; yield 'simple dsn including @ sign, but no user/password/token' => [ 'scheme://@localhost', - new Dsn('scheme', 'localhost', null, null), + 'scheme', + 'localhost', ]; yield 'simple dsn including : sign and @ sign, but no user/password/token' => [ 'scheme://:@localhost', - new Dsn('scheme', 'localhost', null, null), + 'scheme', + 'localhost', ]; yield 'simple dsn including user, : sign and @ sign, but no password' => [ 'scheme://user1:@localhost', - new Dsn('scheme', 'localhost', 'user1', null), + 'scheme', + 'localhost', + 'user1', ]; yield 'simple dsn including : sign, password, and @ sign, but no user' => [ 'scheme://:pass@localhost', - new Dsn('scheme', 'localhost', null, 'pass'), + 'scheme', + 'localhost', + null, + 'pass', ]; yield 'dsn with user and pass' => [ 'scheme://u$er:pa$s@localhost', - new Dsn('scheme', 'localhost', 'u$er', 'pa$s', null, [], null), + 'scheme', + 'localhost', + 'u$er', + 'pa$s', ]; yield 'dsn with user and pass and custom port' => [ 'scheme://u$er:pa$s@localhost:8000', - new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', [], null), + 'scheme', + 'localhost', + 'u$er', + 'pa$s', + 8000, ]; yield 'dsn with user and pass, custom port and custom path' => [ 'scheme://u$er:pa$s@localhost:8000/channel', - new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', [], '/channel'), + 'scheme', + 'localhost', + 'u$er', + 'pa$s', + 8000, + [], + '/channel', ]; - yield 'dsn with user and pass, custom port, custom path and custom options' => [ + yield 'dsn with user and pass, custom port, custom path and custom option' => [ 'scheme://u$er:pa$s@localhost:8000/channel?from=FROM', - new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', ['from' => 'FROM'], '/channel'), + 'scheme', + 'localhost', + 'u$er', + 'pa$s', + 8000, + [ + 'from' => 'FROM', + ], + '/channel', + ]; + + yield 'dsn with user and pass, custom port, custom path and custom options' => [ + 'scheme://u$er:pa$s@localhost:8000/channel?from=FROM&to=TO', + 'scheme', + 'localhost', + 'u$er', + 'pa$s', + 8000, + [ + 'from' => 'FROM', + 'to' => 'TO', + ], + '/channel', + ]; + + yield 'dsn with user and pass, custom port, custom path and custom options and custom options keep the same order' => [ + 'scheme://u$er:pa$s@localhost:8000/channel?to=TO&from=FROM', + 'scheme', + 'localhost', + 'u$er', + 'pa$s', + 8000, + [ + 'to' => 'TO', + 'from' => 'FROM', + ], + '/channel', ]; } /** * @dataProvider invalidDsnProvider */ - public function testInvalidDsn(string $dsn, string $exceptionMessage) + public function testInvalidDsn(string $dsnString, string $exceptionMessage) { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($exceptionMessage); - Dsn::fromString($dsn); + + new Dsn($dsnString); } public function invalidDsnProvider(): iterable @@ -111,13 +169,94 @@ public function invalidDsnProvider(): iterable ]; } - public function testGetOption() + /** + * @dataProvider getOptionProvider + */ + public function testGetOption($expected, string $dsnString, string $option, ?string $default = null) { - $options = ['with_value' => 'some value', 'nullable' => null]; - $dsn = new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', $options, '/channel'); + $dsn = new Dsn($dsnString); - $this->assertSame('some value', $dsn->getOption('with_value')); - $this->assertSame('default', $dsn->getOption('nullable', 'default')); - $this->assertSame('default', $dsn->getOption('not_existent_property', 'default')); + $this->assertSame($expected, $dsn->getOption($option, $default)); + } + + public function getOptionProvider(): iterable + { + yield [ + 'foo', + 'scheme://localhost?with_value=foo', + 'with_value', + ]; + + yield [ + '', + 'scheme://localhost?empty=', + 'empty', + ]; + + yield [ + '0', + 'scheme://localhost?zero=0', + 'zero', + ]; + + yield [ + 'default-value', + 'scheme://localhost?option=value', + 'non_existent_property', + 'default-value', + ]; + } + + /** + * @dataProvider getRequiredOptionProvider + */ + public function testGetRequiredOption(string $expectedValue, string $options, string $option) + { + $dsn = new Dsn(sprintf('scheme://localhost?%s', $options)); + + $this->assertSame($expectedValue, $dsn->getRequiredOption($option)); + } + + public function getRequiredOptionProvider(): iterable + { + yield [ + 'value', + 'with_value=value', + 'with_value', + ]; + + yield [ + '0', + 'timeout=0', + 'timeout', + ]; + } + + /** + * @dataProvider getRequiredOptionThrowsMissingRequiredOptionExceptionProvider + */ + public function testGetRequiredOptionThrowsMissingRequiredOptionException(string $expectedExceptionMessage, string $options, string $option) + { + $dsn = new Dsn(sprintf('scheme://localhost?%s', $options)); + + $this->expectException(MissingRequiredOptionException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $dsn->getRequiredOption($option); + } + + public function getRequiredOptionThrowsMissingRequiredOptionExceptionProvider(): iterable + { + yield [ + 'The option "foo_bar" is required but missing.', + 'with_value=value', + 'foo_bar', + ]; + + yield [ + 'The option "with_empty_string" is required but missing.', + 'with_empty_string=', + 'with_empty_string', + ]; } } diff --git a/src/Symfony/Component/Notifier/Tests/Transport/NullTransportFactoryTest.php b/src/Symfony/Component/Notifier/Tests/Transport/NullTransportFactoryTest.php index a2682a7957c44..470b848372bab 100644 --- a/src/Symfony/Component/Notifier/Tests/Transport/NullTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Tests/Transport/NullTransportFactoryTest.php @@ -41,14 +41,14 @@ public function testCreateThrowsUnsupportedSchemeException() { $this->expectException(UnsupportedSchemeException::class); - $this->nullTransportFactory->create(new Dsn('foo', '')); + $this->nullTransportFactory->create(new Dsn('foo://localhost')); } public function testCreate() { $this->assertInstanceOf( NullTransport::class, - $this->nullTransportFactory->create(new Dsn('null', '')) + $this->nullTransportFactory->create(new Dsn('null://null')) ); } } diff --git a/src/Symfony/Component/Notifier/Tests/TransportFactoryTestCase.php b/src/Symfony/Component/Notifier/Tests/TransportFactoryTestCase.php index be3da0ff34b61..3215e31eafc95 100644 --- a/src/Symfony/Component/Notifier/Tests/TransportFactoryTestCase.php +++ b/src/Symfony/Component/Notifier/Tests/TransportFactoryTestCase.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Notifier\Exception\IncompleteDsnException; +use Symfony\Component\Notifier\Exception\MissingRequiredOptionException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\Dsn; use Symfony\Component\Notifier\Transport\TransportFactoryInterface; @@ -52,6 +53,14 @@ public function incompleteDsnProvider(): iterable return []; } + /** + * @return iterable + */ + public function missingRequiredOptionProvider(): iterable + { + return []; + } + /** * @dataProvider supportsProvider */ @@ -59,7 +68,7 @@ public function testSupports(bool $expected, string $dsn) { $factory = $this->createFactory(); - $this->assertSame($expected, $factory->supports(Dsn::fromString($dsn))); + $this->assertSame($expected, $factory->supports(new Dsn($dsn))); } /** @@ -68,7 +77,7 @@ public function testSupports(bool $expected, string $dsn) public function testCreate(string $expected, string $dsn) { $factory = $this->createFactory(); - $transport = $factory->create(Dsn::fromString($dsn)); + $transport = $factory->create(new Dsn($dsn)); $this->assertSame($expected, (string) $transport); } @@ -80,7 +89,7 @@ public function testUnsupportedSchemeException(string $dsn, string $message = nu { $factory = $this->createFactory(); - $dsn = Dsn::fromString($dsn); + $dsn = new Dsn($dsn); $this->expectException(UnsupportedSchemeException::class); if (null !== $message) { @@ -97,7 +106,7 @@ public function testIncompleteDsnException(string $dsn, string $message = null) { $factory = $this->createFactory(); - $dsn = Dsn::fromString($dsn); + $dsn = new Dsn($dsn); $this->expectException(IncompleteDsnException::class); if (null !== $message) { @@ -106,4 +115,21 @@ public function testIncompleteDsnException(string $dsn, string $message = null) $factory->create($dsn); } + + /** + * @dataProvider missingRequiredOptionProvider + */ + public function testMissingRequiredOptionException(string $dsn, string $message = null) + { + $factory = $this->createFactory(); + + $dsn = new Dsn($dsn); + + $this->expectException(MissingRequiredOptionException::class); + if (null !== $message) { + $this->expectExceptionMessage($message); + } + + $factory->create($dsn); + } } diff --git a/src/Symfony/Component/Notifier/Tests/TransportTestCase.php b/src/Symfony/Component/Notifier/Tests/TransportTestCase.php index 18f2d40d3a3a6..ab479e4c3eb19 100644 --- a/src/Symfony/Component/Notifier/Tests/TransportTestCase.php +++ b/src/Symfony/Component/Notifier/Tests/TransportTestCase.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Notifier\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Transport\TransportInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -79,13 +79,13 @@ public function testUnsupportedMessages(MessageInterface $message, ?TransportInt /** * @dataProvider unsupportedMessagesProvider */ - public function testUnsupportedMessagesTrowLogicExceptionWhenSend(MessageInterface $message, ?TransportInterface $transport = null) + public function testUnsupportedMessagesTrowUnsupportedMessageTypeExceptionWhenSend(MessageInterface $message, ?TransportInterface $transport = null) { if (null === $transport) { $transport = $this->createTransport(); } - $this->expectException(LogicException::class); + $this->expectException(UnsupportedMessageTypeException::class); $transport->send($message); } diff --git a/src/Symfony/Component/Notifier/Texter.php b/src/Symfony/Component/Notifier/Texter.php index 4414b248d8f89..03b1bcf8bb193 100644 --- a/src/Symfony/Component/Notifier/Texter.php +++ b/src/Symfony/Component/Notifier/Texter.php @@ -22,8 +22,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class Texter implements TexterInterface { diff --git a/src/Symfony/Component/Notifier/TexterInterface.php b/src/Symfony/Component/Notifier/TexterInterface.php index 9721c8190921d..e65547755cd70 100644 --- a/src/Symfony/Component/Notifier/TexterInterface.php +++ b/src/Symfony/Component/Notifier/TexterInterface.php @@ -17,8 +17,6 @@ * Interface for classes able to send SMS messages synchronous and/or asynchronous. * * @author Fabien Potencier - * - * @experimental in 5.2 */ interface TexterInterface extends TransportInterface { diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index c3d8617554da4..b2430bbe9ac2d 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -11,14 +11,20 @@ namespace Symfony\Component\Notifier; +use Symfony\Component\Notifier\Bridge\AllMySms\AllMySmsTransportFactory; +use Symfony\Component\Notifier\Bridge\Clickatell\ClickatellTransportFactory; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory; use Symfony\Component\Notifier\Bridge\Esendex\EsendexTransportFactory; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; use Symfony\Component\Notifier\Bridge\FreeMobile\FreeMobileTransportFactory; +use Symfony\Component\Notifier\Bridge\GatewayApi\GatewayApiTransportFactory; +use Symfony\Component\Notifier\Bridge\Gitter\GitterTransportFactory; use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; +use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; +use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; use Symfony\Component\Notifier\Bridge\OvhCloud\OvhCloudTransportFactory; use Symfony\Component\Notifier\Bridge\RocketChat\RocketChatTransportFactory; use Symfony\Component\Notifier\Bridge\Sendinblue\SendinblueTransportFactory; @@ -41,8 +47,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class Transport { @@ -51,8 +55,10 @@ class Transport TelegramTransportFactory::class, MattermostTransportFactory::class, NexmoTransportFactory::class, + IqsmsTransportFactory::class, RocketChatTransportFactory::class, TwilioTransportFactory::class, + AllMySmsTransportFactory::class, InfobipTransportFactory::class, OvhCloudTransportFactory::class, FirebaseTransportFactory::class, @@ -64,6 +70,10 @@ class Transport EsendexTransportFactory::class, SendinblueTransportFactory::class, DiscordTransportFactory::class, + GatewayApiTransportFactory::class, + OctopushTransportFactory::class, + GitterTransportFactory::class, + ClickatellTransportFactory::class, ]; private $factories; @@ -112,7 +122,7 @@ public function fromString(string $dsn): TransportInterface return new RoundRobinTransport($this->createFromDsns($dsns)); } - return $this->fromDsnObject(Dsn::fromString($dsn)); + return $this->fromDsnObject(new Dsn($dsn)); } public function fromDsnObject(Dsn $dsn): TransportInterface @@ -133,7 +143,7 @@ private function createFromDsns(array $dsns): array { $transports = []; foreach ($dsns as $dsn) { - $transports[] = $this->fromDsnObject(Dsn::fromString($dsn)); + $transports[] = $this->fromDsnObject(new Dsn($dsn)); } return $transports; diff --git a/src/Symfony/Component/Notifier/Transport/AbstractTransport.php b/src/Symfony/Component/Notifier/Transport/AbstractTransport.php index 06fa4ed99b4c3..670fd49847ebd 100644 --- a/src/Symfony/Component/Notifier/Transport/AbstractTransport.php +++ b/src/Symfony/Component/Notifier/Transport/AbstractTransport.php @@ -23,8 +23,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ abstract class AbstractTransport implements TransportInterface { @@ -81,7 +79,7 @@ public function send(MessageInterface $message): SentMessage abstract protected function doSend(MessageInterface $message): SentMessage; - protected function getEndpoint(): ?string + protected function getEndpoint(): string { return ($this->host ?: $this->getDefaultHost()).($this->port ? ':'.$this->port : ''); } diff --git a/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php b/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php index ae3167d066d2b..d595aa623a723 100644 --- a/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php +++ b/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php @@ -20,8 +20,6 @@ /** * @author Konstantin Myakshin * @author Fabien Potencier - * - * @experimental in 5.2 */ abstract class AbstractTransportFactory implements TransportFactoryInterface { diff --git a/src/Symfony/Component/Notifier/Transport/Dsn.php b/src/Symfony/Component/Notifier/Transport/Dsn.php index 20ede09023a8a..16f722356578b 100644 --- a/src/Symfony/Component/Notifier/Transport/Dsn.php +++ b/src/Symfony/Component/Notifier/Transport/Dsn.php @@ -12,11 +12,11 @@ namespace Symfony\Component\Notifier\Transport; use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\MissingRequiredOptionException; /** * @author Fabien Potencier - * - * @experimental in 5.2 + * @author Oskar Stark */ final class Dsn { @@ -25,23 +25,14 @@ final class Dsn private $user; private $password; private $port; - private $options; private $path; - private $dsn; + private $options; + private $originalDsn; - public function __construct(string $scheme, string $host, ?string $user = null, ?string $password = null, ?int $port = null, array $options = [], ?string $path = null) + public function __construct(string $dsn) { - $this->scheme = $scheme; - $this->host = $host; - $this->user = $user; - $this->password = $password; - $this->port = $port; - $this->options = $options; - $this->path = $path; - } + $this->originalDsn = $dsn; - public static function fromString(string $dsn): self - { if (false === $parsedDsn = parse_url($dsn)) { throw new InvalidArgumentException(sprintf('The "%s" notifier DSN is invalid.', $dsn)); } @@ -49,21 +40,18 @@ public static function fromString(string $dsn): self if (!isset($parsedDsn['scheme'])) { throw new InvalidArgumentException(sprintf('The "%s" notifier DSN must contain a scheme.', $dsn)); } + $this->scheme = $parsedDsn['scheme']; if (!isset($parsedDsn['host'])) { throw new InvalidArgumentException(sprintf('The "%s" notifier DSN must contain a host (use "default" by default).', $dsn)); } + $this->host = $parsedDsn['host']; - $user = '' !== ($parsedDsn['user'] ?? '') ? urldecode($parsedDsn['user']) : null; - $password = '' !== ($parsedDsn['pass'] ?? '') ? urldecode($parsedDsn['pass']) : null; - $port = $parsedDsn['port'] ?? null; - $path = $parsedDsn['path'] ?? null; - parse_str($parsedDsn['query'] ?? '', $query); - - $dsnObject = new self($parsedDsn['scheme'], $parsedDsn['host'], $user, $password, $port, $query, $path); - $dsnObject->dsn = $dsn; - - return $dsnObject; + $this->user = '' !== ($parsedDsn['user'] ?? '') ? urldecode($parsedDsn['user']) : null; + $this->password = '' !== ($parsedDsn['pass'] ?? '') ? urldecode($parsedDsn['pass']) : null; + $this->port = $parsedDsn['port'] ?? null; + $this->path = $parsedDsn['path'] ?? null; + parse_str($parsedDsn['query'] ?? '', $this->options); } public function getScheme(): string @@ -96,6 +84,20 @@ public function getOption(string $key, $default = null) return $this->options[$key] ?? $default; } + public function getRequiredOption(string $key) + { + if (!\array_key_exists($key, $this->options) || '' === trim($this->options[$key])) { + throw new MissingRequiredOptionException($key); + } + + return $this->options[$key]; + } + + public function getOptions(): array + { + return $this->options; + } + public function getPath(): ?string { return $this->path; @@ -103,6 +105,6 @@ public function getPath(): ?string public function getOriginalDsn(): string { - return $this->dsn; + return $this->originalDsn; } } diff --git a/src/Symfony/Component/Notifier/Transport/FailoverTransport.php b/src/Symfony/Component/Notifier/Transport/FailoverTransport.php index 68edb9f1188fa..cc0191e234738 100644 --- a/src/Symfony/Component/Notifier/Transport/FailoverTransport.php +++ b/src/Symfony/Component/Notifier/Transport/FailoverTransport.php @@ -17,8 +17,6 @@ * Uses several Transports using a failover algorithm. * * @author Fabien Potencier - * - * @experimental in 5.2 */ class FailoverTransport extends RoundRobinTransport { diff --git a/src/Symfony/Component/Notifier/Transport/NullTransport.php b/src/Symfony/Component/Notifier/Transport/NullTransport.php index bd9b878fdec79..5178b1e14e8f4 100644 --- a/src/Symfony/Component/Notifier/Transport/NullTransport.php +++ b/src/Symfony/Component/Notifier/Transport/NullTransport.php @@ -21,8 +21,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ class NullTransport implements TransportInterface { diff --git a/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php b/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php index e2a6b6e854a37..1ee6ed6ed832c 100644 --- a/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php +++ b/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php @@ -15,8 +15,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class NullTransportFactory extends AbstractTransportFactory { diff --git a/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php b/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php index a67bccb95c76e..f438d407f43b5 100644 --- a/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php +++ b/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php @@ -21,8 +21,6 @@ * Uses several Transports using a round robin algorithm. * * @author Fabien Potencier - * - * @experimental in 5.2 */ class RoundRobinTransport implements TransportInterface { diff --git a/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php b/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php index 027d7828e5abc..094e159c72df9 100644 --- a/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php +++ b/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php @@ -12,18 +12,18 @@ namespace Symfony\Component\Notifier\Transport; use Symfony\Component\Notifier\Exception\IncompleteDsnException; +use Symfony\Component\Notifier\Exception\MissingRequiredOptionException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; /** * @author Konstantin Myakshin - * - * @experimental in 5.2 */ interface TransportFactoryInterface { /** * @throws UnsupportedSchemeException * @throws IncompleteDsnException + * @throws MissingRequiredOptionException */ public function create(Dsn $dsn): TransportInterface; diff --git a/src/Symfony/Component/Notifier/Transport/TransportInterface.php b/src/Symfony/Component/Notifier/Transport/TransportInterface.php index be3f3306ce8d4..e2f2e94a7b18f 100644 --- a/src/Symfony/Component/Notifier/Transport/TransportInterface.php +++ b/src/Symfony/Component/Notifier/Transport/TransportInterface.php @@ -17,8 +17,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ interface TransportInterface { diff --git a/src/Symfony/Component/Notifier/Transport/Transports.php b/src/Symfony/Component/Notifier/Transport/Transports.php index cb7d5031b706b..f411d6bc0c126 100644 --- a/src/Symfony/Component/Notifier/Transport/Transports.php +++ b/src/Symfony/Component/Notifier/Transport/Transports.php @@ -18,8 +18,6 @@ /** * @author Fabien Potencier - * - * @experimental in 5.2 */ final class Transports implements TransportInterface { diff --git a/src/Symfony/Component/Notifier/composer.json b/src/Symfony/Component/Notifier/composer.json index 02178970b0afd..4249f669f841c 100644 --- a/src/Symfony/Component/Notifier/composer.json +++ b/src/Symfony/Component/Notifier/composer.json @@ -26,16 +26,25 @@ }, "conflict": { "symfony/http-kernel": "<4.4", - "symfony/firebase-notifier": "<5.2", - "symfony/free-mobile-notifier": "<5.2", - "symfony/mattermost-notifier": "<5.2", - "symfony/nexmo-notifier": "<5.2", - "symfony/ovh-cloud-notifier": "<5.2", - "symfony/rocket-chat-notifier": "<5.2", - "symfony/sinch-notifier": "<5.2", - "symfony/slack-notifier": "<5.2", - "symfony/telegram-notifier": "<5.2", - "symfony/twilio-notifier": "<5.2" + "symfony/discord-notifier": "<5.3", + "symfony/esendex-notifier": "<5.3", + "symfony/firebase-notifier": "<5.3", + "symfony/free-mobile-notifier": "<5.3", + "symfony/google-chat-notifier": "<5.3", + "symfony/infobip-notifier": "<5.3", + "symfony/linked-in-notifier": "<5.3", + "symfony/mattermost-notifier": "<5.3", + "symfony/mobyt-notifier": "<5.3", + "symfony/nexmo-notifier": "<5.3", + "symfony/ovh-cloud-notifier": "<5.3", + "symfony/rocket-chat-notifier": "<5.3", + "symfony/sendinblue-notifier": "<5.3", + "symfony/sinch-notifier": "<5.3", + "symfony/slack-notifier": "<5.3", + "symfony/smsapi-notifier": "<5.3", + "symfony/telegram-notifier": "<5.3", + "symfony/twilio-notifier": "<5.3", + "symfony/zulip-notifier": "<5.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\": "" }, diff --git a/src/Symfony/Component/PasswordHasher/.gitattributes b/src/Symfony/Component/PasswordHasher/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/PasswordHasher/.gitignore b/src/Symfony/Component/PasswordHasher/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/PasswordHasher/CHANGELOG.md b/src/Symfony/Component/PasswordHasher/CHANGELOG.md new file mode 100644 index 0000000000000..53ea3f1c17b6e --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/CHANGELOG.md @@ -0,0 +1,5 @@ +5.3 +--- + + * Add the component + * Use `bcrypt` as default algorithm in `NativePasswordHasher` diff --git a/src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php b/src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php new file mode 100644 index 0000000000000..edee8a2978cc5 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * Hashes a user's password. + * + * @author Sarah Khalil + * @author Robin Chalas + * + * @final + */ +class UserPasswordHashCommand extends Command +{ + protected static $defaultName = 'security:hash-password'; + protected static $defaultDescription = 'Hashes a user password'; + + private $hasherFactory; + private $userClasses; + + public function __construct(PasswordHasherFactoryInterface $hasherFactory, array $userClasses = []) + { + $this->hasherFactory = $hasherFactory; + $this->userClasses = $userClasses; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setDescription(self::$defaultDescription) + ->addArgument('password', InputArgument::OPTIONAL, 'The plain password to hash.') + ->addArgument('user-class', InputArgument::OPTIONAL, 'The User entity class path associated with the hasher used to hash the password.') + ->addOption('empty-salt', null, InputOption::VALUE_NONE, 'Do not generate a salt or let the hasher generate one.') + ->setHelp(<<%command.name% command hashes passwords according to your +security configuration. This command is mainly used to generate passwords for +the in_memory user provider type and for changing passwords +in the database while developing the application. + +Suppose that you have the following security configuration in your application: + + +# app/config/security.yml +security: + password_hashers: + Symfony\Component\Security\Core\User\User: plaintext + App\Entity\User: auto + + +If you execute the command non-interactively, the first available configured +user class under the security.password_hashers key is used and a random salt is +generated to hash the password: + + php %command.full_name% --no-interaction [password] + +Pass the full user class path as the second argument to hash passwords for +your own entities: + + php %command.full_name% --no-interaction [password] 'App\Entity\User' + +Executing the command interactively allows you to generate a random salt for +hashing the password: + + php %command.full_name% [password] 'App\Entity\User' + +In case your hasher doesn't require a salt, add the empty-salt option: + + php %command.full_name% --empty-salt [password] 'App\Entity\User' + +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $errorIo = $output instanceof ConsoleOutputInterface ? new SymfonyStyle($input, $output->getErrorOutput()) : $io; + + $input->isInteractive() ? $errorIo->title('Symfony Password Hash Utility') : $errorIo->newLine(); + + $password = $input->getArgument('password'); + $userClass = $this->getUserClass($input, $io); + $emptySalt = $input->getOption('empty-salt'); + + $hasher = $this->hasherFactory->getPasswordHasher($userClass); + $saltlessWithoutEmptySalt = !$emptySalt && !$hasher instanceof LegacyPasswordHasherInterface; + + if ($saltlessWithoutEmptySalt) { + $emptySalt = true; + } + + if (!$password) { + if (!$input->isInteractive()) { + $errorIo->error('The password must not be empty.'); + + return 1; + } + $passwordQuestion = $this->createPasswordQuestion(); + $password = $errorIo->askQuestion($passwordQuestion); + } + + $salt = null; + + if ($input->isInteractive() && !$emptySalt) { + $emptySalt = true; + + $errorIo->note('The command will take care of generating a salt for you. Be aware that some hashers advise to let them generate their own salt. If you\'re using one of those hashers, please answer \'no\' to the question below. '.\PHP_EOL.'Provide the \'empty-salt\' option in order to let the hasher handle the generation itself.'); + + if ($errorIo->confirm('Confirm salt generation ?')) { + $salt = $this->generateSalt(); + $emptySalt = false; + } + } elseif (!$emptySalt) { + $salt = $this->generateSalt(); + } + + $hashedPassword = $hasher->hash($password, $salt); + + $rows = [ + ['Hasher used', \get_class($hasher)], + ['Password hash', $hashedPassword], + ]; + if (!$emptySalt) { + $rows[] = ['Generated salt', $salt]; + } + $io->table(['Key', 'Value'], $rows); + + if (!$emptySalt) { + $errorIo->note(sprintf('Make sure that your salt storage field fits the salt length: %s chars', \strlen($salt))); + } elseif ($saltlessWithoutEmptySalt) { + $errorIo->note('Self-salting hasher used: the hasher generated its own built-in salt.'); + } + + $errorIo->success('Password hashing succeeded'); + + return 0; + } + + /** + * Create the password question to ask the user for the password to be hashed. + */ + private function createPasswordQuestion(): Question + { + $passwordQuestion = new Question('Type in your password to be hashed'); + + return $passwordQuestion->setValidator(function ($value) { + if ('' === trim($value)) { + throw new InvalidArgumentException('The password must not be empty.'); + } + + return $value; + })->setHidden(true)->setMaxAttempts(20); + } + + private function generateSalt(): string + { + return base64_encode(random_bytes(30)); + } + + private function getUserClass(InputInterface $input, SymfonyStyle $io): string + { + if (null !== $userClass = $input->getArgument('user-class')) { + return $userClass; + } + + if (!$this->userClasses) { + throw new RuntimeException('There are no configured password hashers for the "security" extension.'); + } + + if (!$input->isInteractive() || 1 === \count($this->userClasses)) { + return reset($this->userClasses); + } + + $userClasses = $this->userClasses; + natcasesort($userClasses); + $userClasses = array_values($userClasses); + + return $io->choice('For which user class would you like to hash a password?', $userClasses, reset($userClasses)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php b/src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..2d80d8a78f8ee --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Exception; + +/** + * Interface for exceptions thrown by the password-hasher component. + * + * @author Robin Chalas + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php b/src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php new file mode 100644 index 0000000000000..c70a4d5561531 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Exception; + +/** + * @author Robin Chalas + */ +class InvalidPasswordException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(string $message = 'Invalid password.', int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Exception/LogicException.php b/src/Symfony/Component/PasswordHasher/Exception/LogicException.php new file mode 100644 index 0000000000000..f4d9f31ff5319 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Exception; + +/** + * @author Robin Chalas + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php b/src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php new file mode 100644 index 0000000000000..2dce065ff8191 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * @author Robin Chalas + */ +trait CheckPasswordLengthTrait +{ + private function isPasswordTooLong(string $password): bool + { + return PasswordHasherInterface::MAX_PASSWORD_LENGTH < \strlen($password); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php new file mode 100644 index 0000000000000..0dd18b276bdde --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * MessageDigestPasswordHasher uses a message digest algorithm. + * + * @author Fabien Potencier + */ +class MessageDigestPasswordHasher implements LegacyPasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $algorithm; + private $encodeHashAsBase64; + private $iterations = 1; + private $hashLength = -1; + + /** + * @param string $algorithm The digest algorithm to use + * @param bool $encodeHashAsBase64 Whether to base64 encode the password hash + * @param int $iterations The number of iterations to use to stretch the password hash + */ + public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 5000) + { + $this->algorithm = $algorithm; + $this->encodeHashAsBase64 = $encodeHashAsBase64; + + try { + $this->hashLength = \strlen($this->hash('', 'salt')); + } catch (\LogicException $e) { + // ignore algorithm not supported + } + + $this->iterations = $iterations; + } + + public function hash(string $plainPassword, ?string $salt = null): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + if (!\in_array($this->algorithm, hash_algos(), true)) { + throw new LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); + } + + $salted = $this->mergePasswordAndSalt($plainPassword, $salt); + $digest = hash($this->algorithm, $salted, true); + + // "stretch" hash + for ($i = 1; $i < $this->iterations; ++$i) { + $digest = hash($this->algorithm, $digest.$salted, true); + } + + return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if (\strlen($hashedPassword) !== $this->hashLength || false !== strpos($hashedPassword, '$')) { + return false; + } + + return !$this->isPasswordTooLong($plainPassword) && hash_equals($hashedPassword, $this->hash($plainPassword, $salt)); + } + + public function needsRehash(string $hashedPassword): bool + { + return false; + } + + private function mergePasswordAndSalt(string $password, ?string $salt): string + { + if (!$salt) { + return $password; + } + + if (false !== strrpos($salt, '{') || false !== strrpos($salt, '}')) { + throw new \InvalidArgumentException('Cannot use { or } in salt.'); + } + + return $password.'{'.$salt.'}'; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php new file mode 100644 index 0000000000000..0fb91d047bbc2 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * Hashes passwords using the best available hasher. + * Verifies them using a chain of hashers. + * + * /!\ Don't put a PlaintextPasswordHasher in the list as that'd mean a leaked hash + * could be used to authenticate successfully without knowing the cleartext password. + * + * @author Nicolas Grekas + */ +final class MigratingPasswordHasher implements PasswordHasherInterface +{ + private $bestHasher; + private $extraHashers; + + public function __construct(PasswordHasherInterface $bestHasher, PasswordHasherInterface ...$extraHashers) + { + $this->bestHasher = $bestHasher; + $this->extraHashers = $extraHashers; + } + + public function hash(string $plainPassword, ?string $salt = null): string + { + return $this->bestHasher->hash($plainPassword, $salt); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if ($this->bestHasher->verify($hashedPassword, $plainPassword, $salt)) { + return true; + } + + if (!$this->bestHasher->needsRehash($hashedPassword)) { + return false; + } + + foreach ($this->extraHashers as $hasher) { + if ($hasher->verify($hashedPassword, $plainPassword, $salt)) { + return true; + } + } + + return false; + } + + public function needsRehash(string $hashedPassword): bool + { + return $this->bestHasher->needsRehash($hashedPassword); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php new file mode 100644 index 0000000000000..80483462c7ad1 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * Hashes passwords using password_hash(). + * + * @author Elnur Abdurrakhimov + * @author Terje Bråten + * @author Nicolas Grekas + */ +final class NativePasswordHasher implements PasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $algorithm = \PASSWORD_BCRYPT; + private $options; + + /** + * @param string|null $algorithm An algorithm supported by password_hash() or null to use the best available algorithm + */ + public function __construct(int $opsLimit = null, int $memLimit = null, int $cost = null, ?string $algorithm = null) + { + $cost = $cost ?? 13; + $opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); + $memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); + + if (3 > $opsLimit) { + throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); + } + + if (10 * 1024 > $memLimit) { + throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); + } + + if ($cost < 4 || 31 < $cost) { + throw new \InvalidArgumentException('$cost must be in the range of 4-31.'); + } + + $algorithms = [1 => \PASSWORD_BCRYPT, '2y' => \PASSWORD_BCRYPT]; + + if (\defined('PASSWORD_ARGON2I')) { + $algorithms[2] = $algorithms['argon2i'] = (string) \PASSWORD_ARGON2I; + } + + if (\defined('PASSWORD_ARGON2ID')) { + $algorithms[3] = $algorithms['argon2id'] = (string) \PASSWORD_ARGON2ID; + } + + if (null !== $algorithm) { + $this->algorithm = $algorithms[$algorithm] ?? $algorithm; + } + + $this->options = [ + 'cost' => $cost, + 'time_cost' => $opsLimit, + 'memory_cost' => $memLimit >> 10, + 'threads' => 1, + ]; + } + + public function hash(string $plainPassword): string + { + if ($this->isPasswordTooLong($plainPassword) || ((string) \PASSWORD_BCRYPT === $this->algorithm && 72 < \strlen($plainPassword))) { + throw new InvalidPasswordException(); + } + + return password_hash($plainPassword, $this->algorithm, $this->options); + } + + public function verify(string $hashedPassword, string $plainPassword): bool + { + if ('' === $plainPassword || $this->isPasswordTooLong($plainPassword)) { + return false; + } + + if (0 !== strpos($hashedPassword, '$argon')) { + // BCrypt encodes only the first 72 chars + return (72 >= \strlen($plainPassword) || 0 !== strpos($hashedPassword, '$2')) && password_verify($plainPassword, $hashedPassword); + } + + if (\extension_loaded('sodium') && version_compare(\SODIUM_LIBRARY_VERSION, '1.0.14', '>=')) { + return sodium_crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + if (\extension_loaded('libsodium') && version_compare(phpversion('libsodium'), '1.0.14', '>=')) { + return \Sodium\crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + return password_verify($plainPassword, $hashedPassword); + } + + /** + * {@inheritdoc} + */ + public function needsRehash(string $hashedPassword): bool + { + return password_needs_rehash($hashedPassword, $this->algorithm, $this->options); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php new file mode 100644 index 0000000000000..58046bc56c60c --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +/** + * @author Christophe Coevoet + */ +interface PasswordHasherAwareInterface +{ + /** + * Gets the name of the password hasher used to hash the password. + * + * If the method returns null, the standard way to retrieve the password hasher + * will be used instead. + */ + public function getPasswordHasherName(): ?string; +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php new file mode 100644 index 0000000000000..b75573f746e89 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\Encoder\EncoderAwareInterface; + +/** + * A generic hasher factory implementation. + * + * @author Nicolas Grekas + * @author Robin Chalas + */ +class PasswordHasherFactory implements PasswordHasherFactoryInterface +{ + private $passwordHashers; + + public function __construct(array $passwordHashers) + { + $this->passwordHashers = $passwordHashers; + } + + /** + * {@inheritdoc} + */ + public function getPasswordHasher($user): PasswordHasherInterface + { + $hasherKey = null; + + if (($user instanceof PasswordHasherAwareInterface && null !== $hasherName = $user->getPasswordHasherName()) || ($user instanceof EncoderAwareInterface && null !== $hasherName = $user->getEncoderName())) { + if (!\array_key_exists($hasherName, $this->passwordHashers)) { + throw new \RuntimeException(sprintf('The password hasher "%s" was not configured.', $hasherName)); + } + + $hasherKey = $hasherName; + } else { + foreach ($this->passwordHashers as $class => $hasher) { + if ((\is_object($user) && $user instanceof $class) || (!\is_object($user) && (is_subclass_of($user, $class) || $user == $class))) { + $hasherKey = $class; + break; + } + } + } + + if (null === $hasherKey) { + throw new \RuntimeException(sprintf('No password hasher has been configured for account "%s".', \is_object($user) ? get_debug_type($user) : $user)); + } + + if (!$this->passwordHashers[$hasherKey] instanceof PasswordHasherInterface) { + $this->passwordHashers[$hasherKey] = $this->createHasher($this->passwordHashers[$hasherKey]); + } + + return $this->passwordHashers[$hasherKey]; + } + + /** + * Creates the actual hasher instance. + * + * @throws \InvalidArgumentException + */ + private function createHasher(array $config, bool $isExtra = false): PasswordHasherInterface + { + if (isset($config['algorithm'])) { + $rawConfig = $config; + $config = $this->getHasherConfigFromAlgorithm($config); + } + if (!isset($config['class'])) { + throw new \InvalidArgumentException('"class" must be set in '.json_encode($config)); + } + if (!isset($config['arguments'])) { + throw new \InvalidArgumentException('"arguments" must be set in '.json_encode($config)); + } + + $hasher = new $config['class'](...$config['arguments']); + + if ($isExtra || !\in_array($config['class'], [NativePasswordHasher::class, SodiumPasswordHasher::class], true)) { + return $hasher; + } + + if ($rawConfig ?? null) { + $extrapasswordHashers = array_map(function (string $algo) use ($rawConfig): PasswordHasherInterface { + $rawConfig['algorithm'] = $algo; + + return $this->createHasher($rawConfig); + }, ['pbkdf2', $rawConfig['hash_algorithm'] ?? 'sha512']); + } else { + $extrapasswordHashers = [new Pbkdf2PasswordHasher(), new MessageDigestPasswordHasher()]; + } + + return new MigratingPasswordHasher($hasher, ...$extrapasswordHashers); + } + + private function getHasherConfigFromAlgorithm(array $config): array + { + if ('auto' === $config['algorithm']) { + // "plaintext" is not listed as any leaked hashes could then be used to authenticate directly + if (SodiumPasswordHasher::isSupported()) { + $algorithms = ['native', 'sodium', 'pbkdf2', $config['hash_algorithm']]; + } else { + $algorithms = ['native', 'pbkdf2', $config['hash_algorithm']]; + } + + $hasherChain = []; + foreach ($algorithms as $algorithm) { + $config['algorithm'] = $algorithm; + $hasherChain[] = $this->createHasher($config, true); + } + + return [ + 'class' => MigratingPasswordHasher::class, + 'arguments' => $hasherChain, + ]; + } + + if ($frompasswordHashers = ($config['migrate_from'] ?? false)) { + unset($config['migrate_from']); + $hasherChain = [$this->createHasher($config, true)]; + + foreach ($frompasswordHashers as $name) { + if ($hasher = $this->passwordHashers[$name] ?? false) { + $hasher = $hasher instanceof PasswordHasherInterface ? $hasher : $this->createHasher($hasher, true); + } else { + $hasher = $this->createHasher(['algorithm' => $name], true); + } + + $hasherChain[] = $hasher; + } + + return [ + 'class' => MigratingPasswordHasher::class, + 'arguments' => $hasherChain, + ]; + } + + switch ($config['algorithm']) { + case 'plaintext': + return [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [$config['ignore_case'] ?? false], + ]; + + case 'pbkdf2': + return [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => [ + $config['hash_algorithm'] ?? 'sha512', + $config['encode_as_base64'] ?? true, + $config['iterations'] ?? 1000, + $config['key_length'] ?? 40, + ], + ]; + + case 'bcrypt': + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_BCRYPT; + + return $this->getHasherConfigFromAlgorithm($config); + + case 'native': + return [ + 'class' => NativePasswordHasher::class, + 'arguments' => [ + $config['time_cost'] ?? null, + (($config['memory_cost'] ?? 0) << 10) ?: null, + $config['cost'] ?? null, + ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []), + ]; + + case 'sodium': + return [ + 'class' => SodiumPasswordHasher::class, + 'arguments' => [ + $config['time_cost'] ?? null, + (($config['memory_cost'] ?? 0) << 10) ?: null, + ], + ]; + + case 'argon2i': + if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2I')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2I; + } else { + throw new LogicException(sprintf('Algorithm "argon2i" is not available. Use "%s" instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id" or "auto' : 'auto')); + } + + return $this->getHasherConfigFromAlgorithm($config); + + case 'argon2id': + if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2ID')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2ID; + } else { + throw new LogicException(sprintf('Algorithm "argon2id" is not available. Either use "%s", upgrade to PHP 7.3+ or use libsodium 1.0.15+ instead.', \defined('PASSWORD_ARGON2I') || $hasSodium ? 'argon2i", "auto' : 'auto')); + } + + return $this->getHasherConfigFromAlgorithm($config); + } + + return [ + 'class' => MessageDigestPasswordHasher::class, + 'arguments' => [ + $config['algorithm'], + $config['encode_as_base64'] ?? true, + $config['iterations'] ?? 5000, + ], + ]; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php new file mode 100644 index 0000000000000..038c34a318174 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * PasswordHasherFactoryInterface to support different password hashers for different user accounts. + * + * @author Robin Chalas + * @author Johannes M. Schmitt + */ +interface PasswordHasherFactoryInterface +{ + /** + * Returns the password hasher to use for the given user. + * + * @param UserInterface|string $user A UserInterface instance or a class name + * + * @throws \RuntimeException When no password hasher could be found for the user + */ + public function getPasswordHasher($user): PasswordHasherInterface; +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php new file mode 100644 index 0000000000000..dd2e742db79fe --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * Pbkdf2PasswordHasher uses the PBKDF2 (Password-Based Key Derivation Function 2). + * + * Providing a high level of Cryptographic security, + * PBKDF2 is recommended by the National Institute of Standards and Technology (NIST). + * + * But also warrants a warning, using PBKDF2 (with a high number of iterations) slows down the process. + * PBKDF2 should be used with caution and care. + * + * @author Sebastiaan Stok + * @author Andrew Johnson + * @author Fabien Potencier + */ +final class Pbkdf2PasswordHasher implements LegacyPasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $algorithm; + private $encodeHashAsBase64; + private $iterations = 1; + private $length; + private $encodedLength = -1; + + /** + * @param string $algorithm The digest algorithm to use + * @param bool $encodeHashAsBase64 Whether to base64 encode the password hash + * @param int $iterations The number of iterations to use to stretch the password hash + * @param int $length Length of derived key to create + */ + public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 1000, int $length = 40) + { + $this->algorithm = $algorithm; + $this->encodeHashAsBase64 = $encodeHashAsBase64; + $this->length = $length; + + try { + $this->encodedLength = \strlen($this->hash('', 'salt')); + } catch (\LogicException $e) { + // ignore unsupported algorithm + } + + $this->iterations = $iterations; + } + + public function hash(string $plainPassword, ?string $salt = null): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + if (!\in_array($this->algorithm, hash_algos(), true)) { + throw new LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); + } + + $digest = hash_pbkdf2($this->algorithm, $plainPassword, $salt, $this->iterations, $this->length, true); + + return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if (\strlen($hashedPassword) !== $this->encodedLength || false !== strpos($hashedPassword, '$')) { + return false; + } + + return !$this->isPasswordTooLong($plainPassword) && hash_equals($hashedPassword, $this->hash($plainPassword, $salt)); + } + + public function needsRehash(string $hashedPassword): bool + { + return false; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php new file mode 100644 index 0000000000000..bafe6bce898c7 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * PlaintextPasswordHasher does not do any hashing but is useful in testing environments. + * + * As this hasher is not cryptographically secure, usage of it in production environments is discouraged. + * + * @author Fabien Potencier + */ +class PlaintextPasswordHasher implements LegacyPasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $ignorePasswordCase; + + /** + * @param bool $ignorePasswordCase Compare password case-insensitive + */ + public function __construct(bool $ignorePasswordCase = false) + { + $this->ignorePasswordCase = $ignorePasswordCase; + } + + /** + * {@inheritdoc} + */ + public function hash(string $plainPassword, ?string $salt = null): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + return $this->mergePasswordAndSalt($plainPassword, $salt); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if ($this->isPasswordTooLong($plainPassword)) { + return false; + } + + $pass2 = $this->mergePasswordAndSalt($plainPassword, $salt); + + if (!$this->ignorePasswordCase) { + return hash_equals($hashedPassword, $pass2); + } + + return hash_equals(strtolower($hashedPassword), strtolower($pass2)); + } + + public function needsRehash(string $hashedPassword): bool + { + return false; + } + + private function mergePasswordAndSalt(string $password, ?string $salt): string + { + if (empty($salt)) { + return $password; + } + + if (false !== strrpos($salt, '{') || false !== strrpos($salt, '}')) { + throw new \InvalidArgumentException('Cannot use { or } in salt.'); + } + + return $password.'{'.$salt.'}'; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php new file mode 100644 index 0000000000000..626878815b880 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * Hashes passwords using libsodium. + * + * @author Robin Chalas + * @author Zan Baldwin + * @author Dominik Müller + */ +final class SodiumPasswordHasher implements PasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $opsLimit; + private $memLimit; + + public function __construct(int $opsLimit = null, int $memLimit = null) + { + if (!self::isSupported()) { + throw new LogicException('Libsodium is not available. You should either install the sodium extension or use a different password hasher.'); + } + + $this->opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); + $this->memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); + + if (3 > $this->opsLimit) { + throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); + } + + if (10 * 1024 > $this->memLimit) { + throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); + } + } + + public static function isSupported(): bool + { + return version_compare(\extension_loaded('sodium') ? \SODIUM_LIBRARY_VERSION : phpversion('libsodium'), '1.0.14', '>='); + } + + public function hash(string $plainPassword): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + if (\function_exists('sodium_crypto_pwhash_str')) { + return sodium_crypto_pwhash_str($plainPassword, $this->opsLimit, $this->memLimit); + } + + if (\extension_loaded('libsodium')) { + return \Sodium\crypto_pwhash_str($plainPassword, $this->opsLimit, $this->memLimit); + } + + throw new LogicException('Libsodium is not available. You should either install the sodium extension or use a different password hasher.'); + } + + public function verify(string $hashedPassword, string $plainPassword): bool + { + if ('' === $plainPassword) { + return false; + } + + if ($this->isPasswordTooLong($plainPassword)) { + return false; + } + + if (0 !== strpos($hashedPassword, '$argon')) { + // Accept validating non-argon passwords for seamless migrations + return (72 >= \strlen($plainPassword) || 0 !== strpos($hashedPassword, '$2')) && password_verify($plainPassword, $hashedPassword); + } + + if (\function_exists('sodium_crypto_pwhash_str_verify')) { + return sodium_crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + if (\extension_loaded('libsodium')) { + return \Sodium\crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + return false; + } + + public function needsRehash(string $hashedPassword): bool + { + if (\function_exists('sodium_crypto_pwhash_str_needs_rehash')) { + return sodium_crypto_pwhash_str_needs_rehash($hashedPassword, $this->opsLimit, $this->memLimit); + } + + if (\extension_loaded('libsodium')) { + return \Sodium\crypto_pwhash_str_needs_rehash($hashedPassword, $this->opsLimit, $this->memLimit); + } + + throw new LogicException('Libsodium is not available. You should either install the sodium extension or use a different password hasher.'); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php new file mode 100644 index 0000000000000..bb11680665003 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Hashes passwords based on the user and the PasswordHasherFactory. + * + * @author Ariel Ferrandini + */ +class UserPasswordHasher implements UserPasswordHasherInterface +{ + private $hasherFactory; + + public function __construct(PasswordHasherFactoryInterface $hasherFactory) + { + $this->hasherFactory = $hasherFactory; + } + + public function hashPassword(UserInterface $user, string $plainPassword): string + { + $hasher = $this->hasherFactory->getPasswordHasher($user); + + return $hasher->hash($plainPassword, $user->getSalt()); + } + + /** + * {@inheritdoc} + */ + public function isPasswordValid(UserInterface $user, string $plainPassword): bool + { + if (null === $user->getPassword()) { + return false; + } + + $hasher = $this->hasherFactory->getPasswordHasher($user); + + return $hasher->verify($user->getPassword(), $plainPassword, $user->getSalt()); + } + + /** + * {@inheritdoc} + */ + public function needsRehash(UserInterface $user): bool + { + if (null === $user->getPassword()) { + return false; + } + + $hasher = $this->hasherFactory->getPasswordHasher($user); + + return $hasher->needsRehash($user->getPassword()); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php new file mode 100644 index 0000000000000..13a5b51a8a119 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Interface for the user password hasher service. + * + * @author Ariel Ferrandini + */ +interface UserPasswordHasherInterface +{ + /** + * Hashes the plain password for the given user. + */ + public function hashPassword(UserInterface $user, string $plainPassword): string; + + /** + * Checks if the plaintext password matches the user's password. + */ + public function isPasswordValid(UserInterface $user, string $plainPassword): bool; + + /** + * Checks if a password hash would benefit from rehashing. + */ + public function needsRehash(UserInterface $user): bool; +} diff --git a/src/Symfony/Component/PasswordHasher/LICENSE b/src/Symfony/Component/PasswordHasher/LICENSE new file mode 100644 index 0000000000000..9ff2d0d6306da --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2021 Fabien Potencier + +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/src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php b/src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php new file mode 100644 index 0000000000000..3faf96d2f4d27 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; + +/** + * Provides password hashing and verification capabilities for "legacy" hashers that require external salts. + * + * @author Fabien Potencier + * @author Nicolas Grekas + * @author Robin Chalas + */ +interface LegacyPasswordHasherInterface extends PasswordHasherInterface +{ + /** + * Hashes a plain password. + * + * @return string The hashed password + * + * @throws InvalidPasswordException If the plain password is invalid, e.g. excessively long + */ + public function hash(string $plainPassword, ?string $salt = null): string; + + /** + * Checks that a plain password and a salt match a password hash. + */ + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool; +} diff --git a/src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php b/src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php new file mode 100644 index 0000000000000..6b3575783891f --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; + +/** + * Provides password hashing capabilities. + * + * @author Robin Chalas + * @author Fabien Potencier + * @author Nicolas Grekas + */ +interface PasswordHasherInterface +{ + public const MAX_PASSWORD_LENGTH = 4096; + + /** + * Hashes a plain password. + * + * @throws InvalidPasswordException When the plain password is invalid, e.g. excessively long + */ + public function hash(string $plainPassword): string; + + /** + * Verifies a plain password against a hash. + */ + public function verify(string $hashedPassword, string $plainPassword): bool; + + /** + * Checks if a password hash would benefit from rehashing. + */ + public function needsRehash(string $hashedPassword): bool; +} diff --git a/src/Symfony/Component/PasswordHasher/README.md b/src/Symfony/Component/PasswordHasher/README.md new file mode 100644 index 0000000000000..6a54ecb3355bf --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/README.md @@ -0,0 +1,40 @@ +PasswordHasher Component +======================== + +The PasswordHasher component provides secure password hashing utilities. + +Getting Started +--------------- + +``` +$ composer require symfony/password-hasher +``` + +```php +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; + +// Configure different password hashers via the factory +$factory = new PasswordHasherFactory([ + 'common' => ['algorithm' => 'bcrypt'], + 'memory-hard' => ['algorithm' => 'sodium'], +]); + +// Retrieve the right password hasher by its name +$passwordHasher = $factory->getPasswordHasher('common'); + +// Hash a plain password +$hash = $passwordHasher->hash('plain'); // returns a bcrypt hash + +// Verify that a given plain password matches the hash +$passwordHasher->verify($hash, 'wrong'); // returns false +$passwordHasher->verify($hash, 'plain'); // returns true (valid) +``` + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/password-hasher.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php b/src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php new file mode 100644 index 0000000000000..c633e6240f6b6 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php @@ -0,0 +1,361 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; +use Symfony\Component\Security\Core\User\User; + +class UserPasswordHashCommandTest extends TestCase +{ + /** @var CommandTester */ + private $passwordHasherCommandTester; + + public function testEncodePasswordEmptySalt() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Symfony\Component\Security\Core\User\User', + '--empty-salt' => true, + ], ['decorated' => false]); + + $this->assertStringContainsString(' Password hash password', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodeNoPasswordNoInteraction() + { + $statusCode = $this->passwordHasherCommandTester->execute([ + ], ['interactive' => false]); + + $this->assertStringContainsString('[ERROR] The password must not be empty.', $this->passwordHasherCommandTester->getDisplay()); + $this->assertEquals(1, $statusCode); + } + + public function testEncodePasswordBcrypt() + { + $this->setupBcrypt(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Bcrypt\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = new NativePasswordHasher(null, null, 17, \PASSWORD_BCRYPT); + preg_match('# Password hash\s{1,}([\w+\/$.]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordArgon2i() + { + if (!($sodium = SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm not available.'); + } + $this->setupArgon2i(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Argon2i\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = $sodium ? new SodiumPasswordHasher() : new NativePasswordHasher(null, null, null, \PASSWORD_ARGON2I); + preg_match('# Password hash\s+(\$argon2i?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordArgon2id() + { + if (!($sodium = (SodiumPasswordHasher::isSupported() && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13'))) && !\defined('PASSWORD_ARGON2ID')) { + $this->markTestSkipped('Argon2id algorithm not available.'); + } + $this->setupArgon2id(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Argon2id\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = $sodium ? new SodiumPasswordHasher() : new NativePasswordHasher(null, null, null, \PASSWORD_ARGON2ID); + preg_match('# Password hash\s+(\$argon2id?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordNative() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Native\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = new NativePasswordHasher(); + preg_match('# Password hash\s{1,}([\w+\/$.,=]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordSodium() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + $this->setupSodium(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Sodium\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + preg_match('# Password hash\s+(\$?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue((new SodiumPasswordHasher())->verify($hash, 'password', null)); + } + + public function testEncodePasswordPbkdf2() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Pbkdf2\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = new Pbkdf2PasswordHasher('sha512', true, 1000); + preg_match('# Password hash\s{1,}([\w+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + preg_match('# Generated salt\s{1,}([\w+\/]+={0,2})\s+#', $output, $matches); + $salt = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', $salt)); + } + + public function testEncodePasswordOutput() + { + $this->passwordHasherCommandTester->execute( + [ + 'password' => 'p@ssw0rd', + ], ['interactive' => false] + ); + + $this->assertStringContainsString('Password hashing succeeded', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringContainsString(' Password hash p@ssw0rd', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordEmptySaltOutput() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Symfony\Component\Security\Core\User\User', + '--empty-salt' => true, + ]); + + $this->assertStringContainsString('Password hashing succeeded', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringContainsString(' Password hash p@ssw0rd', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordNativeOutput() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Native\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordArgon2iOutput() + { + if (!(SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm not available.'); + } + + $this->setupArgon2i(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Argon2i\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordArgon2idOutput() + { + if (!(SodiumPasswordHasher::isSupported() && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2ID')) { + $this->markTestSkipped('Argon2id algorithm not available.'); + } + + $this->setupArgon2id(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Argon2id\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordSodiumOutput() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + + $this->setupSodium(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Sodium\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordNoConfigForGivenUserClass() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No password hasher has been configured for account "Foo\Bar\User".'); + + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Foo\Bar\User', + ], ['interactive' => false]); + } + + public function testEncodePasswordAsksNonProvidedUserClass() + { + $this->passwordHasherCommandTester->setInputs(['Custom\Class\Pbkdf2\User', "\n"]); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + ], ['decorated' => false]); + + $this->assertStringContainsString(<<passwordHasherCommandTester->getDisplay(true)); + } + + public function testNonInteractiveEncodePasswordUsesFirstUserClass() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + ], ['interactive' => false]); + + $this->assertStringContainsString('Hasher used Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testThrowsExceptionOnNoConfiguredHashers() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('There are no configured password hashers for the "security" extension.'); + + $tester = new CommandTester(new UserPasswordHashCommand($this->getMockBuilder(PasswordHasherFactoryInterface::class)->getMock(), [])); + $tester->execute([ + 'password' => 'password', + ], ['interactive' => false]); + } + + protected function setUp(): void + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + $hasherFactory = new PasswordHasherFactory([ + User::class => ['algorithm' => 'plaintext'], + 'Custom\Class\Native\User' => ['algorithm' => 'native', 'cost' => 10], + 'Custom\Class\Pbkdf2\User' => ['algorithm' => 'pbkdf2', 'hash_algorithm' => 'sha512', 'iterations' => 1000, 'encode_as_base64' => true], + 'Custom\Class\Test\User' => ['algorithm' => 'test'], + ]); + + $this->passwordHasherCommandTester = new CommandTester(new UserPasswordHashCommand( + $hasherFactory, + [User::class, 'Custom\Class\Native\User', 'Custom\Class\Pbkdf2\User', 'Custom\Class\Test\User'] + )); + } + + protected function tearDown(): void + { + $this->passwordHasherCommandTester = null; + } + + private function setupArgon2i() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Argon2i\User' => ['algorithm' => 'argon2i'], + ]); + + $this->passwordHasherCommandTester = new CommandTester( + new UserPasswordHashCommand($hasherFactory, ['Custom\Class\Argon2i\User']) + ); + } + + private function setupArgon2id() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Argon2id\User' => ['algorithm' => 'argon2id'], + ]); + + $this->passwordHasherCommandTester = new CommandTester( + new UserPasswordHashCommand($hasherFactory, ['Custom\Class\Argon2id\User']) + ); + } + + private function setupBcrypt() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Bcrypt\User' => ['algorithm' => 'bcrypt'], + ]); + + $this->passwordHasherCommandTester = new CommandTester(new UserPasswordHashCommand( + $hasherFactory, + [User::class, 'Custom\Class\Pbkdf2\User', 'Custom\Class\Test\User'] + )); + } + + private function setupSodium() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Sodium\User' => ['algorithm' => 'sodium'], + ]); + + $this->passwordHasherCommandTester = new CommandTester( + new UserPasswordHashCommand($hasherFactory, ['Custom\Class\Sodium\User']) + ); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php new file mode 100644 index 0000000000000..6abcb797b9c27 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; + +class MessageDigestPasswordHasherTest extends TestCase +{ + public function testVerify() + { + $hasher = new MessageDigestPasswordHasher('sha256', false, 1); + + $this->assertTrue($hasher->verify(hash('sha256', 'password'), 'password', '')); + } + + public function testHash() + { + $hasher = new MessageDigestPasswordHasher('sha256', false, 1); + $this->assertSame(hash('sha256', 'password'), $hasher->hash('password', '')); + + $hasher = new MessageDigestPasswordHasher('sha256', true, 1); + $this->assertSame(base64_encode(hash('sha256', 'password', true)), $hasher->hash('password', '')); + + $hasher = new MessageDigestPasswordHasher('sha256', false, 2); + $this->assertSame(hash('sha256', hash('sha256', 'password', true).'password'), $hasher->hash('password', '')); + } + + public function testHashAlgorithmDoesNotExist() + { + $this->expectException(\LogicException::class); + $hasher = new MessageDigestPasswordHasher('foobar'); + $hasher->hash('password', ''); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new MessageDigestPasswordHasher(); + + $hasher->hash(str_repeat('a', 5000), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new MessageDigestPasswordHasher(); + + $this->assertFalse($hasher->verify('encoded', str_repeat('a', 5000), 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php new file mode 100644 index 0000000000000..145a5cc34e33d --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\MigratingPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +class MigratingPasswordHasherTest extends TestCase +{ + public function testValidation() + { + $bestHasher = new NativePasswordHasher(4, 12000, 4); + + $extraHasher = $this->createMock(PasswordHasherInterface::class); + $extraHasher->expects($this->never())->method('hash'); + $extraHasher->expects($this->never())->method('verify'); + $extraHasher->expects($this->never())->method('needsRehash'); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher); + + $this->assertTrue($hasher->needsRehash('foo')); + + $hash = $hasher->hash('foo', 'salt'); + $this->assertFalse($hasher->needsRehash($hash)); + + $this->assertTrue($hasher->verify($hash, 'foo', 'salt')); + $this->assertFalse($hasher->verify($hash, 'bar', 'salt')); + } + + public function testFallback() + { + $bestHasher = new NativePasswordHasher(4, 12000, 4); + + $extraHasher1 = $this->createMock(PasswordHasherInterface::class); + $extraHasher1->expects($this->any()) + ->method('verify') + ->with('abc', 'foo', 'salt') + ->willReturn(true); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher1); + + $this->assertTrue($hasher->verify('abc', 'foo', 'salt')); + + $extraHasher2 = $this->createMock(PasswordHasherInterface::class); + $extraHasher2->expects($this->any()) + ->method('verify') + ->willReturn(false); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher2); + + $this->assertFalse($hasher->verify('abc', 'foo', 'salt')); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher2, $extraHasher1); + + $this->assertTrue($hasher->verify('abc', 'foo', 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php new file mode 100644 index 0000000000000..8132bc76933f9 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; + +/** + * @author Elnur Abdurrakhimov + */ +class NativePasswordHasherTest extends TestCase +{ + public function testCostBelowRange() + { + $this->expectException(\InvalidArgumentException::class); + new NativePasswordHasher(null, null, 3); + } + + public function testCostAboveRange() + { + $this->expectException(\InvalidArgumentException::class); + new NativePasswordHasher(null, null, 32); + } + + /** + * @dataProvider validRangeData + */ + public function testCostInRange($cost) + { + $this->assertInstanceOf(NativePasswordHasher::class, new NativePasswordHasher(null, null, $cost)); + } + + public function validRangeData() + { + $costs = range(4, 31); + array_walk($costs, function (&$cost) { $cost = [$cost]; }); + + return $costs; + } + + public function testValidation() + { + $hasher = new NativePasswordHasher(); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertFalse($hasher->verify($result, 'anotherPassword', null)); + $this->assertFalse($hasher->verify($result, '', null)); + } + + public function testNonArgonValidation() + { + $hasher = new NativePasswordHasher(); + $this->assertTrue($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'password', null)); + $this->assertFalse($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'anotherPassword', null)); + $this->assertTrue($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'password', null)); + $this->assertFalse($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'anotherPassword', null)); + } + + public function testConfiguredAlgorithm() + { + $hasher = new NativePasswordHasher(null, null, null, \PASSWORD_BCRYPT); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertStringStartsWith('$2', $result); + } + + public function testDefaultAlgorithm() + { + $hasher = new NativePasswordHasher(); + $result = $hasher->hash('password'); + $this->assertTrue($hasher->verify($result, 'password')); + $this->assertStringStartsWith('$2', $result); + } + + public function testConfiguredAlgorithmWithLegacyConstValue() + { + $hasher = new NativePasswordHasher(null, null, null, '1'); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertStringStartsWith('$2', $result); + } + + public function testCheckPasswordLength() + { + $hasher = new NativePasswordHasher(null, null, 4); + $result = password_hash(str_repeat('a', 72), \PASSWORD_BCRYPT, ['cost' => 4]); + + $this->assertFalse($hasher->verify($result, str_repeat('a', 73), 'salt')); + $this->assertTrue($hasher->verify($result, str_repeat('a', 72), 'salt')); + } + + public function testNeedsRehash() + { + $hasher = new NativePasswordHasher(4, 11000, 4); + + $this->assertTrue($hasher->needsRehash('dummyhash')); + + $hash = $hasher->hash('foo', 'salt'); + $this->assertFalse($hasher->needsRehash($hash)); + + $hasher = new NativePasswordHasher(5, 11000, 5); + $this->assertTrue($hasher->needsRehash($hash)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php new file mode 100644 index 0000000000000..61c17f18f24ff --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\MigratingPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserInterface; + +class PasswordHasherFactoryTest extends TestCase +{ + public function testGetHasherWithMessageDigestHasher() + { + $factory = new PasswordHasherFactory([UserInterface::class => [ + 'class' => MessageDigestPasswordHasher::class, + 'arguments' => ['sha512', true, 5], + ]]); + + $hasher = $factory->getPasswordHasher($this->createMock(UserInterface::class)); + $expectedHasher = new MessageDigestPasswordHasher('sha512', true, 5); + + $this->assertEquals($expectedHasher->hash('foo', 'moo'), $hasher->hash('foo', 'moo')); + } + + public function testGetHasherWithService() + { + $factory = new PasswordHasherFactory([ + UserInterface::class => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher($this->createMock(UserInterface::class)); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + + $hasher = $factory->getPasswordHasher(new User('user', 'pass')); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetHasherWithClassName() + { + $factory = new PasswordHasherFactory([ + UserInterface::class => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(SomeChildUser::class); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetHasherConfiguredForConcreteClassWithService() + { + $factory = new PasswordHasherFactory([ + 'Symfony\Component\Security\Core\User\User' => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(new User('user', 'pass')); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetHasherConfiguredForConcreteClassWithClassName() + { + $factory = new PasswordHasherFactory([ + 'Symfony\Component\PasswordHasher\Tests\Hasher\SomeUser' => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(SomeChildUser::class); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetNamedHasherForHasherAware() + { + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha256'), + 'hasher_name' => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(new HasherAwareUser('user', 'pass')); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetNullNamedHasherForHasherAware() + { + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha1'), + 'hasher_name' => new MessageDigestPasswordHasher('sha256'), + ]); + + $user = new HasherAwareUser('mathilde', 'krogulec'); + $user->hasherName = null; + $hasher = $factory->getPasswordHasher($user); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetInvalidNamedHasherForHasherAware() + { + $this->expectException(\RuntimeException::class); + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha1'), + 'hasher_name' => new MessageDigestPasswordHasher('sha256'), + ]); + + $user = new HasherAwareUser('user', 'pass'); + $user->hasherName = 'invalid_hasher_name'; + $factory->getPasswordHasher($user); + } + + public function testGetHasherForHasherAwareWithClassName() + { + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha1'), + 'hasher_name' => new MessageDigestPasswordHasher('sha256'), + ]); + + $hasher = $factory->getPasswordHasher(HasherAwareUser::class); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testMigrateFrom() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Sodium is not available'); + } + + $factory = new PasswordHasherFactory([ + 'digest_hasher' => $digest = new MessageDigestPasswordHasher('sha256'), + SomeUser::class => ['algorithm' => 'sodium', 'migrate_from' => ['bcrypt', 'digest_hasher']], + ]); + + $hasher = $factory->getPasswordHasher(SomeUser::class); + $this->assertInstanceOf(MigratingPasswordHasher::class, $hasher); + + $this->assertTrue($hasher->verify((new SodiumPasswordHasher())->hash('foo', null), 'foo', null)); + $this->assertTrue($hasher->verify((new NativePasswordHasher(null, null, null, \PASSWORD_BCRYPT))->hash('foo', null), 'foo', null)); + $this->assertTrue($hasher->verify($digest->hash('foo', null), 'foo', null)); + $this->assertStringStartsWith(\SODIUM_CRYPTO_PWHASH_STRPREFIX, $hasher->hash('foo', null)); + } + + public function testDefaultMigratingHashers() + { + $this->assertInstanceOf( + MigratingPasswordHasher::class, + (new PasswordHasherFactory([SomeUser::class => ['class' => NativePasswordHasher::class, 'arguments' => []]]))->getPasswordHasher(SomeUser::class) + ); + + $this->assertInstanceOf( + MigratingPasswordHasher::class, + (new PasswordHasherFactory([SomeUser::class => ['algorithm' => 'bcrypt', 'cost' => 11]]))->getPasswordHasher(SomeUser::class) + ); + + if (!SodiumPasswordHasher::isSupported()) { + return; + } + + $this->assertInstanceOf( + MigratingPasswordHasher::class, + (new PasswordHasherFactory([SomeUser::class => ['class' => SodiumPasswordHasher::class, 'arguments' => []]]))->getPasswordHasher(SomeUser::class) + ); + } +} + +class SomeUser implements UserInterface +{ + public function getRoles(): array + { + } + + public function getPassword(): ?string + { + } + + public function getSalt(): ?string + { + } + + public function getUsername(): string + { + } + + public function eraseCredentials() + { + } +} + +class SomeChildUser extends SomeUser +{ +} + +class HasherAwareUser extends SomeUser implements PasswordHasherAwareInterface +{ + public $hasherName = 'hasher_name'; + + public function getPasswordHasherName(): ?string + { + return $this->hasherName; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php new file mode 100644 index 0000000000000..05785b2141c46 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; + +class Pbkdf2PasswordHasherTest extends TestCase +{ + public function testVerify() + { + $hasher = new Pbkdf2PasswordHasher('sha256', false, 1, 40); + + $this->assertTrue($hasher->verify('c1232f10f62715fda06ae7c0a2037ca19b33cf103b727ba56d870c11f290a2ab106974c75607c8a3', 'password', '')); + } + + public function testHash() + { + $hasher = new Pbkdf2PasswordHasher('sha256', false, 1, 40); + $this->assertSame('c1232f10f62715fda06ae7c0a2037ca19b33cf103b727ba56d870c11f290a2ab106974c75607c8a3', $hasher->hash('password', '')); + + $hasher = new Pbkdf2PasswordHasher('sha256', true, 1, 40); + $this->assertSame('wSMvEPYnFf2gaufAogN8oZszzxA7cnulbYcMEfKQoqsQaXTHVgfIow==', $hasher->hash('password', '')); + + $hasher = new Pbkdf2PasswordHasher('sha256', false, 2, 40); + $this->assertSame('8bc2f9167a81cdcfad1235cd9047f1136271c1f978fcfcb35e22dbeafa4634f6fd2214218ed63ebb', $hasher->hash('password', '')); + } + + public function testHashAlgorithmDoesNotExist() + { + $this->expectException(\LogicException::class); + $hasher = new Pbkdf2PasswordHasher('foobar'); + $hasher->hash('password', ''); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new Pbkdf2PasswordHasher('foobar'); + + $hasher->hash(str_repeat('a', 5000), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new Pbkdf2PasswordHasher('foobar'); + + $this->assertFalse($hasher->verify('encoded', str_repeat('a', 5000), 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php new file mode 100644 index 0000000000000..dc24db632ab16 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; + +class PlaintextPasswordHasherTest extends TestCase +{ + public function testVerify() + { + $hasher = new PlaintextPasswordHasher(); + + $this->assertTrue($hasher->verify('foo', 'foo', '')); + $this->assertFalse($hasher->verify('bar', 'foo', '')); + $this->assertFalse($hasher->verify('FOO', 'foo', '')); + + $hasher = new PlaintextPasswordHasher(true); + + $this->assertTrue($hasher->verify('foo', 'foo', '')); + $this->assertFalse($hasher->verify('bar', 'foo', '')); + $this->assertTrue($hasher->verify('FOO', 'foo', '')); + } + + public function testHash() + { + $hasher = new PlaintextPasswordHasher(); + + $this->assertSame('foo', $hasher->hash('foo', '')); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new PlaintextPasswordHasher(); + + $hasher->hash(str_repeat('a', 5000), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new PlaintextPasswordHasher(); + + $this->assertFalse($hasher->verify('encoded', str_repeat('a', 5000), 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php new file mode 100644 index 0000000000000..2da309ae92dea --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; + +class SodiumPasswordHasherTest extends TestCase +{ + protected function setUp(): void + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + } + + public function testValidation() + { + $hasher = new SodiumPasswordHasher(); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertFalse($hasher->verify($result, 'anotherPassword', null)); + $this->assertFalse($hasher->verify($result, '', null)); + } + + public function testBCryptValidation() + { + $hasher = new SodiumPasswordHasher(); + $this->assertTrue($hasher->verify('$2y$04$M8GDODMoGQLQRpkYCdoJh.lbiZPee3SZI32RcYK49XYTolDGwoRMm', 'abc', null)); + } + + public function testNonArgonValidation() + { + $hasher = new SodiumPasswordHasher(); + $this->assertTrue($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'password', null)); + $this->assertFalse($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'anotherPassword', null)); + $this->assertTrue($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'password', null)); + $this->assertFalse($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'anotherPassword', null)); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new SodiumPasswordHasher(); + $hasher->hash(str_repeat('a', 4097), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new SodiumPasswordHasher(); + $result = $hasher->hash(str_repeat('a', 4096), null); + $this->assertFalse($hasher->verify($result, str_repeat('a', 4097), null)); + $this->assertTrue($hasher->verify($result, str_repeat('a', 4096), null)); + } + + public function testUserProvidedSaltIsNotUsed() + { + $hasher = new SodiumPasswordHasher(); + $result = $hasher->hash('password', 'salt'); + $this->assertTrue($hasher->verify($result, 'password', 'anotherSalt')); + } + + public function testNeedsRehash() + { + $hasher = new SodiumPasswordHasher(4, 11000); + + $this->assertTrue($hasher->needsRehash('dummyhash')); + + $hash = $hasher->hash('foo', 'salt'); + $this->assertFalse($hasher->needsRehash($hash)); + + $hasher = new SodiumPasswordHasher(5, 11000); + $this->assertTrue($hasher->needsRehash($hash)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php new file mode 100644 index 0000000000000..5aaee750af1ed --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserInterface; + +class UserPasswordHasherTest extends TestCase +{ + public function testHash() + { + $userMock = $this->createMock('Symfony\Component\Security\Core\User\UserInterface'); + $userMock->expects($this->any()) + ->method('getSalt') + ->willReturn('userSalt'); + + $mockHasher = $this->createMock(PasswordHasherInterface::class); + $mockHasher->expects($this->any()) + ->method('hash') + ->with($this->equalTo('plainPassword'), $this->equalTo('userSalt')) + ->willReturn('hash'); + + $mockPasswordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $mockPasswordHasherFactory->expects($this->any()) + ->method('getPasswordHasher') + ->with($this->equalTo($userMock)) + ->willReturn($mockHasher); + + $passwordHasher = new UserPasswordHasher($mockPasswordHasherFactory); + + $encoded = $passwordHasher->hashPassword($userMock, 'plainPassword'); + $this->assertEquals('hash', $encoded); + } + + public function testVerify() + { + $userMock = $this->createMock(UserInterface::class); + $userMock->expects($this->any()) + ->method('getSalt') + ->willReturn('userSalt'); + $userMock->expects($this->any()) + ->method('getPassword') + ->willReturn('hash'); + + $mockHasher = $this->createMock(PasswordHasherInterface::class); + $mockHasher->expects($this->any()) + ->method('verify') + ->with($this->equalTo('hash'), $this->equalTo('plainPassword'), $this->equalTo('userSalt')) + ->willReturn(true); + + $mockPasswordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $mockPasswordHasherFactory->expects($this->any()) + ->method('getPasswordHasher') + ->with($this->equalTo($userMock)) + ->willReturn($mockHasher); + + $passwordHasher = new UserPasswordHasher($mockPasswordHasherFactory); + + $isValid = $passwordHasher->isPasswordValid($userMock, 'plainPassword'); + $this->assertTrue($isValid); + } + + public function testNeedsRehash() + { + $user = new User('username', null); + $hasher = new NativePasswordHasher(4, 20000, 4); + + $mockPasswordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $mockPasswordHasherFactory->expects($this->any()) + ->method('getPasswordHasher') + ->with($user) + ->will($this->onConsecutiveCalls($hasher, $hasher, new NativePasswordHasher(5, 20000, 5), $hasher)); + + $passwordHasher = new UserPasswordHasher($mockPasswordHasherFactory); + + $user->setPassword($passwordHasher->hashPassword($user, 'foo', 'salt')); + $this->assertFalse($passwordHasher->needsRehash($user)); + $this->assertTrue($passwordHasher->needsRehash($user)); + $this->assertFalse($passwordHasher->needsRehash($user)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/composer.json b/src/Symfony/Component/PasswordHasher/composer.json new file mode 100644 index 0000000000000..2ed22ee7066d5 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/password-hasher", + "type": "library", + "description": "Provides password hashing utilities", + "keywords": ["password", "hashing"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "symfony/security-core": "^5.3", + "symfony/console": "^5" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\PasswordHasher\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/PasswordHasher/phpunit.xml.dist b/src/Symfony/Component/PasswordHasher/phpunit.xml.dist new file mode 100644 index 0000000000000..ee4c67f3058c7 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/PropertyInfo/CHANGELOG.md b/src/Symfony/Component/PropertyInfo/CHANGELOG.md index a766e6b302242..5f9979c08dfff 100644 --- a/src/Symfony/Component/PropertyInfo/CHANGELOG.md +++ b/src/Symfony/Component/PropertyInfo/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * Add support for multiple types for collection keys & values + * Deprecate the `Type::getCollectionKeyType()` and `Type::getCollectionValueType()` methods, use `Type::getCollectionKeyTypes()` and `Type::getCollectionValueTypes()` instead + 5.2.0 ----- diff --git a/src/Symfony/Component/PropertyInfo/Extractor/PhpDocExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/PhpDocExtractor.php index f8b62b5e917fb..3e2a68630d0ef 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/PhpDocExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/PhpDocExtractor.php @@ -165,7 +165,7 @@ public function getTypes(string $class, string $property, array $context = []): continue 2; } - $types[] = new Type(Type::BUILTIN_TYPE_OBJECT, $type->isNullable(), $resolvedClass, $type->isCollection(), $type->getCollectionKeyType(), $type->getCollectionValueType()); + $types[] = new Type(Type::BUILTIN_TYPE_OBJECT, $type->isNullable(), $resolvedClass, $type->isCollection(), $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); } } } diff --git a/src/Symfony/Component/PropertyInfo/Tests/TypeTest.php b/src/Symfony/Component/PropertyInfo/Tests/TypeTest.php index 7127fc15b367e..7d8c39fe2ea99 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/TypeTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/TypeTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\PropertyInfo\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\PropertyInfo\Type; /** @@ -19,8 +20,16 @@ */ class TypeTest extends TestCase { - public function testConstruct() + use ExpectDeprecationTrait; + + /** + * @group legacy + */ + public function testLegacyConstruct() { + $this->expectDeprecation('Since symfony/property-info 5.3: The "Symfony\Component\PropertyInfo\Type::getCollectionKeyType()" method is deprecated, use "getCollectionKeyTypes()" instead.'); + $this->expectDeprecation('Since symfony/property-info 5.3: The "Symfony\Component\PropertyInfo\Type::getCollectionValueType()" method is deprecated, use "getCollectionValueTypes()" instead.'); + $type = new Type('object', true, 'ArrayObject', true, new Type('int'), new Type('string')); $this->assertEquals(Type::BUILTIN_TYPE_OBJECT, $type->getBuiltinType()); @@ -37,6 +46,26 @@ public function testConstruct() $this->assertEquals(Type::BUILTIN_TYPE_STRING, $collectionValueType->getBuiltinType()); } + public function testConstruct() + { + $type = new Type('object', true, 'ArrayObject', true, new Type('int'), new Type('string')); + + $this->assertEquals(Type::BUILTIN_TYPE_OBJECT, $type->getBuiltinType()); + $this->assertTrue($type->isNullable()); + $this->assertEquals('ArrayObject', $type->getClassName()); + $this->assertTrue($type->isCollection()); + + $collectionKeyTypes = $type->getCollectionKeyTypes(); + $this->assertIsArray($collectionKeyTypes); + $this->assertContainsOnlyInstancesOf('Symfony\Component\PropertyInfo\Type', $collectionKeyTypes); + $this->assertEquals(Type::BUILTIN_TYPE_INT, $collectionKeyTypes[0]->getBuiltinType()); + + $collectionValueTypes = $type->getCollectionValueTypes(); + $this->assertIsArray($collectionValueTypes); + $this->assertContainsOnlyInstancesOf('Symfony\Component\PropertyInfo\Type', $collectionValueTypes); + $this->assertEquals(Type::BUILTIN_TYPE_STRING, $collectionValueTypes[0]->getBuiltinType()); + } + public function testIterable() { $type = new Type('iterable'); @@ -49,4 +78,46 @@ public function testInvalidType() $this->expectExceptionMessage('"foo" is not a valid PHP type.'); new Type('foo'); } + + public function testArrayCollection() + { + $type = new Type('array', false, null, true, [new Type('int'), new Type('string')], [new Type('object', false, \ArrayObject::class, true), new Type('array', false, null, true)]); + + $this->assertEquals(Type::BUILTIN_TYPE_ARRAY, $type->getBuiltinType()); + $this->assertFalse($type->isNullable()); + $this->assertTrue($type->isCollection()); + + [$firstKeyType, $secondKeyType] = $type->getCollectionKeyTypes(); + $this->assertEquals(Type::BUILTIN_TYPE_INT, $firstKeyType->getBuiltinType()); + $this->assertFalse($firstKeyType->isNullable()); + $this->assertFalse($firstKeyType->isCollection()); + $this->assertEquals(Type::BUILTIN_TYPE_STRING, $secondKeyType->getBuiltinType()); + $this->assertFalse($secondKeyType->isNullable()); + $this->assertFalse($secondKeyType->isCollection()); + + [$firstValueType, $secondValueType] = $type->getCollectionValueTypes(); + $this->assertEquals(Type::BUILTIN_TYPE_OBJECT, $firstValueType->getBuiltinType()); + $this->assertEquals(\ArrayObject::class, $firstValueType->getClassName()); + $this->assertFalse($firstValueType->isNullable()); + $this->assertTrue($firstValueType->isCollection()); + $this->assertEquals(Type::BUILTIN_TYPE_ARRAY, $secondValueType->getBuiltinType()); + $this->assertFalse($secondValueType->isNullable()); + $this->assertTrue($firstValueType->isCollection()); + } + + public function testInvalidCollectionArgument() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('"Symfony\Component\PropertyInfo\Type::validateCollectionArgument()": Argument #5 ($collectionKeyType) must be of type "Symfony\Component\PropertyInfo\Type[]", "Symfony\Component\PropertyInfo\Type" or "null", "stdClass" given.'); + + new Type('array', false, null, true, new \stdClass(), [new Type('string')]); + } + + public function testInvalidCollectionValueArgument() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('"Symfony\Component\PropertyInfo\Type::validateCollectionArgument()": Argument #5 ($collectionKeyType) must be of type "Symfony\Component\PropertyInfo\Type[]", "Symfony\Component\PropertyInfo\Type" or "null", array value "array" given.'); + + new Type('array', false, null, true, [new \stdClass()], [new Type('string')]); + } } diff --git a/src/Symfony/Component/PropertyInfo/Type.php b/src/Symfony/Component/PropertyInfo/Type.php index 582b98d6411f5..93255b2a56712 100644 --- a/src/Symfony/Component/PropertyInfo/Type.php +++ b/src/Symfony/Component/PropertyInfo/Type.php @@ -57,9 +57,12 @@ class Type private $collectionValueType; /** + * @param Type[]|Type|null $collectionKeyType + * @param Type[]|Type|null $collectionValueType + * * @throws \InvalidArgumentException */ - public function __construct(string $builtinType, bool $nullable = false, string $class = null, bool $collection = false, self $collectionKeyType = null, self $collectionValueType = null) + public function __construct(string $builtinType, bool $nullable = false, string $class = null, bool $collection = false, $collectionKeyType = null, $collectionValueType = null) { if (!\in_array($builtinType, self::$builtinTypes)) { throw new \InvalidArgumentException(sprintf('"%s" is not a valid PHP type.', $builtinType)); @@ -69,8 +72,31 @@ public function __construct(string $builtinType, bool $nullable = false, string $this->nullable = $nullable; $this->class = $class; $this->collection = $collection; - $this->collectionKeyType = $collectionKeyType; - $this->collectionValueType = $collectionValueType; + $this->collectionKeyType = $this->validateCollectionArgument($collectionKeyType, 5, '$collectionKeyType') ?? []; + $this->collectionValueType = $this->validateCollectionArgument($collectionValueType, 6, '$collectionValueType') ?? []; + } + + private function validateCollectionArgument($collectionArgument, int $argumentIndex, string $argumentName): ?array + { + if (null === $collectionArgument) { + return null; + } + + if (!\is_array($collectionArgument) && !$collectionArgument instanceof self) { + throw new \TypeError(sprintf('"%s()": Argument #%d (%s) must be of type "%s[]", "%s" or "null", "%s" given.', __METHOD__, $argumentIndex, $argumentName, self::class, self::class, get_debug_type($collectionArgument))); + } + + if (\is_array($collectionArgument)) { + foreach ($collectionArgument as $type) { + if (!$type instanceof self) { + throw new \TypeError(sprintf('"%s()": Argument #%d (%s) must be of type "%s[]", "%s" or "null", array value "%s" given.', __METHOD__, $argumentIndex, $argumentName, self::class, self::class, get_debug_type($collectionArgument))); + } + } + + return $collectionArgument; + } + + return [$collectionArgument]; } /** @@ -107,8 +133,33 @@ public function isCollection(): bool * Gets collection key type. * * Only applicable for a collection type. + * + * @deprecated since Symfony 5.3, use "getCollectionKeyTypes()" instead */ public function getCollectionKeyType(): ?self + { + trigger_deprecation('symfony/property-info', '5.3', 'The "%s()" method is deprecated, use "getCollectionKeyTypes()" instead.', __METHOD__); + + $type = $this->getCollectionKeyTypes(); + if (0 === \count($type)) { + return null; + } + + if (\is_array($type)) { + [$type] = $type; + } + + return $type; + } + + /** + * Gets collection key types. + * + * Only applicable for a collection type. + * + * @return Type[] + */ + public function getCollectionKeyTypes(): array { return $this->collectionKeyType; } @@ -117,8 +168,33 @@ public function getCollectionKeyType(): ?self * Gets collection value type. * * Only applicable for a collection type. + * + * @deprecated since Symfony 5.3, use "getCollectionValueTypes()" instead */ public function getCollectionValueType(): ?self + { + trigger_deprecation('symfony/property-info', '5.3', 'The "%s()" method is deprecated, use "getCollectionValueTypes()" instead.', __METHOD__); + + $type = $this->getCollectionValueTypes(); + if (0 === \count($type)) { + return null; + } + + if (\is_array($type)) { + [$type] = $type; + } + + return $type; + } + + /** + * Gets collection value types. + * + * Only applicable for a collection type. + * + * @return Type[] + */ + public function getCollectionValueTypes(): array { return $this->collectionValueType; } diff --git a/src/Symfony/Component/RateLimiter/CompoundLimiter.php b/src/Symfony/Component/RateLimiter/CompoundLimiter.php index 286940930326e..79a2e2210ae72 100644 --- a/src/Symfony/Component/RateLimiter/CompoundLimiter.php +++ b/src/Symfony/Component/RateLimiter/CompoundLimiter.php @@ -16,7 +16,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class CompoundLimiter implements LimiterInterface { diff --git a/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php b/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php index 02b5c810e8278..0f29ac95ff894 100644 --- a/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php +++ b/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php @@ -14,7 +14,7 @@ /** * @author Tobias Nyholm * - * @experimental in 5.2 + * @experimental in 5.3 */ class InvalidIntervalException extends \LogicException { diff --git a/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php b/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php index 1eeec9de64193..5ff82b5dc953a 100644 --- a/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php +++ b/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php @@ -16,7 +16,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class MaxWaitDurationExceededException extends \RuntimeException { diff --git a/src/Symfony/Component/RateLimiter/Exception/RateLimitExceededException.php b/src/Symfony/Component/RateLimiter/Exception/RateLimitExceededException.php index 0cb10c750befe..2597a6a6fe967 100644 --- a/src/Symfony/Component/RateLimiter/Exception/RateLimitExceededException.php +++ b/src/Symfony/Component/RateLimiter/Exception/RateLimitExceededException.php @@ -16,7 +16,7 @@ /** * @author Kevin Bond * - * @experimental in 5.2 + * @experimental in 5.3 */ class RateLimitExceededException extends \RuntimeException { diff --git a/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php b/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php index 852c99ae1d698..791c19dd77978 100644 --- a/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php +++ b/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php @@ -14,7 +14,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class ReserveNotSupportedException extends \BadMethodCallException { diff --git a/src/Symfony/Component/RateLimiter/LimiterInterface.php b/src/Symfony/Component/RateLimiter/LimiterInterface.php index f190f56354e64..1884ae602342d 100644 --- a/src/Symfony/Component/RateLimiter/LimiterInterface.php +++ b/src/Symfony/Component/RateLimiter/LimiterInterface.php @@ -17,7 +17,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface LimiterInterface { diff --git a/src/Symfony/Component/RateLimiter/LimiterStateInterface.php b/src/Symfony/Component/RateLimiter/LimiterStateInterface.php index 9f70bdc1ad4bb..ad5aff0f236c4 100644 --- a/src/Symfony/Component/RateLimiter/LimiterStateInterface.php +++ b/src/Symfony/Component/RateLimiter/LimiterStateInterface.php @@ -20,7 +20,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface LimiterStateInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php b/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php index b5dfec19311a8..d1d8863e2c6aa 100644 --- a/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php @@ -23,7 +23,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class FixedWindowLimiter implements LimiterInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php b/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php index 88efa5f5e0949..3d545ffa6c71d 100644 --- a/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php @@ -23,7 +23,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class NoLimiter implements LimiterInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/Rate.php b/src/Symfony/Component/RateLimiter/Policy/Rate.php index a0e89fb5c2deb..0c91ef78e76c2 100644 --- a/src/Symfony/Component/RateLimiter/Policy/Rate.php +++ b/src/Symfony/Component/RateLimiter/Policy/Rate.php @@ -18,7 +18,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class Rate { diff --git a/src/Symfony/Component/RateLimiter/Policy/ResetLimiterTrait.php b/src/Symfony/Component/RateLimiter/Policy/ResetLimiterTrait.php index a356490488669..fe7fc10bed216 100644 --- a/src/Symfony/Component/RateLimiter/Policy/ResetLimiterTrait.php +++ b/src/Symfony/Component/RateLimiter/Policy/ResetLimiterTrait.php @@ -15,7 +15,7 @@ use Symfony\Component\RateLimiter\Storage\StorageInterface; /** - * @experimental in 5.2 + * @experimental in 5.3 */ trait ResetLimiterTrait { diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php index e306c5d3f8b8a..6b72d46a4c0b2 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php @@ -18,7 +18,7 @@ * @author Tobias Nyholm * * @internal - * @experimental in 5.2 + * @experimental in 5.3 */ final class SlidingWindow implements LimiterStateInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php index fe6f1da8c31f3..47652256af818 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php @@ -31,7 +31,7 @@ * * @author Tobias Nyholm * - * @experimental in 5.2 + * @experimental in 5.3 */ final class SlidingWindowLimiter implements LimiterInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php b/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php index e6dd30a52fdd6..ec61b828732d2 100644 --- a/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php +++ b/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php @@ -17,7 +17,7 @@ * @author Wouter de Jong * * @internal - * @experimental in 5.2 + * @experimental in 5.3 */ final class TokenBucket implements LimiterStateInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php b/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php index 24f82d21ec720..5a02068f83485 100644 --- a/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php @@ -22,7 +22,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class TokenBucketLimiter implements LimiterInterface { diff --git a/src/Symfony/Component/RateLimiter/Policy/Window.php b/src/Symfony/Component/RateLimiter/Policy/Window.php index 1cfd49eb82ffa..e0970cba6d51c 100644 --- a/src/Symfony/Component/RateLimiter/Policy/Window.php +++ b/src/Symfony/Component/RateLimiter/Policy/Window.php @@ -17,7 +17,7 @@ * @author Wouter de Jong * * @internal - * @experimental in 5.2 + * @experimental in 5.3 */ final class Window implements LimiterStateInterface { diff --git a/src/Symfony/Component/RateLimiter/RateLimit.php b/src/Symfony/Component/RateLimiter/RateLimit.php index 64c706b6e6562..ac6ea882b7bc3 100644 --- a/src/Symfony/Component/RateLimiter/RateLimit.php +++ b/src/Symfony/Component/RateLimiter/RateLimit.php @@ -16,7 +16,7 @@ /** * @author Valentin Silvestre * - * @experimental in 5.2 + * @experimental in 5.3 */ class RateLimit { diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php index 9fdbe474bf3ef..b27afd9789ed9 100644 --- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php +++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php @@ -25,7 +25,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class RateLimiterFactory { diff --git a/src/Symfony/Component/RateLimiter/Reservation.php b/src/Symfony/Component/RateLimiter/Reservation.php index 4ea54ce5a8816..1921c1af83e20 100644 --- a/src/Symfony/Component/RateLimiter/Reservation.php +++ b/src/Symfony/Component/RateLimiter/Reservation.php @@ -14,7 +14,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class Reservation { diff --git a/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php b/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php index b61539d659243..ada3417b20c0a 100644 --- a/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php +++ b/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php @@ -17,7 +17,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class CacheStorage implements StorageInterface { diff --git a/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php b/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php index 9f17392b2d2ae..a39a5d42e11f1 100644 --- a/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php +++ b/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php @@ -16,7 +16,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class InMemoryStorage implements StorageInterface { diff --git a/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php b/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php index 3c5ec6b8a07eb..8191b9e7a005b 100644 --- a/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php +++ b/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php @@ -16,7 +16,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface StorageInterface { diff --git a/src/Symfony/Component/Routing/Annotation/Route.php b/src/Symfony/Component/Routing/Annotation/Route.php index f51b74c38727c..4751f14b2d84d 100644 --- a/src/Symfony/Component/Routing/Annotation/Route.php +++ b/src/Symfony/Component/Routing/Annotation/Route.php @@ -15,6 +15,7 @@ * Annotation class for @Route(). * * @Annotation + * @NamedArgumentConstructor * @Target({"CLASS", "METHOD"}) * * @author Fabien Potencier @@ -34,6 +35,7 @@ class Route private $schemes = []; private $condition; private $priority; + private $env; /** * @param array|string $data data array managed by the Doctrine Annotations library or the path @@ -59,12 +61,15 @@ public function __construct( string $locale = null, string $format = null, bool $utf8 = null, - bool $stateless = null + bool $stateless = null, + string $env = null ) { if (\is_string($data)) { $data = ['path' => $data]; } elseif (!\is_array($data)) { throw new \TypeError(sprintf('"%s": Argument $data is expected to be a string or array, got "%s".', __METHOD__, get_debug_type($data))); + } elseif ([] !== $data) { + trigger_deprecation('symfony/routing', '5.3', 'Passing an array as first argument to "%s" is deprecated. Use named arguments instead.', __METHOD__); } if (null !== $path && !\is_string($path) && !\is_array($path)) { throw new \TypeError(sprintf('"%s": Argument $path is expected to be a string, array or null, got "%s".', __METHOD__, get_debug_type($path))); @@ -84,6 +89,7 @@ public function __construct( $data['format'] = $data['format'] ?? $format; $data['utf8'] = $data['utf8'] ?? $utf8; $data['stateless'] = $data['stateless'] ?? $stateless; + $data['env'] = $data['env'] ?? $env; $data = array_filter($data, static function ($value): bool { return null !== $value; @@ -241,4 +247,14 @@ public function getPriority(): ?int { return $this->priority; } + + public function setEnv(?string $env): void + { + $this->env = $env; + } + + public function getEnv(): ?string + { + return $this->env; + } } diff --git a/src/Symfony/Component/Routing/CHANGELOG.md b/src/Symfony/Component/Routing/CHANGELOG.md index 1d6f133ac1e0d..5f80dde1a9455 100644 --- a/src/Symfony/Component/Routing/CHANGELOG.md +++ b/src/Symfony/Component/Routing/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +5.3 +--- + + * Already encoded slashes are not decoded nor double-encoded anymore when generating URLs + * Add support for per-env configuration in loaders + * Deprecate creating instances of the `Route` annotation class by passing an array of parameters + 5.2.0 ----- diff --git a/src/Symfony/Component/Routing/Exception/MethodNotAllowedException.php b/src/Symfony/Component/Routing/Exception/MethodNotAllowedException.php index 8a437140352cf..27cf2125e2b8e 100644 --- a/src/Symfony/Component/Routing/Exception/MethodNotAllowedException.php +++ b/src/Symfony/Component/Routing/Exception/MethodNotAllowedException.php @@ -27,6 +27,12 @@ class MethodNotAllowedException extends \RuntimeException implements ExceptionIn */ public function __construct(array $allowedMethods, ?string $message = '', int $code = 0, \Throwable $previous = null) { + if (null === $message) { + trigger_deprecation('symfony/routing', '5.3', 'Passing null as $message to "%s()" is deprecated, pass an empty string instead.', __METHOD__); + + $message = ''; + } + $this->allowedMethods = array_map('strtoupper', $allowedMethods); parent::__construct($message, $code, $previous); diff --git a/src/Symfony/Component/Routing/Generator/UrlGenerator.php b/src/Symfony/Component/Routing/Generator/UrlGenerator.php index b2768f7f533ab..8d26b26bd1e42 100644 --- a/src/Symfony/Component/Routing/Generator/UrlGenerator.php +++ b/src/Symfony/Component/Routing/Generator/UrlGenerator.php @@ -66,6 +66,7 @@ class UrlGenerator implements UrlGeneratorInterface, ConfigurableRequirementsInt // some webservers don't allow the slash in encoded form in the path for security reasons anyway // see http://stackoverflow.com/questions/4069002/http-400-if-2f-part-of-get-url-in-jboss '%2F' => '/', + '%252F' => '%2F', // the following chars are general delimiters in the URI specification but have only special meaning in the authority component // so they can safely be used in the path in unencoded form '%40' => '@', diff --git a/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php b/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php index bf8fe2af368b1..0b362ad1e0e04 100644 --- a/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php +++ b/src/Symfony/Component/Routing/Loader/AnnotationClassLoader.php @@ -73,6 +73,7 @@ abstract class AnnotationClassLoader implements LoaderInterface { protected $reader; + protected $env; /** * @var string @@ -84,9 +85,10 @@ abstract class AnnotationClassLoader implements LoaderInterface */ protected $defaultRouteIndex = 0; - public function __construct(Reader $reader = null) + public function __construct(Reader $reader = null, string $env = null) { $this->reader = $reader; + $this->env = $env; } /** @@ -122,6 +124,10 @@ public function load($class, string $type = null) $collection = new RouteCollection(); $collection->addResource(new FileResource($class->getFileName())); + if ($globals['env'] && $this->env !== $globals['env']) { + return $collection; + } + foreach ($class->getMethods() as $method) { $this->defaultRouteIndex = 0; foreach ($this->getAnnotations($method) as $annot) { @@ -144,6 +150,10 @@ public function load($class, string $type = null) */ protected function addRoute(RouteCollection $collection, object $annot, array $globals, \ReflectionClass $class, \ReflectionMethod $method) { + if ($annot->getEnv() && $annot->getEnv() !== $this->env) { + return; + } + $name = $annot->getName(); if (null === $name) { $name = $this->getDefaultRouteName($class, $method); @@ -317,6 +327,7 @@ protected function getGlobals(\ReflectionClass $class) } $globals['priority'] = $annot->getPriority() ?? 0; + $globals['env'] = $annot->getEnv(); foreach ($globals['requirements'] as $placeholder => $requirement) { if (\is_int($placeholder)) { @@ -342,6 +353,7 @@ private function resetGlobals(): array 'condition' => '', 'name' => '', 'priority' => 0, + 'env' => null, ]; } diff --git a/src/Symfony/Component/Routing/Loader/ClosureLoader.php b/src/Symfony/Component/Routing/Loader/ClosureLoader.php index cea5f9c1947d8..2407307482ea0 100644 --- a/src/Symfony/Component/Routing/Loader/ClosureLoader.php +++ b/src/Symfony/Component/Routing/Loader/ClosureLoader.php @@ -33,7 +33,7 @@ class ClosureLoader extends Loader */ public function load($closure, string $type = null) { - return $closure(); + return $closure($this->env); } /** diff --git a/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php b/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php index e5086e2441b85..70d68dfc3c737 100644 --- a/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php +++ b/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php @@ -24,13 +24,15 @@ class RoutingConfigurator private $loader; private $path; private $file; + private $env; - public function __construct(RouteCollection $collection, PhpFileLoader $loader, string $path, string $file) + public function __construct(RouteCollection $collection, PhpFileLoader $loader, string $path, string $file, string $env = null) { $this->collection = $collection; $this->loader = $loader; $this->path = $path; $this->file = $file; + $this->env = $env; } /** @@ -58,6 +60,21 @@ final public function collection(string $name = ''): CollectionConfigurator return new CollectionConfigurator($this->collection, $name); } + /** + * @return static + */ + final public function when(string $env): self + { + if ($env === $this->env) { + return clone $this; + } + + $clone = clone $this; + $clone->collection = new RouteCollection(); + + return $clone; + } + /** * @return static */ diff --git a/src/Symfony/Component/Routing/Loader/ContainerLoader.php b/src/Symfony/Component/Routing/Loader/ContainerLoader.php index 92bf2a096bf48..8128b742135b3 100644 --- a/src/Symfony/Component/Routing/Loader/ContainerLoader.php +++ b/src/Symfony/Component/Routing/Loader/ContainerLoader.php @@ -22,9 +22,10 @@ class ContainerLoader extends ObjectLoader { private $container; - public function __construct(ContainerInterface $container) + public function __construct(ContainerInterface $container, string $env = null) { $this->container = $container; + parent::__construct($env); } /** diff --git a/src/Symfony/Component/Routing/Loader/ObjectLoader.php b/src/Symfony/Component/Routing/Loader/ObjectLoader.php index d6ec1a727765e..062453908c948 100644 --- a/src/Symfony/Component/Routing/Loader/ObjectLoader.php +++ b/src/Symfony/Component/Routing/Loader/ObjectLoader.php @@ -59,7 +59,7 @@ public function load($resource, string $type = null) throw new \BadMethodCallException(sprintf('Method "%s" not found on "%s" when importing routing resource "%s".', $method, get_debug_type($loaderObject), $resource)); } - $routeCollection = $loaderObject->$method($this); + $routeCollection = $loaderObject->$method($this, $this->env); if (!$routeCollection instanceof RouteCollection) { $type = get_debug_type($routeCollection); diff --git a/src/Symfony/Component/Routing/Loader/PhpFileLoader.php b/src/Symfony/Component/Routing/Loader/PhpFileLoader.php index e000c5a0ebaed..2418b0d322abe 100644 --- a/src/Symfony/Component/Routing/Loader/PhpFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/PhpFileLoader.php @@ -71,7 +71,7 @@ protected function callConfigurator(callable $result, string $path, string $file { $collection = new RouteCollection(); - $result(new RoutingConfigurator($collection, $this, $path, $file)); + $result(new RoutingConfigurator($collection, $this, $path, $file, $this->env)); return $collection; } diff --git a/src/Symfony/Component/Routing/Loader/XmlFileLoader.php b/src/Symfony/Component/Routing/Loader/XmlFileLoader.php index 9951625be93df..4f38ce95ccadc 100644 --- a/src/Symfony/Component/Routing/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/XmlFileLoader.php @@ -88,6 +88,16 @@ protected function parseNode(RouteCollection $collection, \DOMElement $node, str case 'import': $this->parseImport($collection, $node, $path, $file); break; + case 'when': + if (!$this->env || $node->getAttribute('env') !== $this->env) { + break; + } + foreach ($node->childNodes as $node) { + if ($node instanceof \DOMElement) { + $this->parseNode($collection, $node, $path, $file); + } + } + break; default: throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "route" or "import".', $node->localName, $path)); } diff --git a/src/Symfony/Component/Routing/Loader/YamlFileLoader.php b/src/Symfony/Component/Routing/Loader/YamlFileLoader.php index b236e4b54e8f6..1ec8a810a2b31 100644 --- a/src/Symfony/Component/Routing/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/YamlFileLoader.php @@ -84,6 +84,24 @@ public function load($file, string $type = null) } foreach ($parsedConfig as $name => $config) { + if (0 === strpos($name, 'when@')) { + if (!$this->env || 'when@'.$this->env !== $name) { + continue; + } + + foreach ($config as $name => $config) { + $this->validate($config, $name.'" when "@'.$this->env, $path); + + if (isset($config['resource'])) { + $this->parseImport($collection, $config, $path, $file); + } else { + $this->parseRoute($collection, $name, $config, $path); + } + } + + continue; + } + $this->validate($config, $name, $path); if (isset($config['resource'])) { diff --git a/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd b/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd index 846d126724d5e..e03114791ec8b 100644 --- a/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd +++ b/src/Symfony/Component/Routing/Loader/schema/routing/routing-1.0.xsd @@ -21,9 +21,18 @@ + + + + + + + + + diff --git a/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php b/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php index a482378bdec79..d0c57113adab8 100644 --- a/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php +++ b/src/Symfony/Component/Routing/Tests/Annotation/RouteTest.php @@ -12,16 +12,25 @@ namespace Symfony\Component\Routing\Tests\Annotation; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Routing\Annotation\Route; class RouteTest extends TestCase { + use ExpectDeprecationTrait; + + /** + * @group legacy + */ public function testInvalidRouteParameter() { $this->expectException(\BadMethodCallException::class); new Route(['foo' => 'bar']); } + /** + * @group legacy + */ public function testTryingToSetLocalesDirectly() { $this->expectException(\BadMethodCallException::class); @@ -29,18 +38,30 @@ public function testTryingToSetLocalesDirectly() } /** + * @requires PHP 8 * @dataProvider getValidParameters */ - public function testRouteParameters($parameter, $value, $getter) + public function testRouteParameters(string $parameter, $value, string $getter) + { + $route = new Route(...[$parameter => $value]); + $this->assertEquals($route->$getter(), $value); + } + + /** + * @group legacy + * @dataProvider getLegacyValidParameters + */ + public function testLegacyRouteParameters(string $parameter, $value, string $getter) { + $this->expectDeprecation('Since symfony/routing 5.3: Passing an array as first argument to "Symfony\Component\Routing\Annotation\Route::__construct" is deprecated. Use named arguments instead.'); + $route = new Route([$parameter => $value]); $this->assertEquals($route->$getter(), $value); } - public function getValidParameters() + public function getValidParameters(): iterable { return [ - ['value', '/Blog', 'getPath'], ['requirements', ['locale' => 'en'], 'getRequirements'], ['options', ['compiler_class' => 'RouteCompiler'], 'getOptions'], ['name', 'blog_index', 'getName'], @@ -49,7 +70,14 @@ public function getValidParameters() ['methods', ['GET', 'POST'], 'getMethods'], ['host', '{locale}.example.com', 'getHost'], ['condition', 'context.getMethod() == "GET"', 'getCondition'], - ['value', ['nl' => '/hier', 'en' => '/here'], 'getLocalizedPaths'], ]; } + + public function getLegacyValidParameters(): iterable + { + yield from $this->getValidParameters(); + + yield ['value', '/Blog', 'getPath']; + yield ['value', ['nl' => '/hier', 'en' => '/here'], 'getLocalizedPaths']; + } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AnnotationFixtures/RouteWithEnv.php b/src/Symfony/Component/Routing/Tests/Fixtures/AnnotationFixtures/RouteWithEnv.php new file mode 100644 index 0000000000000..dcc94e7a174e3 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AnnotationFixtures/RouteWithEnv.php @@ -0,0 +1,25 @@ +when('some-env') + ->add('a', '/a2') + ->add('b', '/b'); + + $routes + ->when('some-other-env') + ->add('a', '/a3') + ->add('c', '/c'); + + $routes + ->add('a', '/a1'); +}; diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/when-env.xml b/src/Symfony/Component/Routing/Tests/Fixtures/when-env.xml new file mode 100644 index 0000000000000..50d1fd8b397fe --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/when-env.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/when-env.yml b/src/Symfony/Component/Routing/Tests/Fixtures/when-env.yml new file mode 100644 index 0000000000000..0f97ab22f2261 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/when-env.yml @@ -0,0 +1,9 @@ +when@some-env: + a: {path: /a2} + b: {path: /b} + +when@some-other-env: + a: {path: /a3} + c: {path: /c} + +a: {path: /a1} diff --git a/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php b/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php index f006f4ce9d587..29743450d719b 100644 --- a/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php +++ b/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php @@ -471,6 +471,12 @@ public function testEncodingOfRelativePathSegments() $this->assertSame('/app.php/a./.a/a../..a/...', $this->getGenerator($routes)->generate('test')); } + public function testEncodingOfSlashInPath() + { + $routes = $this->getRoutes('test', new Route('/dir/{path}/dir2', [], ['path' => '.+'])); + $this->assertSame('/app.php/dir/foo/bar%2Fbaz/dir2', $this->getGenerator($routes)->generate('test', ['path' => 'foo/bar%2Fbaz'])); + } + public function testAdjacentVariables() { $routes = $this->getRoutes('test', new Route('/{x}{y}{z}.{_format}', ['z' => 'default-z', '_format' => 'html'], ['y' => '\d+'])); diff --git a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php index 3b82499c4f550..a4f6e3e3a6d13 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderTest.php @@ -247,5 +247,16 @@ public function testLoadingRouteWithPrefix() $this->assertEquals('/prefix/path', $routes->get('action')->getPath()); } + public function testWhenEnv() + { + $routes = $this->loader->load($this->getNamespace().'\RouteWithEnv'); + $this->assertCount(0, $routes); + + $this->setUp('some-env'); + $routes = $this->loader->load($this->getNamespace().'\RouteWithEnv'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $routes->get('action')->getPath()); + } + abstract protected function getNamespace(): string; } diff --git a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAnnotationsTest.php b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAnnotationsTest.php index ef9ca39e827fe..d2fe627ef99ea 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAnnotationsTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAnnotationsTest.php @@ -9,10 +9,10 @@ class AnnotationClassLoaderWithAnnotationsTest extends AnnotationClassLoaderTest { - protected function setUp(): void + protected function setUp(string $env = null): void { $reader = new AnnotationReader(); - $this->loader = new class($reader) extends AnnotationClassLoader { + $this->loader = new class($reader, $env) extends AnnotationClassLoader { protected function configureRoute(Route $route, \ReflectionClass $class, \ReflectionMethod $method, object $annot): void { } diff --git a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAttributesTest.php b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAttributesTest.php index 1545253e56d96..ea2a5c573b49a 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAttributesTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/AnnotationClassLoaderWithAttributesTest.php @@ -10,9 +10,9 @@ */ class AnnotationClassLoaderWithAttributesTest extends AnnotationClassLoaderTest { - protected function setUp(): void + protected function setUp(string $env = null): void { - $this->loader = new class() extends AnnotationClassLoader { + $this->loader = new class(null, $env) extends AnnotationClassLoader { protected function configureRoute(Route $route, \ReflectionClass $class, \ReflectionMethod $method, object $annot): void { } diff --git a/src/Symfony/Component/Routing/Tests/Loader/AnnotationFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/AnnotationFileLoaderTest.php index ee66ef1804ed3..0e1331bf8147a 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/AnnotationFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/AnnotationFileLoaderTest.php @@ -51,7 +51,7 @@ public function testLoadFileWithoutStartTag() public function testLoadVariadic() { - $route = new Route(['path' => '/path/to/{id}']); + $route = new Route('/path/to/{id}'); $this->reader->expects($this->once())->method('getClassAnnotation'); $this->reader->expects($this->once())->method('getMethodAnnotations') ->willReturn([$route]); diff --git a/src/Symfony/Component/Routing/Tests/Loader/ClosureLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/ClosureLoaderTest.php index 5d963f86fb01b..da8ad090dd4d8 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/ClosureLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/ClosureLoaderTest.php @@ -33,10 +33,12 @@ public function testSupports() public function testLoad() { - $loader = new ClosureLoader(); + $loader = new ClosureLoader('some-env'); $route = new Route('/'); - $routes = $loader->load(function () use ($route) { + $routes = $loader->load(function (string $env = null) use ($route) { + $this->assertSame('some-env', $env); + $routes = new RouteCollection(); $routes->add('foo', $route); diff --git a/src/Symfony/Component/Routing/Tests/Loader/ObjectLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/ObjectLoaderTest.php index 50e75b5fd4cc2..54d3643b1f584 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/ObjectLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/ObjectLoaderTest.php @@ -20,14 +20,14 @@ class ObjectLoaderTest extends TestCase { public function testLoadCallsServiceAndReturnsCollection() { - $loader = new TestObjectLoader(); + $loader = new TestObjectLoader('some-env'); // create a basic collection that will be returned $collection = new RouteCollection(); $collection->add('foo', new Route('/foo')); $loader->loaderMap = [ - 'my_route_provider_service' => new TestObjectLoaderRouteService($collection), + 'my_route_provider_service' => new TestObjectLoaderRouteService($collection, 'some-env'), ]; $actualRoutes = $loader->load( @@ -112,14 +112,20 @@ protected function getObject(string $id) class TestObjectLoaderRouteService { private $collection; + private $env; - public function __construct($collection) + public function __construct($collection, string $env = null) { $this->collection = $collection; + $this->env = $env; } - public function loadRoutes() + public function loadRoutes(TestObjectLoader $loader, string $env = null) { + if ($this->env !== $env) { + throw new \InvalidArgumentException(sprintf('Expected env "%s", "%s" given.', $this->env, $env)); + } + return $this->collection; } } diff --git a/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php index 7ed941c711e3b..fd3fca4154a1c 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php @@ -284,4 +284,14 @@ public function testImportingRoutesWithSingleHostInImporter() $this->assertEquals($expectedRoutes('php'), $routes); } + + public function testWhenEnv() + { + $loader = new PhpFileLoader(new FileLocator([__DIR__.'/../Fixtures']), 'some-env'); + $routes = $loader->load('when-env.php'); + + $this->assertSame(['b', 'a'], array_keys($routes->all())); + $this->assertSame('/b', $routes->get('b')->getPath()); + $this->assertSame('/a1', $routes->get('a')->getPath()); + } } diff --git a/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php index 9ab49fc1311aa..8518129a5b495 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/XmlFileLoaderTest.php @@ -563,4 +563,14 @@ public function testImportingRoutesWithSingleHostsInImporter() $this->assertEquals($expectedRoutes('xml'), $routes); } + + public function testWhenEnv() + { + $loader = new XmlFileLoader(new FileLocator([__DIR__.'/../Fixtures']), 'some-env'); + $routes = $loader->load('when-env.xml'); + + $this->assertSame(['b', 'a'], array_keys($routes->all())); + $this->assertSame('/b', $routes->get('b')->getPath()); + $this->assertSame('/a1', $routes->get('a')->getPath()); + } } diff --git a/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php index 56915c5ce5e6c..09e3f07951024 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/YamlFileLoaderTest.php @@ -435,4 +435,14 @@ public function testImportingRoutesWithSingleHostInImporter() $this->assertEquals($expectedRoutes('yml'), $routes); } + + public function testWhenEnv() + { + $loader = new YamlFileLoader(new FileLocator([__DIR__.'/../Fixtures']), 'some-env'); + $routes = $loader->load('when-env.yml'); + + $this->assertSame(['b', 'a'], array_keys($routes->all())); + $this->assertSame('/b', $routes->get('b')->getPath()); + $this->assertSame('/a1', $routes->get('a')->getPath()); + } } diff --git a/src/Symfony/Component/Routing/composer.json b/src/Symfony/Component/Routing/composer.json index 164dbb2bfdc99..aeccf0681f3ee 100644 --- a/src/Symfony/Component/Routing/composer.json +++ b/src/Symfony/Component/Routing/composer.json @@ -21,16 +21,17 @@ "symfony/polyfill-php80": "^1.15" }, "require-dev": { - "symfony/config": "^5.0", + "symfony/config": "^5.3", "symfony/http-foundation": "^4.4|^5.0", "symfony/yaml": "^4.4|^5.0", "symfony/expression-language": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", - "doctrine/annotations": "^1.10.4", + "doctrine/annotations": "^1.12", "psr/log": "~1.0" }, "conflict": { - "symfony/config": "<5.0", + "doctrine/annotations": "<1.12", + "symfony/config": "<5.3", "symfony/dependency-injection": "<4.4", "symfony/yaml": "<4.4" }, diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index 566625a7c1f7c..7e0b6b2a337eb 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +5.3 +--- + + * Deprecate all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead + * Deprecate the `SessionInterface $session` constructor argument of `SessionTokenStorage`, inject a `\Symfony\Component\HttpFoundation\RequestStack $requestStack` instead + * Deprecate the `session` service provided by the ServiceLocator injected in `UsageTrackingTokenStorage`, provide a `request_stack` service instead + * Deprecate using `SessionTokenStorage` outside a request context, it will throw a `SessionNotFoundException` in Symfony 6.0 + * Randomize CSRF tokens to harden BREACH attacks + * Deprecated voters that do not return a valid decision when calling the `vote` method. + 5.2.0 ----- diff --git a/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php b/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php index e91c5d8144f6c..c4099603ef59f 100644 --- a/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php +++ b/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Core\Authentication; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\AuthenticationEvents; @@ -18,6 +19,7 @@ use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\Exception\AccountStatusException; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\ProviderNotFoundException; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -89,6 +91,8 @@ public function authenticate(TokenInterface $token) break; } catch (AuthenticationException $e) { $lastException = $e; + } catch (InvalidPasswordException $e) { + $lastException = new BadCredentialsException('Bad credentials.', 0, $e); } } diff --git a/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php b/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php index c65a9505526f7..26beb6b945ee5 100644 --- a/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php +++ b/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php @@ -20,6 +20,7 @@ use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; /** * DaoAuthenticationProvider uses a UserProviderInterface to retrieve the user @@ -29,14 +30,21 @@ */ class DaoAuthenticationProvider extends UserAuthenticationProvider { - private $encoderFactory; + private $hasherFactory; private $userProvider; - public function __construct(UserProviderInterface $userProvider, UserCheckerInterface $userChecker, string $providerKey, EncoderFactoryInterface $encoderFactory, bool $hideUserNotFoundExceptions = true) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct(UserProviderInterface $userProvider, UserCheckerInterface $userChecker, string $providerKey, $hasherFactory, bool $hideUserNotFoundExceptions = true) { parent::__construct($userChecker, $providerKey, $hideUserNotFoundExceptions); - $this->encoderFactory = $encoderFactory; + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + + $this->hasherFactory = $hasherFactory; $this->userProvider = $userProvider; } @@ -59,14 +67,29 @@ protected function checkAuthentication(UserInterface $user, UsernamePasswordToke throw new BadCredentialsException('The presented password is invalid.'); } - $encoder = $this->encoderFactory->getEncoder($user); + // deprecated since Symfony 5.3 + if ($this->hasherFactory instanceof EncoderFactoryInterface) { + $encoder = $this->hasherFactory->getEncoder($user); + + if (!$encoder->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { + throw new BadCredentialsException('The presented password is invalid.'); + } + + if ($this->userProvider instanceof PasswordUpgraderInterface && method_exists($encoder, 'needsRehash') && $encoder->needsRehash($user->getPassword())) { + $this->userProvider->upgradePassword($user, $encoder->encodePassword($presentedPassword, $user->getSalt())); + } + + return; + } + + $hasher = $this->hasherFactory->getPasswordHasher($user); - if (!$encoder->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { + if (!$hasher->verify($user->getPassword(), $presentedPassword, $user->getSalt())) { throw new BadCredentialsException('The presented password is invalid.'); } - if ($this->userProvider instanceof PasswordUpgraderInterface && method_exists($encoder, 'needsRehash') && $encoder->needsRehash($user->getPassword())) { - $this->userProvider->upgradePassword($user, $encoder->encodePassword($presentedPassword, $user->getSalt())); + if ($this->userProvider instanceof PasswordUpgraderInterface && $hasher->needsRehash($user->getPassword())) { + $this->userProvider->upgradePassword($user, $hasher->hash($presentedPassword, $user->getSalt())); } } } diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/Storage/UsageTrackingTokenStorage.php b/src/Symfony/Component/Security/Core/Authentication/Token/Storage/UsageTrackingTokenStorage.php index b90d5ab28b635..0b8d9c320176f 100644 --- a/src/Symfony/Component/Security/Core/Authentication/Token/Storage/UsageTrackingTokenStorage.php +++ b/src/Symfony/Component/Security/Core/Authentication/Token/Storage/UsageTrackingTokenStorage.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Security\Core\Authentication\Token\Storage; use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -24,13 +25,13 @@ final class UsageTrackingTokenStorage implements TokenStorageInterface, ServiceSubscriberInterface { private $storage; - private $sessionLocator; + private $container; private $enableUsageTracking = false; - public function __construct(TokenStorageInterface $storage, ContainerInterface $sessionLocator) + public function __construct(TokenStorageInterface $storage, ContainerInterface $container) { $this->storage = $storage; - $this->sessionLocator = $sessionLocator; + $this->container = $container; } /** @@ -40,7 +41,7 @@ public function getToken(): ?TokenInterface { if ($this->enableUsageTracking) { // increments the internal session usage index - $this->sessionLocator->get('session')->getMetadataBag(); + $this->getSession()->getMetadataBag(); } return $this->storage->getToken(); @@ -55,7 +56,7 @@ public function setToken(TokenInterface $token = null): void if ($token && $this->enableUsageTracking) { // increments the internal session usage index - $this->sessionLocator->get('session')->getMetadataBag(); + $this->getSession()->getMetadataBag(); } } @@ -72,7 +73,19 @@ public function disableUsageTracking(): void public static function getSubscribedServices(): array { return [ - 'session' => SessionInterface::class, + 'request_stack' => RequestStack::class, ]; } + + private function getSession(): SessionInterface + { + // BC for symfony/security-bundle < 5.3 + if ($this->container->has('session')) { + trigger_deprecation('symfony/security-core', '5.3', 'Injecting the "session" in "%s" is deprecated, inject the "request_stack" instead.', __CLASS__); + + return $this->container->get('session'); + } + + return $this->container->get('request_stack')->getSession(); + } } diff --git a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php index 440eac75cdf0d..82f9e0ae827d3 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php +++ b/src/Symfony/Component/Security/Core/Authorization/AccessDecisionManager.php @@ -89,6 +89,8 @@ private function decideAffirmative(TokenInterface $token, array $attributes, $ob if (VoterInterface::ACCESS_DENIED === $result) { ++$deny; + } elseif (VoterInterface::ACCESS_ABSTAIN !== $result) { + trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class); } } @@ -124,6 +126,8 @@ private function decideConsensus(TokenInterface $token, array $attributes, $obje ++$grant; } elseif (VoterInterface::ACCESS_DENIED === $result) { ++$deny; + } elseif (VoterInterface::ACCESS_ABSTAIN !== $result) { + trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class); } } @@ -161,6 +165,8 @@ private function decideUnanimous(TokenInterface $token, array $attributes, $obje if (VoterInterface::ACCESS_GRANTED === $result) { ++$grant; + } elseif (VoterInterface::ACCESS_ABSTAIN !== $result) { + trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class); } } } @@ -192,6 +198,10 @@ private function decidePriority(TokenInterface $token, array $attributes, $objec if (VoterInterface::ACCESS_DENIED === $result) { return false; } + + if (VoterInterface::ACCESS_ABSTAIN !== $result) { + trigger_deprecation('symfony/security-core', '5.3', 'Returning "%s" in "%s::vote()" is deprecated, return one of "%s" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".', var_export($result, true), get_debug_type($voter), VoterInterface::class); + } } return $this->allowIfAllAbstainDecisions; diff --git a/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php index e067a48a37be3..613cddd84a06b 100644 --- a/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php @@ -11,10 +11,16 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\CheckPasswordLengthTrait; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', BasePasswordEncoder::class, CheckPasswordLengthTrait::class)); + /** * BasePasswordEncoder is the base class for all password encoders. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use CheckPasswordLengthTrait instead */ abstract class BasePasswordEncoder implements PasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php b/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php index 546f4f7337ab5..70231e2ce3de0 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php @@ -11,8 +11,12 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; + /** * @author Christophe Coevoet + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherAwareInterface} instead. */ interface EncoderAwareInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php b/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php index d07891bf77290..e2294d5b41c3f 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php @@ -11,12 +11,18 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; use Symfony\Component\Security\Core\Exception\LogicException; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', EncoderFactory::class, PasswordHasherFactory::class)); + /** * A generic encoder factory implementation. * * @author Johannes M. Schmitt + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherFactory} instead */ class EncoderFactory implements EncoderFactoryInterface { @@ -34,7 +40,7 @@ public function getEncoder($user) { $encoderKey = null; - if ($user instanceof EncoderAwareInterface && (null !== $encoderName = $user->getEncoderName())) { + if (($user instanceof PasswordHasherAwareInterface && null !== $encoderName = $user->getPasswordHasherName()) || ($user instanceof EncoderAwareInterface && null !== $encoderName = $user->getEncoderName())) { if (!\array_key_exists($encoderName, $this->encoders)) { throw new \RuntimeException(sprintf('The encoder "%s" was not configured.', $encoderName)); } diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php b/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php index 2b9834b6a041c..4c2f9fb6e4484 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\Security\Core\User\UserInterface; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', EncoderFactoryInterface::class, PasswordHasherFactoryInterface::class)); + /** * EncoderFactoryInterface to support different encoders for different accounts. * * @author Johannes M. Schmitt + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherFactoryInterface} instead */ interface EncoderFactoryInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php b/src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php new file mode 100644 index 0000000000000..d1263213fe309 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Encoder; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; + +/** + * @internal + */ +trait LegacyEncoderTrait +{ + /** + * @var PasswordHasherInterface|LegacyPasswordHasherInterface + */ + private $hasher; + + /** + * {@inheritdoc} + */ + public function encodePassword(string $raw, ?string $salt): string + { + try { + return $this->hasher->hash($raw, $salt); + } catch (InvalidPasswordException $e) { + throw new BadCredentialsException('Bad credentials.'); + } + } + + /** + * {@inheritdoc} + */ + public function isPasswordValid(string $encoded, string $raw, ?string $salt): bool + { + return $this->hasher->verify($encoded, $raw, $salt); + } + + /** + * {@inheritdoc} + */ + public function needsRehash(string $encoded): bool + { + return $this->hasher->needsRehash($encoded); + } +} diff --git a/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php index d769f2f470275..416e940dcc613 100644 --- a/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php @@ -11,19 +11,20 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', MessageDigestPasswordEncoder::class, MessageDigestPasswordHasher::class)); /** * MessageDigestPasswordEncoder uses a message digest algorithm. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link MessageDigestPasswordHasher} instead */ class MessageDigestPasswordEncoder extends BasePasswordEncoder { - private $algorithm; - private $encodeHashAsBase64; - private $iterations = 1; - private $encodedLength = -1; + use LegacyEncoderTrait; /** * @param string $algorithm The digest algorithm to use @@ -32,51 +33,6 @@ class MessageDigestPasswordEncoder extends BasePasswordEncoder */ public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 5000) { - $this->algorithm = $algorithm; - $this->encodeHashAsBase64 = $encodeHashAsBase64; - - try { - $this->encodedLength = \strlen($this->encodePassword('', 'salt')); - } catch (\LogicException $e) { - // ignore algorithm not supported - } - - $this->iterations = $iterations; - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - throw new BadCredentialsException('Invalid password.'); - } - - if (!\in_array($this->algorithm, hash_algos(), true)) { - throw new \LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); - } - - $salted = $this->mergePasswordAndSalt($raw, $salt); - $digest = hash($this->algorithm, $salted, true); - - // "stretch" hash - for ($i = 1; $i < $this->iterations; ++$i) { - $digest = hash($this->algorithm, $digest.$salted, true); - } - - return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt) - { - if (\strlen($encoded) !== $this->encodedLength || false !== strpos($encoded, '$')) { - return false; - } - - return !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt)); + $this->hasher = new MessageDigestPasswordHasher($algorithm, $encodeHashAsBase64, $iterations); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php index cd10b32bf733f..af881a96e5420 100644 --- a/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php @@ -11,6 +11,10 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\MigratingPasswordHasher; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', MigratingPasswordEncoder::class, MigratingPasswordHasher::class)); + /** * Hashes passwords using the best available encoder. * Validates them using a chain of encoders. @@ -19,6 +23,8 @@ * could be used to authenticate successfully without knowing the cleartext password. * * @author Nicolas Grekas + * + * @deprecated since Symfony 5.3, use {@link MigratingPasswordHasher} instead */ final class MigratingPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php index 83b7f3f1e89b5..f80d1957e5632 100644 --- a/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', NativePasswordEncoder::class, NativePasswordHasher::class)); /** * Hashes passwords using password_hash(). @@ -19,105 +21,18 @@ * @author Elnur Abdurrakhimov * @author Terje Bråten * @author Nicolas Grekas + * + * @deprecated since Symfony 5.3, use {@link NativePasswordHasher} instead */ final class NativePasswordEncoder implements PasswordEncoderInterface, SelfSaltingEncoderInterface { - private const MAX_PASSWORD_LENGTH = 4096; - - private $algo = \PASSWORD_BCRYPT; - private $options; + use LegacyEncoderTrait; /** * @param string|null $algo An algorithm supported by password_hash() or null to use the stronger available algorithm */ public function __construct(int $opsLimit = null, int $memLimit = null, int $cost = null, string $algo = null) { - $cost = $cost ?? 13; - $opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); - $memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); - - if (3 > $opsLimit) { - throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); - } - - if (10 * 1024 > $memLimit) { - throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); - } - - if ($cost < 4 || 31 < $cost) { - throw new \InvalidArgumentException('$cost must be in the range of 4-31.'); - } - - $algos = [1 => \PASSWORD_BCRYPT, '2y' => \PASSWORD_BCRYPT]; - - if (\defined('PASSWORD_ARGON2I')) { - $this->algo = $algos[2] = $algos['argon2i'] = (string) \PASSWORD_ARGON2I; - } - - if (\defined('PASSWORD_ARGON2ID')) { - $this->algo = $algos[3] = $algos['argon2id'] = (string) \PASSWORD_ARGON2ID; - } - - if (null !== $algo) { - $this->algo = $algos[$algo] ?? $algo; - } - - $this->options = [ - 'cost' => $cost, - 'time_cost' => $opsLimit, - 'memory_cost' => $memLimit >> 10, - 'threads' => 1, - ]; - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt): string - { - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH || ((string) \PASSWORD_BCRYPT === $this->algo && 72 < \strlen($raw))) { - throw new BadCredentialsException('Invalid password.'); - } - - // Ignore $salt, the auto-generated one is always the best - - return password_hash($raw, $this->algo, $this->options); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt): bool - { - if ('' === $raw) { - return false; - } - - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) { - return false; - } - - if (0 !== strpos($encoded, '$argon')) { - // BCrypt encodes only the first 72 chars - return (72 >= \strlen($raw) || 0 !== strpos($encoded, '$2')) && password_verify($raw, $encoded); - } - - if (\extension_loaded('sodium') && version_compare(\SODIUM_LIBRARY_VERSION, '1.0.14', '>=')) { - return sodium_crypto_pwhash_str_verify($encoded, $raw); - } - - if (\extension_loaded('libsodium') && version_compare(phpversion('libsodium'), '1.0.14', '>=')) { - return \Sodium\crypto_pwhash_str_verify($encoded, $raw); - } - - return password_verify($raw, $encoded); - } - - /** - * {@inheritdoc} - */ - public function needsRehash(string $encoded): bool - { - return password_needs_rehash($encoded, $this->algo, $this->options); + $this->hasher = new NativePasswordHasher($opsLimit, $memLimit, $cost, $algo); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php b/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php index 9d8d48f8db43a..2b55af05779f2 100644 --- a/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', PasswordEncoderInterface::class, PasswordHasherInterface::class)); + /** * PasswordEncoderInterface is the interface for all encoders. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherInterface} instead */ interface PasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php index ab5e1a5340d84..fcc286a042de7 100644 --- a/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', Pbkdf2PasswordEncoder::class, Pbkdf2PasswordHasher::class)); /** * Pbkdf2PasswordEncoder uses the PBKDF2 (Password-Based Key Derivation Function 2). @@ -25,14 +27,12 @@ * @author Sebastiaan Stok * @author Andrew Johnson * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link Pbkdf2PasswordHasher} instead */ class Pbkdf2PasswordEncoder extends BasePasswordEncoder { - private $algorithm; - private $encodeHashAsBase64; - private $iterations = 1; - private $length; - private $encodedLength = -1; + use LegacyEncoderTrait; /** * @param string $algorithm The digest algorithm to use @@ -42,48 +42,6 @@ class Pbkdf2PasswordEncoder extends BasePasswordEncoder */ public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 1000, int $length = 40) { - $this->algorithm = $algorithm; - $this->encodeHashAsBase64 = $encodeHashAsBase64; - $this->length = $length; - - try { - $this->encodedLength = \strlen($this->encodePassword('', 'salt')); - } catch (\LogicException $e) { - // ignore algorithm not supported - } - - $this->iterations = $iterations; - } - - /** - * {@inheritdoc} - * - * @throws \LogicException when the algorithm is not supported - */ - public function encodePassword(string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - throw new BadCredentialsException('Invalid password.'); - } - - if (!\in_array($this->algorithm, hash_algos(), true)) { - throw new \LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); - } - - $digest = hash_pbkdf2($this->algorithm, $raw, $salt, $this->iterations, $this->length, true); - - return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt) - { - if (\strlen($encoded) !== $this->encodedLength || false !== strpos($encoded, '$')) { - return false; - } - - return !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt)); + $this->hasher = new Pbkdf2PasswordHasher($algorithm, $encodeHashAsBase64, $iterations, $length); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php index 90e7e3d5be69e..3165855b3d9ca 100644 --- a/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', PlaintextPasswordEncoder::class, PlaintextPasswordHasher::class)); /** * PlaintextPasswordEncoder does not do any encoding but is useful in testing environments. @@ -19,46 +21,18 @@ * As this encoder is not cryptographically secure, usage of it in production environments is discouraged. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link PlaintextPasswordHasher} instead */ class PlaintextPasswordEncoder extends BasePasswordEncoder { - private $ignorePasswordCase; + use LegacyEncoderTrait; /** * @param bool $ignorePasswordCase Compare password case-insensitive */ public function __construct(bool $ignorePasswordCase = false) { - $this->ignorePasswordCase = $ignorePasswordCase; - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - throw new BadCredentialsException('Invalid password.'); - } - - return $this->mergePasswordAndSalt($raw, $salt); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - return false; - } - - $pass2 = $this->mergePasswordAndSalt($raw, $salt); - - if (!$this->ignorePasswordCase) { - return $this->comparePasswords($encoded, $pass2); - } - - return $this->comparePasswords(strtolower($encoded), strtolower($pass2)); + $this->hasher = new PlaintextPasswordHasher($ignorePasswordCase); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php b/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php index 37855b60cff83..d1e93e165d512 100644 --- a/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php @@ -11,11 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" interface is deprecated, use "%s" on hasher implementations that deal with salts instead.', SelfSaltingEncoderInterface::class, LegacyPasswordHasherInterface::class)); + /** * SelfSaltingEncoderInterface is a marker interface for encoders that do not * require a user-generated salt. * * @author Zan Baldwin + * + * @deprecated since Symfony 5.3, use {@link LegacyPasswordHasherInterface} instead */ interface SelfSaltingEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php index 53c6660014a78..95810e597a9a1 100644 --- a/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php @@ -11,8 +11,9 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Exception\LogicException; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; + +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', SodiumPasswordEncoder::class, SodiumPasswordHasher::class)); /** * Hashes passwords using libsodium. @@ -20,99 +21,20 @@ * @author Robin Chalas * @author Zan Baldwin * @author Dominik Müller + * + * @deprecated since Symfony 5.3, use {@link SodiumPasswordHasher} instead */ final class SodiumPasswordEncoder implements PasswordEncoderInterface, SelfSaltingEncoderInterface { - private const MAX_PASSWORD_LENGTH = 4096; - - private $opsLimit; - private $memLimit; + use LegacyEncoderTrait; public function __construct(int $opsLimit = null, int $memLimit = null) { - if (!self::isSupported()) { - throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.'); - } - - $this->opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); - $this->memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); - - if (3 > $this->opsLimit) { - throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); - } - - if (10 * 1024 > $this->memLimit) { - throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); - } + $this->hasher = new SodiumPasswordHasher($opsLimit, $memLimit); } public static function isSupported(): bool { - return version_compare(\extension_loaded('sodium') ? \SODIUM_LIBRARY_VERSION : phpversion('libsodium'), '1.0.14', '>='); - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt): string - { - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) { - throw new BadCredentialsException('Invalid password.'); - } - - if (\function_exists('sodium_crypto_pwhash_str')) { - return sodium_crypto_pwhash_str($raw, $this->opsLimit, $this->memLimit); - } - - if (\extension_loaded('libsodium')) { - return \Sodium\crypto_pwhash_str($raw, $this->opsLimit, $this->memLimit); - } - - throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.'); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt): bool - { - if ('' === $raw) { - return false; - } - - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) { - return false; - } - - if (0 !== strpos($encoded, '$argon')) { - // Accept validating non-argon passwords for seamless migrations - return (72 >= \strlen($raw) || 0 !== strpos($encoded, '$2')) && password_verify($raw, $encoded); - } - - if (\function_exists('sodium_crypto_pwhash_str_verify')) { - return sodium_crypto_pwhash_str_verify($encoded, $raw); - } - - if (\extension_loaded('libsodium')) { - return \Sodium\crypto_pwhash_str_verify($encoded, $raw); - } - - return false; - } - - /** - * {@inheritdoc} - */ - public function needsRehash(string $encoded): bool - { - if (\function_exists('sodium_crypto_pwhash_str_needs_rehash')) { - return sodium_crypto_pwhash_str_needs_rehash($encoded, $this->opsLimit, $this->memLimit); - } - - if (\extension_loaded('libsodium')) { - return \Sodium\crypto_pwhash_str_needs_rehash($encoded, $this->opsLimit, $this->memLimit); - } - - throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.'); + return SodiumPasswordHasher::isSupported(); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php index aeb29956469d7..32ab07c69041c 100644 --- a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; use Symfony\Component\Security\Core\User\UserInterface; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', UserPasswordEncoder::class, UserPasswordHasher::class)); + /** * A generic password encoder. * * @author Ariel Ferrandini + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHasher} instead */ class UserPasswordEncoder implements UserPasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php index 522ec0b02300c..a113d1085acec 100644 --- a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\User\UserInterface; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" interface is deprecated, use "%s" instead.', UserPasswordEncoderInterface::class, UserPasswordHasherInterface::class)); + /** * UserPasswordEncoderInterface is the interface for the password encoder service. * * @author Ariel Ferrandini + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHasherInterface} instead */ interface UserPasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php index d911fe3dab16e..76a5548d0622d 100644 --- a/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php +++ b/src/Symfony/Component/Security/Core/Role/RoleHierarchy.php @@ -48,7 +48,7 @@ public function getReachableRoleNames(array $roles): array } } - return $reachableRoles; + return array_values(array_unique($reachableRoles)); } protected function buildRoleMap() diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php index 57ed2d0bf786f..20e75b80760df 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php @@ -15,17 +15,17 @@ use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; -use Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder; use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; -use Symfony\Component\Security\Core\Tests\Encoder\TestPasswordEncoderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class DaoAuthenticationProviderTest extends TestCase { @@ -39,7 +39,10 @@ public function testRetrieveUserWhenProviderDoesNotReturnAnUserInterface() $method->invoke($provider, 'fabien', $this->getSupportedToken()); } - public function testRetrieveUserWhenUsernameIsNotFound() + /** + * @group legacy + */ + public function testRetrieveUserWhenUsernameIsNotFoundWithLegacyEncoderFactory() { $this->expectException(UsernameNotFoundException::class); $userProvider = $this->createMock(UserProviderInterface::class); @@ -55,6 +58,22 @@ public function testRetrieveUserWhenUsernameIsNotFound() $method->invoke($provider, 'fabien', $this->getSupportedToken()); } + public function testRetrieveUserWhenUsernameIsNotFound() + { + $this->expectException(UsernameNotFoundException::class); + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->willThrowException(new UsernameNotFoundException()) + ; + + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); + $method = new \ReflectionMethod($provider, 'retrieveUser'); + $method->setAccessible(true); + + $method->invoke($provider, 'fabien', $this->getSupportedToken()); + } + public function testRetrieveUserWhenAnExceptionOccurs() { $this->expectException(AuthenticationServiceException::class); @@ -64,7 +83,7 @@ public function testRetrieveUserWhenAnExceptionOccurs() ->willThrowException(new \RuntimeException()) ; - $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(EncoderFactoryInterface::class)); + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); $method = new \ReflectionMethod($provider, 'retrieveUser'); $method->setAccessible(true); @@ -85,7 +104,7 @@ public function testRetrieveUserReturnsUserFromTokenOnReauthentication() ->willReturn($user) ; - $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(EncoderFactoryInterface::class)); + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); $reflection = new \ReflectionMethod($provider, 'retrieveUser'); $reflection->setAccessible(true); $result = $reflection->invoke($provider, 'someUser', $token); @@ -103,7 +122,7 @@ public function testRetrieveUser() ->willReturn($user) ; - $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(EncoderFactoryInterface::class)); + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); $method = new \ReflectionMethod($provider, 'retrieveUser'); $method->setAccessible(true); @@ -113,13 +132,13 @@ public function testRetrieveUser() public function testCheckAuthenticationWhenCredentialsAreEmpty() { $this->expectException(BadCredentialsException::class); - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder + $hasher = $this->getMockBuilder(PasswordHasherInterface::class)->getMock(); + $hasher ->expects($this->never()) - ->method('isPasswordValid') + ->method('verify') ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -135,14 +154,14 @@ public function testCheckAuthenticationWhenCredentialsAreEmpty() public function testCheckAuthenticationWhenCredentialsAre0() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher ->expects($this->once()) - ->method('isPasswordValid') + ->method('verify') ->willReturn(true) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -163,13 +182,13 @@ public function testCheckAuthenticationWhenCredentialsAre0() public function testCheckAuthenticationWhenCredentialsAreNotValid() { $this->expectException(BadCredentialsException::class); - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->once()) - ->method('isPasswordValid') + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->once()) + ->method('verify') ->willReturn(false) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -235,13 +254,13 @@ public function testCheckAuthenticationWhenTokenNeedsReauthenticationWorksWithou public function testCheckAuthentication() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->once()) - ->method('isPasswordValid') + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->once()) + ->method('verify') ->willReturn(true) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -258,21 +277,21 @@ public function testPasswordUpgrades() { $user = new User('user', 'pwd'); - $encoder = $this->createMock(TestPasswordEncoderInterface::class); - $encoder->expects($this->once()) - ->method('isPasswordValid') + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->once()) + ->method('verify') ->willReturn(true) ; - $encoder->expects($this->once()) - ->method('encodePassword') + $hasher->expects($this->once()) + ->method('hash') ->willReturn('foobar') ; - $encoder->expects($this->once()) + $hasher->expects($this->once()) ->method('needsRehash') ->willReturn(true) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $userProvider = ((array) $provider)[sprintf("\0%s\0userProvider", DaoAuthenticationProvider::class)]; $userProvider->expects($this->once()) @@ -304,7 +323,7 @@ protected function getSupportedToken() return $mock; } - protected function getProvider($user = null, $userChecker = null, $passwordEncoder = null) + protected function getProvider($user = null, $userChecker = null, $passwordHasher = null) { $userProvider = $this->createMock(PasswordUpgraderProvider::class); if (null !== $user) { @@ -318,18 +337,18 @@ protected function getProvider($user = null, $userChecker = null, $passwordEncod $userChecker = $this->createMock(UserCheckerInterface::class); } - if (null === $passwordEncoder) { - $passwordEncoder = new PlaintextPasswordEncoder(); + if (null === $passwordHasher) { + $passwordHasher = new PlaintextPasswordHasher(); } - $encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $encoderFactory + $hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $hasherFactory ->expects($this->any()) - ->method('getEncoder') - ->willReturn($passwordEncoder) + ->method('getPasswordHasher') + ->willReturn($passwordHasher) ; - return new DaoAuthenticationProvider($userProvider, $userChecker, 'key', $encoderFactory); + return new DaoAuthenticationProvider($userProvider, $userChecker, 'key', $hasherFactory); } } diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Storage/UsageTrackingTokenStorageTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Storage/UsageTrackingTokenStorageTest.php index 607ccc750480d..c5d2eaf543203 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Storage/UsageTrackingTokenStorageTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Token/Storage/UsageTrackingTokenStorageTest.php @@ -13,6 +13,8 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\UsageTrackingTokenStorage; @@ -24,14 +26,19 @@ class UsageTrackingTokenStorageTest extends TestCase public function testGetSetToken() { $sessionAccess = 0; - $sessionLocator = new class(['session' => function () use (&$sessionAccess) { + $sessionLocator = new class(['request_stack' => function () use (&$sessionAccess) { ++$sessionAccess; $session = $this->createMock(SessionInterface::class); $session->expects($this->once()) ->method('getMetadataBag'); - return $session; + $request = new Request(); + $request->setSession($session); + $requestStack = new RequestStack(); + $requestStack->push($request); + + return $requestStack; }]) implements ContainerInterface { use ServiceLocatorTrait; }; diff --git a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php index 5f2e5d657acb7..375fb6d6d49ef 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authorization/AccessDecisionManagerTest.php @@ -12,12 +12,15 @@ namespace Symfony\Component\Security\Core\Tests\Authorization; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class AccessDecisionManagerTest extends TestCase { + use ExpectDeprecationTrait; + public function testSetUnsupportedStrategy() { $this->expectException(\InvalidArgumentException::class); @@ -35,6 +38,20 @@ public function testStrategies($strategy, $voters, $allowIfAllAbstainDecisions, $this->assertSame($expected, $manager->decide($token, ['ROLE_FOO'])); } + /** + * @dataProvider provideStrategies + * @group legacy + */ + public function testDeprecatedVoter($strategy) + { + $token = $this->createMock(TokenInterface::class); + $manager = new AccessDecisionManager([$this->getVoter(3)], $strategy); + + $this->expectDeprecation('Since symfony/security-core 5.3: Returning "3" in "%s::vote()" is deprecated, return one of "Symfony\Component\Security\Core\Authorization\Voter\VoterInterface" constants: "ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN".'); + + $manager->decide($token, ['ROLE_FOO']); + } + public function getStrategyTests() { return [ @@ -95,6 +112,14 @@ public function getStrategyTests() ]; } + public function provideStrategies() + { + yield [AccessDecisionManager::STRATEGY_AFFIRMATIVE]; + yield [AccessDecisionManager::STRATEGY_CONSENSUS]; + yield [AccessDecisionManager::STRATEGY_UNANIMOUS]; + yield [AccessDecisionManager::STRATEGY_PRIORITY]; + } + protected function getVoters($grants, $denies, $abstains) { $voters = []; diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php index a6999991393c4..7b79986b826a6 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php @@ -20,7 +20,13 @@ use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; +/** + * @group legacy + */ class EncoderFactoryTest extends TestCase { public function testGetEncoderWithMessageDigestEncoder() @@ -176,6 +182,17 @@ public function testDefaultMigratingEncoders() (new EncoderFactory([SomeUser::class => ['class' => SodiumPasswordEncoder::class, 'arguments' => []]]))->getEncoder(SomeUser::class) ); } + + public function testHasherAwareCompat() + { + $factory = new PasswordHasherFactory([ + 'encoder_name' => new MessageDigestPasswordHasher('sha1'), + ]); + + $encoder = $factory->getPasswordHasher(new HasherAwareUser('user', 'pass')); + $expectedEncoder = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedEncoder->hash('foo', ''), $encoder->hash('foo', '')); + } } class SomeUser implements UserInterface @@ -214,3 +231,14 @@ public function getEncoderName(): ?string return $this->encoderName; } } + + +class HasherAwareUser extends SomeUser implements PasswordHasherAwareInterface +{ + public $hasherName = 'encoder_name'; + + public function getPasswordHasherName(): ?string + { + return $this->hasherName; + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php index c2b514bb6b0af..a354b0dbf25a8 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class MessageDigestPasswordEncoderTest extends TestCase { public function testIsPasswordValid() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php index efa360ecb2cf1..fbaf89b0b1b1a 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; +/** + * @group legacy + */ class MigratingPasswordEncoderTest extends TestCase { public function testValidation() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php index c67bf8668b4dd..9d864dfce038e 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php @@ -16,6 +16,7 @@ /** * @author Elnur Abdurrakhimov + * @group legacy */ class NativePasswordEncoderTest extends TestCase { diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php index db274716bd834..000e07d659113 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class Pbkdf2PasswordEncoderTest extends TestCase { public function testIsPasswordValid() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php index fb5e674567d1b..398044035eb61 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class PlaintextPasswordEncoderTest extends TestCase { public function testIsPasswordValid() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php index b4073a1cfba53..4bae5f89f35bc 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class SodiumPasswordEncoderTest extends TestCase { protected function setUp(): void diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php b/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php index 13e2d0d3b36ea..3764038e9a9d3 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php @@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; +/** + * @group legacy + */ interface TestPasswordEncoderInterface extends PasswordEncoderInterface { public function needsRehash(string $encoded): bool; diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php index 0d72919abc40a..6f52fbf1b22d9 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php @@ -19,6 +19,9 @@ use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserInterface; +/** + * @group legacy + */ class UserPasswordEncoderTest extends TestCase { public function testEncodePassword() diff --git a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php index b84889f57a6cd..5c42e0b39f8bf 100644 --- a/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Role/RoleHierarchyTest.php @@ -28,5 +28,6 @@ public function testGetReachableRoleNames() $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_ADMIN'])); $this->assertEquals(['ROLE_FOO', 'ROLE_ADMIN', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_FOO', 'ROLE_ADMIN'])); $this->assertEquals(['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_SUPER_ADMIN'])); + $this->assertEquals(['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_FOO', 'ROLE_USER'], $role->getReachableRoleNames(['ROLE_SUPER_ADMIN', 'ROLE_SUPER_ADMIN'])); } } diff --git a/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php b/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php index 9c65298b07f56..ef62023d53e45 100644 --- a/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php +++ b/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php @@ -17,11 +17,11 @@ interface PasswordUpgraderInterface { /** - * Upgrades the encoded password of a user, typically for using a better hash algorithm. + * Upgrades the hashed password of a user, typically for using a better hash algorithm. * * This method should persist the new password in the user storage and update the $user object accordingly. * Because you don't want your users not being able to log in, this method should be opportunistic: * it's fine if it does nothing or if it fails without throwing any exception. */ - public function upgradePassword(UserInterface $user, string $newEncodedPassword): void; + public function upgradePassword(UserInterface $user, string $newHashedPassword): void; } diff --git a/src/Symfony/Component/Security/Core/User/UserInterface.php b/src/Symfony/Component/Security/Core/User/UserInterface.php index 239eb0ed00c6c..c005e3ca9c9b7 100644 --- a/src/Symfony/Component/Security/Core/User/UserInterface.php +++ b/src/Symfony/Component/Security/Core/User/UserInterface.php @@ -15,7 +15,7 @@ * Represents the interface that all user classes must implement. * * This interface is useful because the authentication layer can deal with - * the object through its lifecycle, using the object to get the encoded + * the object through its lifecycle, using the object to get the hashed * password (for checking against a submitted password), assigning roles * and so on. * @@ -49,17 +49,17 @@ public function getRoles(); /** * Returns the password used to authenticate the user. * - * This should be the encoded password. On authentication, a plain-text - * password will be salted, encoded, and then compared to this value. + * This should be the hashed password. On authentication, a plain-text + * password will be hashed, and then compared to this value. * - * @return string|null The encoded password if any + * @return string|null The hashed password if any */ public function getPassword(); /** - * Returns the salt that was originally used to encode the password. + * Returns the salt that was originally used to hash the password. * - * This can return null if the password was not encoded using a salt. + * This can return null if the password was not hashed using a salt. * * @return string|null The salt */ diff --git a/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php b/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php index 24b032484fa1a..0181ccbcbb935 100644 --- a/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php +++ b/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php @@ -13,7 +13,9 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; @@ -22,12 +24,19 @@ class UserPasswordValidator extends ConstraintValidator { private $tokenStorage; - private $encoderFactory; + private $hasherFactory; - public function __construct(TokenStorageInterface $tokenStorage, EncoderFactoryInterface $encoderFactory) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct(TokenStorageInterface $tokenStorage, $hasherFactory) { + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + $this->tokenStorage = $tokenStorage; - $this->encoderFactory = $encoderFactory; + $this->hasherFactory = $hasherFactory; } /** @@ -51,9 +60,9 @@ public function validate($password, Constraint $constraint) throw new ConstraintDefinitionException('The User object must implement the UserInterface interface.'); } - $encoder = $this->encoderFactory->getEncoder($user); + $hasher = $this->hasherFactory instanceof EncoderFactoryInterface ? $this->hasherFactory->getEncoder($user) : $this->hasherFactory->getPasswordHasher($user); - if (null === $user->getPassword() || !$encoder->isPasswordValid($user->getPassword(), $password, $user->getSalt())) { + if (null === $user->getPassword() || !($hasher instanceof PasswordEncoderInterface ? $hasher->isPasswordValid($user->getPassword(), $password, $user->getSalt()) : $hasher->verify($user->getPassword(), $password, $user->getSalt()))) { $this->context->addViolation($constraint->message); } } diff --git a/src/Symfony/Component/Security/Core/composer.json b/src/Symfony/Component/Security/Core/composer.json index 3d74c1c73dcea..424c077569d51 100644 --- a/src/Symfony/Component/Security/Core/composer.json +++ b/src/Symfony/Component/Security/Core/composer.json @@ -20,13 +20,14 @@ "symfony/event-dispatcher-contracts": "^1.1|^2", "symfony/polyfill-php80": "^1.15", "symfony/service-contracts": "^1.1.6|^2", - "symfony/deprecation-contracts": "^2.1" + "symfony/deprecation-contracts": "^2.1", + "symfony/password-hasher": "^5.3" }, "require-dev": { "psr/container": "^1.0", "symfony/event-dispatcher": "^4.4|^5.0", "symfony/expression-language": "^4.4|^5.0", - "symfony/http-foundation": "^4.4|^5.0", + "symfony/http-foundation": "^5.3", "symfony/ldap": "^4.4|^5.0", "symfony/translation": "^4.4|^5.0", "symfony/validator": "^5.2", @@ -34,6 +35,7 @@ }, "conflict": { "symfony/event-dispatcher": "<4.4", + "symfony/http-foundation": "<5.3", "symfony/security-guard": "<4.4", "symfony/ldap": "<4.4", "symfony/validator": "<5.2" diff --git a/src/Symfony/Component/Security/Csrf/CsrfTokenManager.php b/src/Symfony/Component/Security/Csrf/CsrfTokenManager.php index da28302b78f31..31a39b06bfb77 100644 --- a/src/Symfony/Component/Security/Csrf/CsrfTokenManager.php +++ b/src/Symfony/Component/Security/Csrf/CsrfTokenManager.php @@ -77,7 +77,7 @@ public function getToken(string $tokenId) $this->storage->setToken($namespacedId, $value); } - return new CsrfToken($tokenId, $value); + return new CsrfToken($tokenId, $this->randomize($value)); } /** @@ -90,7 +90,7 @@ public function refreshToken(string $tokenId) $this->storage->setToken($namespacedId, $value); - return new CsrfToken($tokenId, $value); + return new CsrfToken($tokenId, $this->randomize($value)); } /** @@ -111,11 +111,40 @@ public function isTokenValid(CsrfToken $token) return false; } - return hash_equals($this->storage->getToken($namespacedId), $token->getValue()); + return hash_equals($this->storage->getToken($namespacedId), $this->derandomize($token->getValue())); } private function getNamespace(): string { return \is_callable($ns = $this->namespace) ? $ns() : $ns; } + + private function randomize(string $value): string + { + $key = random_bytes(32); + $value = $this->xor($value, $key); + + return sprintf('%s.%s.%s', substr(md5($key), 0, 1 + (\ord($key[0]) % 32)), rtrim(strtr(base64_encode($key), '+/', '-_'), '='), rtrim(strtr(base64_encode($value), '+/', '-_'), '=')); + } + + private function derandomize(string $value): string + { + $parts = explode('.', $value); + if (3 !== \count($parts)) { + return $value; + } + $key = base64_decode(strtr($parts[1], '-_', '+/')); + $value = base64_decode(strtr($parts[2], '-_', '+/')); + + return $this->xor($value, $key); + } + + private function xor(string $value, string $key): string + { + if (\strlen($value) > \strlen($key)) { + $key = str_repeat($key, ceil(\strlen($value) / \strlen($key))); + } + + return $value ^ $key; + } } diff --git a/src/Symfony/Component/Security/Csrf/Tests/CsrfTokenManagerTest.php b/src/Symfony/Component/Security/Csrf/Tests/CsrfTokenManagerTest.php index fe81b9547b126..d654bbf195fa4 100644 --- a/src/Symfony/Component/Security/Csrf/Tests/CsrfTokenManagerTest.php +++ b/src/Symfony/Component/Security/Csrf/Tests/CsrfTokenManagerTest.php @@ -46,7 +46,7 @@ public function testGetNonExistingToken($namespace, $manager, $storage, $generat $this->assertInstanceOf(CsrfToken::class, $token); $this->assertSame('token_id', $token->getId()); - $this->assertSame('TOKEN', $token->getValue()); + $this->assertNotSame('TOKEN', $token->getValue()); } /** @@ -68,7 +68,34 @@ public function testUseExistingTokenIfAvailable($namespace, $manager, $storage) $this->assertInstanceOf(CsrfToken::class, $token); $this->assertSame('token_id', $token->getId()); - $this->assertSame('TOKEN', $token->getValue()); + $this->assertNotSame('TOKEN', $token->getValue()); + } + + /** + * @dataProvider getManagerGeneratorAndStorage + */ + public function testRandomizeTheToken($namespace, $manager, $storage) + { + $storage->expects($this->any()) + ->method('hasToken') + ->with($namespace.'token_id') + ->willReturn(true); + + $storage->expects($this->any()) + ->method('getToken') + ->with($namespace.'token_id') + ->willReturn('TOKEN'); + + $values = []; + $lengths = []; + for ($i = 0; $i < 10; ++$i) { + $token = $manager->getToken('token_id'); + $values[] = $token->getValue(); + $lengths[] = \strlen($token->getValue()); + } + + $this->assertCount(10, array_unique($values)); + $this->assertGreaterThan(2, \count(array_unique($lengths))); } /** @@ -91,13 +118,33 @@ public function testRefreshTokenAlwaysReturnsNewToken($namespace, $manager, $sto $this->assertInstanceOf(CsrfToken::class, $token); $this->assertSame('token_id', $token->getId()); - $this->assertSame('TOKEN', $token->getValue()); + $this->assertNotSame('TOKEN', $token->getValue()); } /** * @dataProvider getManagerGeneratorAndStorage */ public function testMatchingTokenIsValid($namespace, $manager, $storage) + { + $storage->expects($this->exactly(2)) + ->method('hasToken') + ->with($namespace.'token_id') + ->willReturn(true); + + $storage->expects($this->exactly(2)) + ->method('getToken') + ->with($namespace.'token_id') + ->willReturn('TOKEN'); + + $token = $manager->getToken('token_id'); + $this->assertNotSame('TOKEN', $token->getValue()); + $this->assertTrue($manager->isTokenValid($token)); + } + + /** + * @dataProvider getManagerGeneratorAndStorage + */ + public function testMatchingTokenIsValidWithLegacyToken($namespace, $manager, $storage) { $storage->expects($this->once()) ->method('hasToken') @@ -172,7 +219,6 @@ public function testNamespaced() $token = $manager->getToken('foo'); $this->assertSame('foo', $token->getId()); - $this->assertSame('random', $token->getValue()); } public function getManagerGeneratorAndStorage() diff --git a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php index 5046cc0deca12..230f33fb257f3 100644 --- a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php +++ b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php @@ -12,6 +12,8 @@ namespace Symfony\Component\Security\Csrf\Tests\TokenStorage; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; @@ -37,7 +39,11 @@ class SessionTokenStorageTest extends TestCase protected function setUp(): void { $this->session = new Session(new MockArraySessionStorage()); - $this->storage = new SessionTokenStorage($this->session, self::SESSION_NAMESPACE); + $request = new Request(); + $request->setSession($this->session); + $requestStack = new RequestStack(); + $requestStack->push($request); + $this->storage = new SessionTokenStorage($requestStack, self::SESSION_NAMESPACE); } public function testStoreTokenInNotStartedSessionStartsTheSession() diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php b/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php index 04774d0c96419..70613f5f26f25 100644 --- a/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php +++ b/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php @@ -11,7 +11,12 @@ namespace Symfony\Component\Security\Csrf\TokenStorage; +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; /** @@ -26,17 +31,30 @@ class SessionTokenStorage implements ClearableTokenStorageInterface */ public const SESSION_NAMESPACE = '_csrf'; - private $session; + private $requestStack; private $namespace; + /** + * Tp be remove in Symfony 6.0 + */ + private $session; /** - * Initializes the storage with a Session object and a session namespace. + * Initializes the storage with a RequestStack object and a session namespace. * - * @param string $namespace The namespace under which the token is stored in the session + * @param RequestStack $requestStack + * @param string $namespace The namespace under which the token is stored in the requestStack */ - public function __construct(SessionInterface $session, string $namespace = self::SESSION_NAMESPACE) + public function __construct(/* RequestStack*/ $requestStack, string $namespace = self::SESSION_NAMESPACE) { - $this->session = $session; + if ($requestStack instanceof SessionInterface) { + trigger_deprecation('symfony/security-csrf', '5.3', 'Passing a "%s" to "%s" is deprecated, use a "%s" instead.', SessionInterface::class, __CLASS__, RequestStack::class); + $request = new Request(); + $request->setSession($requestStack); + + $requestStack = new RequestStack(); + $requestStack->push($request); + } + $this->requestStack = $requestStack; $this->namespace = $namespace; } @@ -45,15 +63,16 @@ public function __construct(SessionInterface $session, string $namespace = self: */ public function getToken(string $tokenId) { - if (!$this->session->isStarted()) { - $this->session->start(); + $session = $this->getSession(); + if (!$session->isStarted()) { + $session->start(); } - if (!$this->session->has($this->namespace.'/'.$tokenId)) { + if (!$session->has($this->namespace.'/'.$tokenId)) { throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' does not exist.'); } - return (string) $this->session->get($this->namespace.'/'.$tokenId); + return (string) $session->get($this->namespace.'/'.$tokenId); } /** @@ -61,11 +80,12 @@ public function getToken(string $tokenId) */ public function setToken(string $tokenId, string $token) { - if (!$this->session->isStarted()) { - $this->session->start(); + $session = $this->getSession(); + if (!$session->isStarted()) { + $session->start(); } - $this->session->set($this->namespace.'/'.$tokenId, $token); + $session->set($this->namespace.'/'.$tokenId, $token); } /** @@ -73,11 +93,12 @@ public function setToken(string $tokenId, string $token) */ public function hasToken(string $tokenId) { - if (!$this->session->isStarted()) { - $this->session->start(); + $session = $this->getSession(); + if (!$session->isStarted()) { + $session->start(); } - return $this->session->has($this->namespace.'/'.$tokenId); + return $session->has($this->namespace.'/'.$tokenId); } /** @@ -85,11 +106,12 @@ public function hasToken(string $tokenId) */ public function removeToken(string $tokenId) { - if (!$this->session->isStarted()) { - $this->session->start(); + $session = $this->getSession(); + if (!$session->isStarted()) { + $session->start(); } - return $this->session->remove($this->namespace.'/'.$tokenId); + return $session->remove($this->namespace.'/'.$tokenId); } /** @@ -97,10 +119,22 @@ public function removeToken(string $tokenId) */ public function clear() { - foreach (array_keys($this->session->all()) as $key) { + $session = $this->getSession(); + foreach (array_keys($session->all()) as $key) { if (0 === strpos($key, $this->namespace.'/')) { - $this->session->remove($key); + $session->remove($key); } } } + + private function getSession(): SessionInterface + { + try { + return $this->requestStack->getSession(); + } catch (SessionNotFoundException $e) { + trigger_deprecation('symfony/security-csrf', '5.3', 'Using the "%s" without a session has no effect and is deprecated. It will throw a "%s" in Symfony 6.0', __CLASS__, SessionNotFoundException::class); + + return $this->session ?? $this->session = new Session(new MockArraySessionStorage()); + } + } } diff --git a/src/Symfony/Component/Security/Csrf/composer.json b/src/Symfony/Component/Security/Csrf/composer.json index 3bdea6f7a449a..5693034cffaa7 100644 --- a/src/Symfony/Component/Security/Csrf/composer.json +++ b/src/Symfony/Component/Security/Csrf/composer.json @@ -20,10 +20,10 @@ "symfony/security-core": "^4.4|^5.0" }, "require-dev": { - "symfony/http-foundation": "^4.4|^5.0" + "symfony/http-foundation": "^5.3" }, "conflict": { - "symfony/http-foundation": "<4.4" + "symfony/http-foundation": "<5.3" }, "suggest": { "symfony/http-foundation": "For using the class SessionTokenStorage." diff --git a/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php index 49244680ad737..806b1b6504ac9 100644 --- a/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php +++ b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Guard\Provider; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; @@ -42,19 +43,24 @@ class GuardAuthenticationProvider implements AuthenticationProviderInterface private $userProvider; private $providerKey; private $userChecker; - private $passwordEncoder; + private $passwordHasher; /** * @param iterable|AuthenticatorInterface[] $guardAuthenticators The authenticators, with keys that match what's passed to GuardAuthenticationListener * @param string $providerKey The provider (i.e. firewall) key + * @param UserPasswordHasherInterface $passwordHasher */ - public function __construct(iterable $guardAuthenticators, UserProviderInterface $userProvider, string $providerKey, UserCheckerInterface $userChecker, UserPasswordEncoderInterface $passwordEncoder = null) + public function __construct(iterable $guardAuthenticators, UserProviderInterface $userProvider, string $providerKey, UserCheckerInterface $userChecker, $passwordHasher = null) { $this->guardAuthenticators = $guardAuthenticators; $this->userProvider = $userProvider; $this->providerKey = $providerKey; $this->userChecker = $userChecker; - $this->passwordEncoder = $passwordEncoder; + $this->passwordHasher = $passwordHasher; + + if ($passwordHasher instanceof UserPasswordEncoderInterface) { + trigger_deprecation('symfony/security-core', '5.3', sprintf('Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', UserPasswordEncoderInterface::class, __CLASS__, UserPasswordHasherInterface::class)); + } } /** @@ -123,8 +129,13 @@ private function authenticateViaGuard(AuthenticatorInterface $guardAuthenticator throw new BadCredentialsException(sprintf('Authentication failed because "%s::checkCredentials()" did not return true.', get_debug_type($guardAuthenticator))); } - if ($this->userProvider instanceof PasswordUpgraderInterface && $guardAuthenticator instanceof PasswordAuthenticatedInterface && null !== $this->passwordEncoder && (null !== $password = $guardAuthenticator->getPassword($token->getCredentials())) && method_exists($this->passwordEncoder, 'needsRehash') && $this->passwordEncoder->needsRehash($user)) { - $this->userProvider->upgradePassword($user, $this->passwordEncoder->encodePassword($user, $password)); + if ($this->userProvider instanceof PasswordUpgraderInterface && $guardAuthenticator instanceof PasswordAuthenticatedInterface && null !== $this->passwordHasher && (null !== $password = $guardAuthenticator->getPassword($token->getCredentials())) && $this->passwordHasher->needsRehash($user)) { + if ($this->passwordHasher instanceof UserPasswordEncoderInterface) { + // @deprecated since Symfony 5.3 + $this->userProvider->upgradePassword($user, $this->passwordHasher->encodePassword($user, $password)); + } else { + $this->userProvider->upgradePassword($user, $this->passwordHasher->hashPassword($user, $password)); + } } $this->userChecker->checkPostAuth($user); diff --git a/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php b/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php index e9202ed2b35db..413f982ecc5ac 100644 --- a/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php +++ b/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php @@ -11,12 +11,10 @@ namespace Symfony\Component\Security\Http\Attribute; -use Symfony\Component\HttpKernel\Attribute\ArgumentInterface; - /** * Indicates that a controller argument should receive the current logged user. */ #[\Attribute(\Attribute::TARGET_PARAMETER)] -class CurrentUser implements ArgumentInterface +class CurrentUser { } diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php index d3afaacdd17b1..0b48a5b24594a 100644 --- a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php @@ -39,7 +39,7 @@ * @author Ryan Weaver * @author Amaury Leroux de Lens * - * @experimental in 5.2 + * @experimental in 5.3 */ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php index dfbd6dc14659a..b1abce56a9bdd 100644 --- a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManagerInterface.php @@ -19,7 +19,7 @@ * @author Wouter de Jong * @author Ryan Weaver * - * @experimental in 5.2 + * @experimental in 5.3 */ interface AuthenticatorManagerInterface { diff --git a/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php b/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php index 4e409b80b9e56..fe32a42a97392 100644 --- a/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php +++ b/src/Symfony/Component/Security/Http/Authentication/UserAuthenticatorInterface.php @@ -19,7 +19,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface UserAuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php index 5a6c489058f24..474847d189fb4 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php @@ -22,7 +22,7 @@ * * @author Ryan Weaver * - * @experimental in 5.2 + * @experimental in 5.3 */ abstract class AbstractAuthenticator implements AuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php index aeaf1d17cd193..deb157784d749 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractLoginFormAuthenticator.php @@ -23,7 +23,7 @@ * * @author Ryan Weaver * - * @experimental in 5.2 + * @experimental in 5.3 */ abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php index a11f2c000aa65..8ccd356ca1d09 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php @@ -33,7 +33,7 @@ * @author Fabien Potencier * * @internal - * @experimental in 5.2 + * @experimental in 5.3 */ abstract class AbstractPreAuthenticatedAuthenticator implements InteractiveAuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php b/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php index e89e9c52bcaea..0d7e9cb7bb6f4 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php +++ b/src/Symfony/Component/Security/Http/Authenticator/AuthenticatorInterface.php @@ -24,7 +24,7 @@ * @author Amaury Leroux de Lens * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface AuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php index 246e4894b1abc..234b2dc0a3d5a 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/FormLoginAuthenticator.php @@ -41,7 +41,7 @@ * @author Fabien Potencier * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class FormLoginAuthenticator extends AbstractLoginFormAuthenticator { diff --git a/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php index cd592b74930a1..179e1fdd1a94c 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/HttpBasicAuthenticator.php @@ -33,7 +33,7 @@ * @author Fabien Potencier * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class HttpBasicAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php index 013692783f40b..69ef4d28f5969 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php @@ -45,7 +45,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class JsonLoginAuthenticator implements InteractiveAuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php index 00623e2d0d2ed..0973a9595820c 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php @@ -28,7 +28,7 @@ /** * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ final class LoginLinkAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php index b47cad217f654..8e2d222089f4a 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/BadgeInterface.php @@ -16,7 +16,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php index 73f9ae5f177c6..dbfef17f9eaf1 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/CsrfTokenBadge.php @@ -21,7 +21,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class CsrfTokenBadge implements BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php index cfffe9d307b78..76b10f31b3864 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PasswordUpgradeBadge.php @@ -22,7 +22,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class PasswordUpgradeBadge implements BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php index e4ceb6f98d361..f78dedfc75f14 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/PreAuthenticatedUserBadge.php @@ -23,7 +23,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class PreAuthenticatedUserBadge implements BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php index ee890e318ec74..8ce47fce278b3 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/RememberMeBadge.php @@ -26,7 +26,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class RememberMeBadge implements BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php index 10856e4bfe870..a58f86cd13e4c 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php @@ -23,7 +23,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class UserBadge implements BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php index 08896cfe5e7ae..bdbbf751481c3 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CredentialsInterface.php @@ -19,7 +19,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface CredentialsInterface extends BadgeInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php index f0407107e6d4d..648e42f6d4881 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/CustomCredentials.php @@ -20,7 +20,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class CustomCredentials implements CredentialsInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php index 30838a836e8bf..e8b95bb8c292e 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Credentials/PasswordCredentials.php @@ -22,7 +22,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class PasswordCredentials implements CredentialsInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php index d9b23cd3a79de..6ae34e7a9f239 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php @@ -21,7 +21,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class Passport implements UserPassportInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php index e3cdc005ca6d5..15034b20e5e3b 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportInterface.php @@ -23,7 +23,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface PassportInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php index e075c42ba8323..1846c80214b8f 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/PassportTrait.php @@ -17,7 +17,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ trait PassportTrait { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php index c22d01bd4058c..ddce4cad0e8fe 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/SelfValidatingPassport.php @@ -21,7 +21,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class SelfValidatingPassport extends Passport { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php index 2d8b57f22b8ba..3c4ef8e9d9f78 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/UserPassportInterface.php @@ -18,7 +18,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ interface UserPassportInterface extends PassportInterface { diff --git a/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php b/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php index 70f7d92f9d8f0..b5e5551bfd859 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php @@ -25,7 +25,7 @@ * @author Fabien Potencier * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class X509Authenticator extends AbstractPreAuthenticatedAuthenticator { diff --git a/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php b/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php index 4b469edf8b10e..715004318e18c 100644 --- a/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php +++ b/src/Symfony/Component/Security/Http/Controller/UserValueResolver.php @@ -37,7 +37,7 @@ public function supports(Request $request, ArgumentMetadata $argument): bool { // with the attribute, the type can be any UserInterface implementation // otherwise, the type must be UserInterface - if (UserInterface::class !== $argument->getType() && !$argument->getAttribute() instanceof CurrentUser) { + if (UserInterface::class !== $argument->getType() && !$argument->getAttributes(CurrentUser::class, ArgumentMetadata::IS_INSTANCEOF)) { return false; } diff --git a/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php b/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php index c1f649b089ce0..95f9c88542189 100644 --- a/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; /** * This listeners uses the interfaces of authenticators to @@ -27,22 +28,29 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class CheckCredentialsListener implements EventSubscriberInterface { - private $encoderFactory; + private $hasherFactory; - public function __construct(EncoderFactoryInterface $encoderFactory) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct($hasherFactory) { - $this->encoderFactory = $encoderFactory; + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + + $this->hasherFactory = $hasherFactory; } public function checkPassport(CheckPassportEvent $event): void { $passport = $event->getPassport(); if ($passport instanceof UserPassportInterface && $passport->hasBadge(PasswordCredentials::class)) { - // Use the password encoder to validate the credentials + // Use the password hasher to validate the credentials $user = $passport->getUser(); /** @var PasswordCredentials $badge */ $badge = $passport->getBadge(PasswordCredentials::class); @@ -60,8 +68,15 @@ public function checkPassport(CheckPassportEvent $event): void throw new BadCredentialsException('The presented password is invalid.'); } - if (!$this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { - throw new BadCredentialsException('The presented password is invalid.'); + // @deprecated since Symfony 5.3 + if ($this->hasherFactory instanceof EncoderFactoryInterface) { + if (!$this->hasherFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { + throw new BadCredentialsException('The presented password is invalid.'); + } + } else { + if (!$this->hasherFactory->getPasswordHasher($user)->verify($user->getPassword(), $presentedPassword, $user->getSalt())) { + throw new BadCredentialsException('The presented password is invalid.'); + } } $badge->markResolved(); diff --git a/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php b/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php index 6634c51217f41..aac282bb749b1 100644 --- a/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/CsrfProtectionListener.php @@ -22,7 +22,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class CsrfProtectionListener implements EventSubscriberInterface { diff --git a/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php index 2b4954d71a538..d59b46e619da6 100644 --- a/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php @@ -22,7 +22,7 @@ /** * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class LoginThrottlingListener implements EventSubscriberInterface { diff --git a/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php b/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php index 81d4c04838619..d19121dec81c4 100644 --- a/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php @@ -12,6 +12,8 @@ namespace Symfony\Component\Security\Http\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; @@ -23,15 +25,22 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class PasswordMigratingListener implements EventSubscriberInterface { - private $encoderFactory; + private $hasherFactory; - public function __construct(EncoderFactoryInterface $encoderFactory) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct($hasherFactory) { - $this->encoderFactory = $encoderFactory; + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + + $this->hasherFactory = $hasherFactory; } public function onLoginSuccess(LoginSuccessEvent $event): void @@ -50,8 +59,8 @@ public function onLoginSuccess(LoginSuccessEvent $event): void } $user = $passport->getUser(); - $passwordEncoder = $this->encoderFactory->getEncoder($user); - if (!$passwordEncoder->needsRehash($user->getPassword())) { + $passwordHasher = $this->hasherFactory instanceof EncoderFactoryInterface ? $this->hasherFactory->getEncoder($user) : $this->hasherFactory->getPasswordHasher($user); + if (!$passwordHasher->needsRehash($user->getPassword())) { return; } @@ -72,7 +81,7 @@ public function onLoginSuccess(LoginSuccessEvent $event): void } } - $passwordUpgrader->upgradePassword($user, $passwordEncoder->encodePassword($plaintextPassword, $user->getSalt())); + $passwordUpgrader->upgradePassword($user, $passwordHasher instanceof PasswordHasherInterface ? $passwordHasher->hash($plaintextPassword, $user->getSalt()) : $passwordHasher->encodePassword($plaintextPassword, $user->getSalt())); } public static function getSubscribedEvents(): array diff --git a/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php b/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php index ea3d87cc9046d..70e15aa406d66 100644 --- a/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/RememberMeListener.php @@ -29,7 +29,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class RememberMeListener implements EventSubscriberInterface { diff --git a/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php b/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php index bc346b9ad837f..f53cdecaeac35 100644 --- a/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php @@ -23,7 +23,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class UserCheckerListener implements EventSubscriberInterface { diff --git a/src/Symfony/Component/Security/Http/EventListener/UserProviderListener.php b/src/Symfony/Component/Security/Http/EventListener/UserProviderListener.php index 5b862e6c0a755..715ae675c0bef 100644 --- a/src/Symfony/Component/Security/Http/EventListener/UserProviderListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/UserProviderListener.php @@ -22,7 +22,7 @@ * @author Wouter de Jong * * @final - * @experimental in 5.2 + * @experimental in 5.3 */ class UserProviderListener { diff --git a/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php b/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php index df1c22a41aa62..dc51982a3da78 100644 --- a/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/AuthenticatorManagerListener.php @@ -20,7 +20,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class AuthenticatorManagerListener extends AbstractListener { diff --git a/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php b/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php index 999128d14ed63..adaba2c715af9 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php +++ b/src/Symfony/Component/Security/Http/LoginLink/Exception/ExpiredLoginLinkException.php @@ -13,7 +13,7 @@ /** * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ class ExpiredLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface { diff --git a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php index c72506d7f7c8a..6c5c1d869bfac 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php +++ b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php @@ -17,7 +17,7 @@ * Thrown when a login link is invalid. * * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ class InvalidLoginLinkAuthenticationException extends AuthenticationException { diff --git a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php index d4967f44f4514..46f91298fff2a 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php +++ b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkException.php @@ -13,7 +13,7 @@ /** * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ class InvalidLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface { diff --git a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkExceptionInterface.php b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkExceptionInterface.php index 77fdd790453f0..57380f6367982 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkExceptionInterface.php +++ b/src/Symfony/Component/Security/Http/LoginLink/Exception/InvalidLoginLinkExceptionInterface.php @@ -13,7 +13,7 @@ /** * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ interface InvalidLoginLinkExceptionInterface extends \Throwable { diff --git a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkDetails.php b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkDetails.php index b33011728cf28..a3ef5fa2adf0c 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkDetails.php +++ b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkDetails.php @@ -13,7 +13,7 @@ /** * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ class LoginLinkDetails { diff --git a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php index 5110d1cbca8bb..8c8329a91e97a 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php +++ b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php @@ -22,7 +22,7 @@ /** * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ final class LoginLinkHandler implements LoginLinkHandlerInterface { diff --git a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandlerInterface.php b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandlerInterface.php index 4c460baa1321a..4c7bd694285e1 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandlerInterface.php +++ b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandlerInterface.php @@ -18,7 +18,7 @@ * A class that is able to create and handle "magic" login links. * * @author Ryan Weaver - * @experimental in 5.2 + * @experimental in 5.3 */ interface LoginLinkHandlerInterface { diff --git a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkNotification.php b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkNotification.php index 1b6d4eca17afb..5e09537dda3bf 100644 --- a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkNotification.php +++ b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkNotification.php @@ -26,7 +26,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ class LoginLinkNotification extends Notification implements EmailNotificationInterface, SmsNotificationInterface { diff --git a/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php b/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php index cdf7109cf3ad4..c038a434bb80b 100644 --- a/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php +++ b/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php @@ -24,7 +24,7 @@ * * @author Wouter de Jong * - * @experimental in 5.2 + * @experimental in 5.3 */ final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter { diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php index 79e914965ab9e..27e20917d0bc6 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php @@ -4,31 +4,31 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Tests\Authenticator\Fixtures\PasswordUpgraderProvider; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class HttpBasicAuthenticatorTest extends TestCase { private $userProvider; - private $encoderFactory; - private $encoder; + private $hasherFactory; + private $hasher; private $authenticator; protected function setUp(): void { $this->userProvider = $this->createMock(UserProviderInterface::class); - $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $this->encoder = $this->createMock(PasswordEncoderInterface::class); - $this->encoderFactory + $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $this->hasher = $this->createMock(PasswordHasherInterface::class); + $this->hasherFactory ->expects($this->any()) - ->method('getEncoder') - ->willReturn($this->encoder); + ->method('getPasswordHasher') + ->willReturn($this->hasher); $this->authenticator = new HttpBasicAuthenticator('test', $this->userProvider); } diff --git a/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php b/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php index ca3197e5e4f9a..bfded5d20a141 100644 --- a/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Controller/UserValueResolverTest.php @@ -77,7 +77,8 @@ public function testResolveWithAttribute() $tokenStorage->setToken($token); $resolver = new UserValueResolver($tokenStorage); - $metadata = new ArgumentMetadata('foo', null, false, false, null, false, new CurrentUser()); + $metadata = $this->createMock(ArgumentMetadata::class); + $metadata = new ArgumentMetadata('foo', null, false, false, null, false, [new CurrentUser()]); $this->assertTrue($resolver->supports(Request::create('/'), $metadata)); $this->assertSame([$user], iterator_to_array($resolver->resolve(Request::create('/'), $metadata))); @@ -89,7 +90,7 @@ public function testResolveWithAttributeAndNoUser() $tokenStorage->setToken(new UsernamePasswordToken('username', 'password', 'provider')); $resolver = new UserValueResolver($tokenStorage); - $metadata = new ArgumentMetadata('foo', null, false, false, null, false, new CurrentUser()); + $metadata = new ArgumentMetadata('foo', null, false, false, null, false, [new CurrentUser()]); $this->assertFalse($resolver->supports(Request::create('/'), $metadata)); } diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php index e903dcd22cbf6..315d7ccce4585 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; @@ -25,18 +24,20 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\CheckPassportEvent; use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class CheckCredentialsListenerTest extends TestCase { - private $encoderFactory; + private $hasherFactory; private $listener; private $user; protected function setUp(): void { - $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $this->listener = new CheckCredentialsListener($this->encoderFactory); - $this->user = new User('wouter', 'encoded-password'); + $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $this->listener = new CheckCredentialsListener($this->hasherFactory); + $this->user = new User('wouter', 'password-hash'); } /** @@ -44,10 +45,10 @@ protected function setUp(): void */ public function testPasswordAuthenticated($password, $passwordValid, $result) { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', $password)->willReturn($passwordValid); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', $password)->willReturn($passwordValid); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); if (false === $result) { $this->expectException(BadCredentialsException::class); @@ -73,7 +74,7 @@ public function testEmptyPassword() $this->expectException(BadCredentialsException::class); $this->expectExceptionMessage('The presented password cannot be empty.'); - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); $event = $this->createEvent(new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials(''))); $this->listener->checkPassport($event); @@ -84,7 +85,7 @@ public function testEmptyPassword() */ public function testCustomAuthenticated($result) { - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); if (false === $result) { $this->expectException(BadCredentialsException::class); @@ -108,7 +109,7 @@ public function provideCustomAuthenticatedResults() public function testNoCredentialsBadgeProvided() { - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); $this->listener->checkPassport($event); @@ -116,10 +117,10 @@ public function testNoCredentialsBadgeProvided() public function testAddsPasswordUpgradeBadge() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', 'ThePa$$word')->willReturn(true); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', 'ThePa$$word')->willReturn(true); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); $passport = new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials('ThePa$$word')); $this->listener->checkPassport($this->createEvent($passport)); @@ -130,10 +131,10 @@ public function testAddsPasswordUpgradeBadge() public function testAddsNoPasswordUpgradeBadgeIfItAlreadyExists() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', 'ThePa$$word')->willReturn(true); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', 'ThePa$$word')->willReturn(true); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); $passport = $this->getMockBuilder(Passport::class) ->setMethods(['addBadge']) @@ -147,10 +148,10 @@ public function testAddsNoPasswordUpgradeBadgeIfItAlreadyExists() public function testAddsNoPasswordUpgradeBadgeIfPasswordIsInvalid() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', 'ThePa$$word')->willReturn(false); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', 'ThePa$$word')->willReturn(false); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); $passport = $this->getMockBuilder(Passport::class) ->setMethods(['addBadge']) diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php index 39a62e0f69d68..0e32433c5b251 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php @@ -14,8 +14,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; @@ -27,23 +25,25 @@ use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class PasswordMigratingListenerTest extends TestCase { - private $encoderFactory; + private $hasherFactory; private $listener; private $user; protected function setUp(): void { $this->user = $this->createMock(UserInterface::class); - $this->user->expects($this->any())->method('getPassword')->willReturn('old-encoded-password'); - $encoder = $this->createMock(PasswordEncoderInterface::class); + $this->user->expects($this->any())->method('getPassword')->willReturn('old-hash'); + $encoder = $this->createMock(PasswordHasherInterface::class); $encoder->expects($this->any())->method('needsRehash')->willReturn(true); - $encoder->expects($this->any())->method('encodePassword')->with('pa$$word', null)->willReturn('new-encoded-password'); - $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->user)->willReturn($encoder); - $this->listener = new PasswordMigratingListener($this->encoderFactory); + $encoder->expects($this->any())->method('hash')->with('pa$$word', null)->willReturn('new-hash'); + $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->user)->willReturn($encoder); + $this->listener = new PasswordMigratingListener($this->hasherFactory); } /** @@ -51,7 +51,7 @@ protected function setUp(): void */ public function testUnsupportedEvents($event) { - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); $this->listener->onLoginSuccess($event); } @@ -87,7 +87,7 @@ public function testUpgradeWithUpgrader() $passwordUpgrader = $this->createPasswordUpgrader(); $passwordUpgrader->expects($this->once()) ->method('upgradePassword') - ->with($this->user, 'new-encoded-password') + ->with($this->user, 'new-hash') ; $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', function () { return $this->user; }), [new PasswordUpgradeBadge('pa$$word', $passwordUpgrader)])); @@ -101,7 +101,7 @@ public function testUpgradeWithoutUpgrader() $userLoader->expects($this->once()) ->method('upgradePassword') - ->with($this->user, 'new-encoded-password') + ->with($this->user, 'new-hash') ; $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', [$userLoader, 'loadUserByUsername']), [new PasswordUpgradeBadge('pa$$word')])); diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php index c2980c293ab0a..07ce14759f828 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php @@ -16,6 +16,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -375,13 +376,17 @@ public function testSessionIsNotReported() protected function runSessionOnKernelResponse($newToken, $original = null) { $session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($session); + $requestStack = new RequestStack(); + $requestStack->push($request); if (null !== $original) { $session->set('_security_session', $original); } - $tokenStorage = new UsageTrackingTokenStorage(new TokenStorage(), new class(['session' => function () use ($session) { - return $session; + $tokenStorage = new UsageTrackingTokenStorage(new TokenStorage(), new class(['request_stack' => function () use ($requestStack) { + return $requestStack; }, ]) implements ContainerInterface { use ServiceLocatorTrait; @@ -389,8 +394,6 @@ protected function runSessionOnKernelResponse($newToken, $original = null) $tokenStorage->setToken($newToken); - $request = new Request(); - $request->setSession($session); $request->cookies->set('MOCKSESSID', true); $sessionId = $session->getId(); @@ -424,13 +427,22 @@ private function handleEventWithPreviousSession($userProviders, UserInterface $u $request = new Request(); $request->setSession($session); $request->cookies->set('MOCKSESSID', true); + $requestStack = new RequestStack(); + $requestStack->push($request); $tokenStorage = new TokenStorage(); $usageIndex = $session->getUsageIndex(); - $tokenStorage = new UsageTrackingTokenStorage($tokenStorage, new class(['session' => function () use ($session) { - return $session; - }, - ]) implements ContainerInterface { + $tokenStorage = new UsageTrackingTokenStorage($tokenStorage, new class( + (new \ReflectionClass(UsageTrackingTokenStorage::class))->hasMethod('getSession') ? [ + 'request_stack' => function () use ($requestStack) { + return $requestStack; + }] : [ + // BC for symfony/framework-bundle < 5.3 + 'session' => function () use ($session) { + return $session; + }, + ] + ) implements ContainerInterface { use ServiceLocatorTrait; }); $sessionTrackerEnabler = [$tokenStorage, 'enableUsageTracking']; diff --git a/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php index 13f2f3350745a..6942745e882a9 100644 --- a/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; @@ -23,13 +24,23 @@ class CsrfTokenClearingLogoutHandlerTest extends TestCase { private $session; + private $requestStack; private $csrfTokenStorage; private $csrfTokenClearingLogoutHandler; protected function setUp(): void { $this->session = new Session(new MockArraySessionStorage()); - $this->csrfTokenStorage = new SessionTokenStorage($this->session, 'foo'); + + // BC for symfony/security-core < 5.3 + if (\method_exists(SessionTokenStorage::class, 'getSession')) { + $request = new Request(); + $request->setSession($this->session); + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + } + + $this->csrfTokenStorage = new SessionTokenStorage($this->requestStack ?? $this->session, 'foo'); $this->csrfTokenStorage->setToken('foo', 'bar'); $this->csrfTokenStorage->setToken('foobar', 'baz'); $this->csrfTokenClearingLogoutHandler = new CsrfTokenClearingLogoutHandler($this->csrfTokenStorage); @@ -51,7 +62,7 @@ public function testCsrfTokenCookieWithSameNamespaceIsRemoved() public function testCsrfTokenCookieWithDifferentNamespaceIsNotRemoved() { - $barNamespaceCsrfSessionStorage = new SessionTokenStorage($this->session, 'bar'); + $barNamespaceCsrfSessionStorage = new SessionTokenStorage($this->requestStack ?? $this->session, 'bar'); $barNamespaceCsrfSessionStorage->setToken('foo', 'bar'); $barNamespaceCsrfSessionStorage->setToken('foobar', 'baz'); diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index f4d31854a87fb..d953ac23a63a3 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -18,9 +18,9 @@ "require": { "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1", - "symfony/security-core": "^5.2", + "symfony/security-core": "^5.3", "symfony/http-foundation": "^5.2", - "symfony/http-kernel": "^5.2", + "symfony/http-kernel": "^5.3", "symfony/polyfill-php80": "^1.15", "symfony/property-access": "^4.4|^5.0" }, diff --git a/src/Symfony/Component/Semaphore/CHANGELOG.md b/src/Symfony/Component/Semaphore/CHANGELOG.md index 37cc739197900..f53fc9dc2a2cf 100644 --- a/src/Symfony/Component/Semaphore/CHANGELOG.md +++ b/src/Symfony/Component/Semaphore/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * The component is not marked as `@experimental` anymore + 5.2.0 ----- diff --git a/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php b/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php index 616e0208cb8d1..fc1cfa29caf9d 100644 --- a/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php +++ b/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php @@ -14,8 +14,6 @@ /** * Base ExceptionInterface for the Semaphore Component. * - * @experimental in 5.2 - * * @author Jérémy Derussé */ interface ExceptionInterface extends \Throwable diff --git a/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php b/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php index 1eca46a4e3fd0..4d73171928347 100644 --- a/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php +++ b/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php @@ -12,8 +12,6 @@ namespace Symfony\Component\Semaphore\Exception; /** - * @experimental in 5.2 - * * @author Jérémy Derussé */ class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface diff --git a/src/Symfony/Component/Semaphore/Exception/RuntimeException.php b/src/Symfony/Component/Semaphore/Exception/RuntimeException.php index 5119ae68db185..96db2987422e5 100644 --- a/src/Symfony/Component/Semaphore/Exception/RuntimeException.php +++ b/src/Symfony/Component/Semaphore/Exception/RuntimeException.php @@ -12,8 +12,6 @@ namespace Symfony\Component\Semaphore\Exception; /** - * @experimental in 5.2 - * * @author Grégoire Pineau */ class RuntimeException extends \RuntimeException implements ExceptionInterface diff --git a/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php b/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php index d54c1af1ceea1..62596a762dc59 100644 --- a/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php +++ b/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php @@ -16,8 +16,6 @@ /** * SemaphoreAcquiringException is thrown when an issue happens during the acquisition of a semaphore. * - * @experimental in 5.2 - * * @author Jérémy Derussé * @author Grégoire Pineau */ diff --git a/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php b/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php index 695df079bf90b..12ddeaef5947c 100644 --- a/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php +++ b/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php @@ -16,8 +16,6 @@ /** * SemaphoreExpiredException is thrown when a semaphore may conflict due to a TTL expiration. * - * @experimental in 5.2 - * * @author Jérémy Derussé * @author Grégoire Pineau */ diff --git a/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php b/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php index a9979815d5f7c..716838def7c9d 100644 --- a/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php +++ b/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php @@ -16,8 +16,6 @@ /** * SemaphoreReleasingException is thrown when an issue happens during the release of a semaphore. * - * @experimental in 5.2 - * * @author Jérémy Derussé * @author Grégoire Pineau */ diff --git a/src/Symfony/Component/Semaphore/Key.php b/src/Symfony/Component/Semaphore/Key.php index b2ba8db50acea..7fbf632f5bb8d 100644 --- a/src/Symfony/Component/Semaphore/Key.php +++ b/src/Symfony/Component/Semaphore/Key.php @@ -16,8 +16,6 @@ /** * Key is a container for the state of the semaphores in stores. * - * @experimental in 5.2 - * * @author Grégoire Pineau * @author Jérémy Derussé */ diff --git a/src/Symfony/Component/Semaphore/PersistingStoreInterface.php b/src/Symfony/Component/Semaphore/PersistingStoreInterface.php index df322d1355dbe..11e2789a2843f 100644 --- a/src/Symfony/Component/Semaphore/PersistingStoreInterface.php +++ b/src/Symfony/Component/Semaphore/PersistingStoreInterface.php @@ -16,8 +16,6 @@ use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; /** - * @experimental in 5.2 - * * @author Grégoire Pineau * @author Jérémy Derussé */ diff --git a/src/Symfony/Component/Semaphore/README.md b/src/Symfony/Component/Semaphore/README.md index 4a56641ae26b9..0b72c811b44e4 100644 --- a/src/Symfony/Component/Semaphore/README.md +++ b/src/Symfony/Component/Semaphore/README.md @@ -5,15 +5,10 @@ The Semaphore Component manages [semaphores](https://en.wikipedia.org/wiki/Semaphore_(programming)), a mechanism to provide exclusive access to a shared resource. -**This Component is experimental**. -[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) -are not covered by Symfony's -[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). - Resources --------- - * [Documentation](https://symfony.com/doc/master/components/semaphore.html) + * [Documentation](https://symfony.com/doc/current/components/semaphore.html) * [Contributing](https://symfony.com/doc/current/contributing/index.html) * [Report issues](https://github.com/symfony/symfony/issues) and [send Pull Requests](https://github.com/symfony/symfony/pulls) diff --git a/src/Symfony/Component/Semaphore/Semaphore.php b/src/Symfony/Component/Semaphore/Semaphore.php index 572b5f16cecb7..b79dad7d1cab2 100644 --- a/src/Symfony/Component/Semaphore/Semaphore.php +++ b/src/Symfony/Component/Semaphore/Semaphore.php @@ -23,8 +23,6 @@ /** * Semaphore is the default implementation of the SemaphoreInterface. * - * @experimental in 5.2 - * * @author Grégoire Pineau * @author Jérémy Derussé */ diff --git a/src/Symfony/Component/Semaphore/SemaphoreFactory.php b/src/Symfony/Component/Semaphore/SemaphoreFactory.php index 9194817f096e3..7daebce7701de 100644 --- a/src/Symfony/Component/Semaphore/SemaphoreFactory.php +++ b/src/Symfony/Component/Semaphore/SemaphoreFactory.php @@ -18,8 +18,6 @@ /** * Factory provides method to create semaphores. * - * @experimental in 5.2 - * * @author Grégoire Pineau * @author Jérémy Derussé * @author Hamza Amrouche diff --git a/src/Symfony/Component/Semaphore/SemaphoreInterface.php b/src/Symfony/Component/Semaphore/SemaphoreInterface.php index cbc1f36db4cef..6897498145bec 100644 --- a/src/Symfony/Component/Semaphore/SemaphoreInterface.php +++ b/src/Symfony/Component/Semaphore/SemaphoreInterface.php @@ -17,8 +17,6 @@ /** * SemaphoreInterface defines an interface to manipulate the status of a semaphore. * - * @experimental in 5.2 - * * @author Jérémy Derussé * @author Grégoire Pineau */ diff --git a/src/Symfony/Component/Semaphore/Store/RedisStore.php b/src/Symfony/Component/Semaphore/Store/RedisStore.php index 0aae297715074..a29addf0d94a1 100644 --- a/src/Symfony/Component/Semaphore/Store/RedisStore.php +++ b/src/Symfony/Component/Semaphore/Store/RedisStore.php @@ -22,8 +22,6 @@ /** * RedisStore is a PersistingStoreInterface implementation using Redis as store engine. * - * @experimental in 5.2 - * * @author Grégoire Pineau * @author Jérémy Derussé */ diff --git a/src/Symfony/Component/Semaphore/Store/StoreFactory.php b/src/Symfony/Component/Semaphore/Store/StoreFactory.php index c42eda627e137..d19315061e010 100644 --- a/src/Symfony/Component/Semaphore/Store/StoreFactory.php +++ b/src/Symfony/Component/Semaphore/Store/StoreFactory.php @@ -21,8 +21,6 @@ /** * StoreFactory create stores and connections. * - * @experimental in 5.2 - * * @author Jérémy Derussé * @author Jérémy Derussé */ diff --git a/src/Symfony/Component/Serializer/Annotation/Context.php b/src/Symfony/Component/Serializer/Annotation/Context.php new file mode 100644 index 0000000000000..08e1f7cf69a5c --- /dev/null +++ b/src/Symfony/Component/Serializer/Annotation/Context.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Annotation; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * Annotation class for @Context(). + * + * @Annotation + * @Target({"PROPERTY", "METHOD"}) + * + * @author Maxime Steinhausser + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class Context +{ + private $context; + private $normalizationContext; + private $denormalizationContext; + private $groups; + + /** + * @throws InvalidArgumentException + */ + public function __construct(array $options = [], array $context = [], array $normalizationContext = [], array $denormalizationContext = [], array $groups = []) + { + if (!$context) { + if (!array_intersect((array_keys($options)), ['normalizationContext', 'groups', 'context', 'value', 'denormalizationContext'])) { + // gracefully supports context as first, unnamed attribute argument if it cannot be confused with Doctrine-style options + $context = $options; + } else { + // If at least one of the options match, it's likely to be Doctrine-style options. Search for the context inside: + $context = $options['value'] ?? $options['context'] ?? []; + } + } + + $normalizationContext = $options['normalizationContext'] ?? $normalizationContext; + $denormalizationContext = $options['denormalizationContext'] ?? $denormalizationContext; + + foreach (compact(['context', 'normalizationContext', 'denormalizationContext']) as $key => $value) { + if (!\is_array($value)) { + throw new InvalidArgumentException(sprintf('Option "%s" of annotation "%s" must be an array.', $key, static::class)); + } + } + + if (!$context && !$normalizationContext && !$denormalizationContext) { + throw new InvalidArgumentException(sprintf('At least one of the "context", "normalizationContext", or "denormalizationContext" options of annotation "%s" must be provided as a non-empty array.', static::class)); + } + + $groups = (array) ($options['groups'] ?? $groups); + + foreach ($groups as $group) { + if (!\is_string($group)) { + throw new InvalidArgumentException(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "%s".', static::class, get_debug_type($group))); + } + } + + $this->context = $context; + $this->normalizationContext = $normalizationContext; + $this->denormalizationContext = $denormalizationContext; + $this->groups = $groups; + } + + public function getContext(): array + { + return $this->context; + } + + public function getNormalizationContext(): array + { + return $this->normalizationContext; + } + + public function getDenormalizationContext(): array + { + return $this->denormalizationContext; + } + + public function getGroups(): array + { + return $this->groups; + } +} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 97ae3fd62fdab..dde0f2cf21bfe 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +5.3 +--- + + * Add the ability to provide (de)normalization context using metadata (e.g. `@Symfony\Component\Serializer\Annotation\Context`) + * deprecated `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead. + * added normalization formats to `UidNormalizer` + 5.2.0 ----- diff --git a/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php b/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php index 732e0bd5908cc..36d1e92b66f39 100644 --- a/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php +++ b/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php @@ -59,6 +59,24 @@ class AttributeMetadata implements AttributeMetadataInterface */ public $ignore = false; + /** + * @var array[] Normalization contexts per group name ("*" applies to all groups) + * + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link getNormalizationContexts()} instead. + */ + public $normalizationContexts = []; + + /** + * @var array[] Denormalization contexts per group name ("*" applies to all groups) + * + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link getDenormalizationContexts()} instead. + */ + public $denormalizationContexts = []; + public function __construct(string $name) { $this->name = $name; @@ -138,6 +156,76 @@ public function isIgnored(): bool return $this->ignore; } + /** + * {@inheritdoc} + */ + public function getNormalizationContexts(): array + { + return $this->normalizationContexts; + } + + /** + * {@inheritdoc} + */ + public function getNormalizationContextForGroups(array $groups): array + { + $contexts = []; + foreach ($groups as $group) { + $contexts[] = $this->normalizationContexts[$group] ?? []; + } + + return array_merge($this->normalizationContexts['*'] ?? [], ...$contexts); + } + + /** + * {@inheritdoc} + */ + public function setNormalizationContextForGroups(array $context, array $groups = []): void + { + if (!$groups) { + $this->normalizationContexts['*'] = $context; + } + + foreach ($groups as $group) { + $this->normalizationContexts[$group] = $context; + } + } + + /** + * {@inheritdoc} + */ + public function getDenormalizationContexts(): array + { + return $this->denormalizationContexts; + } + + /** + * {@inheritdoc} + */ + public function getDenormalizationContextForGroups(array $groups): array + { + $contexts = []; + foreach ($groups as $group) { + $contexts[] = $this->denormalizationContexts[$group] ?? []; + } + + return array_merge($this->denormalizationContexts['*'] ?? [], ...$contexts); + } + + /** + * {@inheritdoc} + */ + public function setDenormalizationContextForGroups(array $context, array $groups = []): void + { + if (!$groups) { + $this->denormalizationContexts['*'] = $context; + } + + foreach ($groups as $group) { + $this->denormalizationContexts[$group] = $context; + } + } + /** * {@inheritdoc} */ @@ -157,6 +245,12 @@ public function merge(AttributeMetadataInterface $attributeMetadata) $this->serializedName = $attributeMetadata->getSerializedName(); } + // Overwrite only if both contexts are empty + if (!$this->normalizationContexts && !$this->denormalizationContexts) { + $this->normalizationContexts = $attributeMetadata->getNormalizationContexts(); + $this->denormalizationContexts = $attributeMetadata->getDenormalizationContexts(); + } + if ($ignore = $attributeMetadata->isIgnored()) { $this->ignore = $ignore; } @@ -169,6 +263,6 @@ public function merge(AttributeMetadataInterface $attributeMetadata) */ public function __sleep() { - return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore']; + return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore', 'normalizationContexts', 'denormalizationContexts']; } } diff --git a/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php b/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php index 9e78cf0d31743..9e5a1ae2d1797 100644 --- a/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php +++ b/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php @@ -75,4 +75,34 @@ public function isIgnored(): bool; * Merges an {@see AttributeMetadataInterface} with in the current one. */ public function merge(self $attributeMetadata); + + /** + * Gets all the normalization contexts per group ("*" being the base context applied to all groups). + */ + public function getNormalizationContexts(): array; + + /** + * Gets the computed normalization contexts for given groups. + */ + public function getNormalizationContextForGroups(array $groups): array; + + /** + * Sets the normalization context for given groups. + */ + public function setNormalizationContextForGroups(array $context, array $groups = []): void; + + /** + * Gets all the denormalization contexts per group ("*" being the base context applied to all groups). + */ + public function getDenormalizationContexts(): array; + + /** + * Gets the computed denormalization contexts for given groups. + */ + public function getDenormalizationContextForGroups(array $groups): array; + + /** + * Sets the denormalization context for given groups. + */ + public function setDenormalizationContextForGroups(array $context, array $groups = []): void; } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php index 5b0fec2f62be1..bd0f049c729f1 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Mapping\Loader; use Doctrine\Common\Annotations\Reader; +use Symfony\Component\Serializer\Annotation\Context; use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Ignore; @@ -19,6 +20,7 @@ use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; +use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; @@ -36,6 +38,7 @@ class AnnotationLoader implements LoaderInterface Ignore::class => true, MaxDepth::class => true, SerializedName::class => true, + Context::class => true, ]; private $reader; @@ -83,6 +86,8 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) $attributesMetadata[$property->name]->setSerializedName($annotation->getSerializedName()); } elseif ($annotation instanceof Ignore) { $attributesMetadata[$property->name]->setIgnore(true); + } elseif ($annotation instanceof Context) { + $this->setAttributeContextsForGroups($annotation, $attributesMetadata[$property->name]); } $loaded = true; @@ -130,6 +135,12 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) $attributeMetadata->setSerializedName($annotation->getSerializedName()); } elseif ($annotation instanceof Ignore) { $attributeMetadata->setIgnore(true); + } elseif ($annotation instanceof Context) { + if (!$accessorOrMutator) { + throw new MappingException(sprintf('Context on "%s::%s()" cannot be added. Context can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + $this->setAttributeContextsForGroups($annotation, $attributeMetadata); } $loaded = true; @@ -166,4 +177,20 @@ public function loadAnnotations(object $reflector): iterable yield from $this->reader->getPropertyAnnotations($reflector); } } + + private function setAttributeContextsForGroups(Context $annotation, AttributeMetadataInterface $attributeMetadata): void + { + if ($annotation->getContext()) { + $attributeMetadata->setNormalizationContextForGroups($annotation->getContext(), $annotation->getGroups()); + $attributeMetadata->setDenormalizationContextForGroups($annotation->getContext(), $annotation->getGroups()); + } + + if ($annotation->getNormalizationContext()) { + $attributeMetadata->setNormalizationContextForGroups($annotation->getNormalizationContext(), $annotation->getGroups()); + } + + if ($annotation->getDenormalizationContext()) { + $attributeMetadata->setDenormalizationContextForGroups($annotation->getDenormalizationContext(), $annotation->getGroups()); + } + } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php index 843ec86e83886..aea9732e5dc61 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php @@ -74,6 +74,25 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) if (isset($attribute['ignore'])) { $attributeMetadata->setIgnore((bool) $attribute['ignore']); } + + foreach ($attribute->context as $node) { + $groups = (array) $node->group; + $context = $this->parseContext($node->entry); + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } + + foreach ($attribute->normalization_context as $node) { + $groups = (array) $node->group; + $context = $this->parseContext($node->entry); + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + } + + foreach ($attribute->denormalization_context as $node) { + $groups = (array) $node->group; + $context = $this->parseContext($node->entry); + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } } if (isset($xml->{'discriminator-map'})) { @@ -136,4 +155,29 @@ private function getClassesFromXml(): array return $classes; } + + private function parseContext(\SimpleXMLElement $nodes): array + { + $context = []; + + foreach ($nodes as $node) { + if (\count($node) > 0) { + if (\count($node->entry) > 0) { + $value = $this->parseContext($node->entry); + } else { + $value = []; + } + } else { + $value = XmlUtils::phpize($node); + } + + if (isset($node['name'])) { + $context[(string) $node['name']] = $value; + } else { + $context[] = $value; + } + } + + return $context; + } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php index ff50e622eeadf..5975fb334d207 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php @@ -101,6 +101,23 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) $attributeMetadata->setIgnore($data['ignore']); } + + foreach ($data['contexts'] ?? [] as $line) { + $groups = $line['groups'] ?? []; + + if ($context = $line['context'] ?? false) { + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } + + if ($context = $line['normalization_context'] ?? false) { + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + } + + if ($context = $line['denormalization_context'] ?? false) { + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } + } } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd b/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd index b427a36e368c1..0228e41ce10d3 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd +++ b/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd @@ -60,9 +60,12 @@ Contains serialization groups and max depth for attributes. The name of the attribute should be given in the "name" option. ]]> - + - + + + + @@ -81,4 +84,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 4a03ab851a3a2..9e64e607f6daa 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -237,8 +237,7 @@ protected function getAllowedAttributes($classOrObject, array $context, bool $at return false; } - $tmpGroups = $context[self::GROUPS] ?? $this->defaultContext[self::GROUPS] ?? null; - $groups = (\is_array($tmpGroups) || is_scalar($tmpGroups)) ? (array) $tmpGroups : false; + $groups = $this->getGroups($context); $allowedAttributes = []; $ignoreUsed = false; @@ -250,14 +249,14 @@ protected function getAllowedAttributes($classOrObject, array $context, bool $at // If you update this check, update accordingly the one in Symfony\Component\PropertyInfo\Extractor\SerializerExtractor::getProperties() if ( !$ignore && - (false === $groups || array_intersect(array_merge($attributeMetadata->getGroups(), ['*']), $groups)) && + ([] === $groups || array_intersect(array_merge($attributeMetadata->getGroups(), ['*']), $groups)) && $this->isAllowedAttribute($classOrObject, $name = $attributeMetadata->getName(), null, $context) ) { $allowedAttributes[] = $attributesAsString ? $name : $attributeMetadata; } } - if (!$ignoreUsed && false === $groups && $allowExtraAttributes) { + if (!$ignoreUsed && [] === $groups && $allowExtraAttributes) { // Backward Compatibility with the code using this method written before the introduction of @Ignore return false; } @@ -265,6 +264,13 @@ protected function getAllowedAttributes($classOrObject, array $context, bool $at return $allowedAttributes; } + protected function getGroups(array $context): array + { + $groups = $context[self::GROUPS] ?? $this->defaultContext[self::GROUPS] ?? []; + + return is_scalar($groups) ? (array) $groups : $groups; + } + /** * Is this attribute allowed? * diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index aa1be48cfbaf5..6a151c31b7647 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -175,9 +175,10 @@ public function normalize($object, string $format = null, array $context = []) continue; } - $attributeValue = $this->getAttributeValue($object, $attribute, $format, $context); + $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); + $attributeValue = $this->getAttributeValue($object, $attribute, $format, $attributeContext); if ($maxDepthReached) { - $attributeValue = $maxDepthHandler($attributeValue, $object, $attribute, $format, $context); + $attributeValue = $maxDepthHandler($attributeValue, $object, $attribute, $format, $attributeContext); } /** @@ -185,14 +186,14 @@ public function normalize($object, string $format = null, array $context = []) */ $callback = $context[self::CALLBACKS][$attribute] ?? $this->defaultContext[self::CALLBACKS][$attribute] ?? null; if ($callback) { - $attributeValue = $callback($attributeValue, $object, $attribute, $format, $context); + $attributeValue = $callback($attributeValue, $object, $attribute, $format, $attributeContext); } if (null !== $attributeValue && !is_scalar($attributeValue)) { $stack[$attribute] = $attributeValue; } - $data = $this->updateData($data, $attribute, $attributeValue, $class, $format, $context); + $data = $this->updateData($data, $attribute, $attributeValue, $class, $format, $attributeContext); } foreach ($stack as $attribute => $attributeValue) { @@ -200,7 +201,10 @@ public function normalize($object, string $format = null, array $context = []) throw new LogicException(sprintf('Cannot normalize attribute "%s" because the injected serializer is not a normalizer.', $attribute)); } - $data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $this->createChildContext($context, $attribute, $format)), $class, $format, $context); + $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); + $childContext = $this->createChildContext($attributeContext, $attribute, $format); + + $data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $childContext), $class, $format, $attributeContext); } if (isset($context[self::PRESERVE_EMPTY_OBJECTS]) && !\count($data)) { @@ -210,6 +214,39 @@ public function normalize($object, string $format = null, array $context = []) return $data; } + /** + * Computes the normalization context merged with current one. Metadata always wins over global context, as more specific. + */ + private function getAttributeNormalizationContext($object, string $attribute, array $context): array + { + if (null === $metadata = $this->getAttributeMetadata($object, $attribute)) { + return $context; + } + + return array_merge($context, $metadata->getNormalizationContextForGroups($this->getGroups($context))); + } + + /** + * Computes the denormalization context merged with current one. Metadata always wins over global context, as more specific. + */ + private function getAttributeDenormalizationContext(string $class, string $attribute, array $context): array + { + if (null === $metadata = $this->getAttributeMetadata($class, $attribute)) { + return $context; + } + + return array_merge($context, $metadata->getDenormalizationContextForGroups($this->getGroups($context))); + } + + private function getAttributeMetadata($objectOrClass, string $attribute): ?AttributeMetadataInterface + { + if (!$this->classMetadataFactory) { + return null; + } + + return $this->classMetadataFactory->getMetadataFor($objectOrClass)->getAttributesMetadata()[$attribute] ?? null; + } + /** * {@inheritdoc} */ @@ -312,8 +349,10 @@ public function denormalize($data, string $type, string $format = null, array $c $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); foreach ($normalizedData as $attribute => $value) { + $attributeContext = $this->getAttributeDenormalizationContext($resolvedClass, $attribute, $context); + if ($this->nameConverter) { - $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $context); + $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $attributeContext); } if ((false !== $allowedAttributes && !\in_array($attribute, $allowedAttributes)) || !$this->isAllowedAttribute($resolvedClass, $attribute, $format, $context)) { @@ -324,16 +363,16 @@ public function denormalize($data, string $type, string $format = null, array $c continue; } - if ($context[self::DEEP_OBJECT_TO_POPULATE] ?? $this->defaultContext[self::DEEP_OBJECT_TO_POPULATE] ?? false) { + if ($attributeContext[self::DEEP_OBJECT_TO_POPULATE] ?? $this->defaultContext[self::DEEP_OBJECT_TO_POPULATE] ?? false) { try { - $context[self::OBJECT_TO_POPULATE] = $this->getAttributeValue($object, $attribute, $format, $context); + $attributeContext[self::OBJECT_TO_POPULATE] = $this->getAttributeValue($object, $attribute, $format, $attributeContext); } catch (NoSuchPropertyException $e) { } } - $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context); + $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $attributeContext); try { - $this->setAttributeValue($object, $attribute, $value, $format, $context); + $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); } catch (InvalidArgumentException $e) { throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e); } @@ -373,7 +412,7 @@ private function validateAndDenormalize(string $currentClass, string $attribute, return null; } - $collectionValueType = $type->isCollection() ? $type->getCollectionValueType() : null; + $collectionValueType = $type->isCollection() ? $type->getCollectionValueTypes()[0] ?? null : null; // Fix a collection that contains the only one element // This is special to xml format only @@ -431,18 +470,18 @@ private function validateAndDenormalize(string $currentClass, string $attribute, $builtinType = Type::BUILTIN_TYPE_OBJECT; $class = $collectionValueType->getClassName().'[]'; - if (null !== $collectionKeyType = $type->getCollectionKeyType()) { - $context['key_type'] = $collectionKeyType; + if (null !== $collectionKeyType = $type->getCollectionKeyTypes()) { + [$context['key_type']] = $collectionKeyType; } - } elseif ($type->isCollection() && null !== ($collectionValueType = $type->getCollectionValueType()) && Type::BUILTIN_TYPE_ARRAY === $collectionValueType->getBuiltinType()) { + } elseif ($type->isCollection() && null !== ($collectionValueType = $type->getCollectionValueTypes()) && \count($collectionValueType) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) { // get inner type for any nested array - $innerType = $collectionValueType; + [$innerType] = $collectionValueType; // note that it will break for any other builtinType $dimensions = '[]'; - while (null !== $innerType->getCollectionValueType() && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { + while (null !== $innerType->getCollectionValueTypes() && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { $dimensions .= '[]'; - $innerType = $innerType->getCollectionValueType(); + [$innerType] = $innerType->getCollectionValueTypes(); } if (null !== $innerType->getClassName()) { diff --git a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php index 854400b538e0d..77c746c752282 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php @@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Exception\BadMethodCallException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -24,24 +25,19 @@ * * @final */ -class ArrayDenormalizer implements ContextAwareDenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +class ArrayDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface, SerializerAwareInterface, CacheableSupportsMethodInterface { - /** - * @var SerializerInterface|DenormalizerInterface - */ - private $serializer; + use DenormalizerAwareTrait; /** * {@inheritdoc} * * @throws NotNormalizableValueException - * - * @return array */ - public function denormalize($data, string $type, string $format = null, array $context = []) + public function denormalize($data, string $type, string $format = null, array $context = []): array { - if (null === $this->serializer) { - throw new BadMethodCallException('Please set a serializer before calling denormalize()!'); + if (null === $this->denormalizer) { + throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!'); } if (!\is_array($data)) { throw new InvalidArgumentException('Data expected to be an array, '.get_debug_type($data).' given.'); @@ -50,7 +46,6 @@ public function denormalize($data, string $type, string $format = null, array $c throw new InvalidArgumentException('Unsupported class: '.$type); } - $serializer = $this->serializer; $type = substr($type, 0, -2); $builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null; @@ -59,7 +54,7 @@ public function denormalize($data, string $type, string $format = null, array $c throw new NotNormalizableValueException(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key))); } - $data[$key] = $serializer->denormalize($value, $type, $format, $context); + $data[$key] = $this->denormalizer->denormalize($value, $type, $format, $context); } return $data; @@ -70,16 +65,18 @@ public function denormalize($data, string $type, string $format = null, array $c */ public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool { - if (null === $this->serializer) { - throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used.', __METHOD__)); + if (null === $this->denormalizer) { + throw new BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__)); } return '[]' === substr($type, -2) - && $this->serializer->supportsDenormalization($data, substr($type, 0, -2), $format, $context); + && $this->denormalizer->supportsDenormalization($data, substr($type, 0, -2), $format, $context); } /** * {@inheritdoc} + * + * @deprecated call setDenormalizer() instead */ public function setSerializer(SerializerInterface $serializer) { @@ -87,7 +84,11 @@ public function setSerializer(SerializerInterface $serializer) throw new InvalidArgumentException('Expected a serializer that also implements DenormalizerInterface.'); } - $this->serializer = $serializer; + if (Serializer::class !== debug_backtrace()[1]['class'] ?? null) { + trigger_deprecation('symfony/serializer', '5.3', 'Calling "%s" is deprecated. Please call setDenormalizer() instead.'); + } + + $this->setDenormalizer($serializer); } /** @@ -95,6 +96,6 @@ public function setSerializer(SerializerInterface $serializer) */ public function hasCacheableSupportsMethod(): bool { - return $this->serializer instanceof CacheableSupportsMethodInterface && $this->serializer->hasCacheableSupportsMethod(); + return $this->denormalizer instanceof CacheableSupportsMethodInterface && $this->denormalizer->hasCacheableSupportsMethod(); } } diff --git a/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php index 22b563adc5804..009d334895ee8 100644 --- a/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Uid\Ulid; @@ -18,12 +19,41 @@ final class UidNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface { + public const NORMALIZATION_FORMAT_KEY = 'uid_normalization_format'; + + public const NORMALIZATION_FORMAT_CANONICAL = 'canonical'; + public const NORMALIZATION_FORMAT_BASE58 = 'base58'; + public const NORMALIZATION_FORMAT_BASE32 = 'base32'; + public const NORMALIZATION_FORMAT_RFC4122 = 'rfc4122'; + + private $defaultContext = [ + self::NORMALIZATION_FORMAT_KEY => self::NORMALIZATION_FORMAT_CANONICAL, + ]; + + public function __construct(array $defaultContext = []) + { + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + } + /** * {@inheritdoc} + * + * @param AbstractUid $object */ public function normalize($object, string $format = null, array $context = []) { - return (string) $object; + switch ($context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY]) { + case self::NORMALIZATION_FORMAT_CANONICAL: + return (string) $object; + case self::NORMALIZATION_FORMAT_BASE58: + return $object->toBase58(); + case self::NORMALIZATION_FORMAT_BASE32: + return $object->toBase32(); + case self::NORMALIZATION_FORMAT_RFC4122: + return $object->toRfc4122(); + } + + throw new LogicException(sprintf('The "%s" format is not valid.', $context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY])); } /** diff --git a/src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php b/src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php new file mode 100644 index 0000000000000..a79178f1ba95d --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Annotation; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\VarDumper\Dumper\CliDumper; +use Symfony\Component\VarDumper\Test\VarDumperTestTrait; + +/** + * @author Maxime Steinhausser + */ +class ContextTest extends TestCase +{ + use VarDumperTestTrait; + + protected function setUp(): void + { + $this->setUpVarDumper([], CliDumper::DUMP_LIGHT_ARRAY | CliDumper::DUMP_TRAILING_COMMA); + } + + /** + * @dataProvider provideTestThrowsOnEmptyContextData + */ + public function testThrowsOnEmptyContext(callable $factory) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one of the "context", "normalizationContext", or "denormalizationContext" options of annotation "Symfony\Component\Serializer\Annotation\Context" must be provided as a non-empty array.'); + + $factory(); + } + + public function provideTestThrowsOnEmptyContextData(): iterable + { + yield 'constructor: empty args' => [function () { new Context([]); }]; + + yield 'doctrine-style: value option as empty array' => [function () { new Context(['value' => []]); }]; + yield 'doctrine-style: context option as empty array' => [function () { new Context(['context' => []]); }]; + yield 'doctrine-style: context option not provided' => [function () { new Context(['groups' => ['group_1']]); }]; + + if (\PHP_VERSION_ID >= 80000) { + yield 'named args: empty context' => [function () { + eval('return new Symfony\Component\Serializer\Annotation\Context(context: []);'); + }]; + } + } + + /** + * @dataProvider provideTestThrowsOnNonArrayContextData + */ + public function testThrowsOnNonArrayContext(array $options) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Option "%s" of annotation "%s" must be an array.', key($options), Context::class)); + + new Context($options); + } + + public function provideTestThrowsOnNonArrayContextData(): iterable + { + yield 'non-array context' => [['context' => 'not_an_array']]; + yield 'non-array normalization context' => [['normalizationContext' => 'not_an_array']]; + yield 'non-array denormalization context' => [['normalizationContext' => 'not_an_array']]; + } + + public function testInvalidGroupOption() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "stdClass"', Context::class)); + + new Context(['context' => ['foo' => 'bar'], 'groups' => ['fine', new \stdClass()]]); + } + + public function testInvalidGroupArgument() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "stdClass"', Context::class)); + + new Context([], ['foo' => 'bar'], [], [], ['fine', new \stdClass()]); + } + + public function testAsFirstArg() + { + $context = new Context(['foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $context->getContext()); + self::assertEmpty($context->getNormalizationContext()); + self::assertEmpty($context->getDenormalizationContext()); + self::assertEmpty($context->getGroups()); + } + + public function testAsContextArg() + { + $context = new Context([], ['foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $context->getContext()); + self::assertEmpty($context->getNormalizationContext()); + self::assertEmpty($context->getDenormalizationContext()); + self::assertEmpty($context->getGroups()); + } + + /** + * @dataProvider provideValidInputs + */ + public function testValidInputs(callable $factory, string $expectedDump) + { + self::assertDumpEquals($expectedDump, $factory()); + } + + public function provideValidInputs(): iterable + { + yield 'doctrine-style: with context option' => [ + function () { return new Context(['context' => ['foo' => 'bar']]); }, + $expected = << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] + -groups: [] +} +DUMP + ]; + + yield 'constructor: with context arg' => [ + function () { return new Context([], ['foo' => 'bar']); }, + $expected, + ]; + + yield 'doctrine-style: with normalization context option' => [ + function () { return new Context(['normalizationContext' => ['foo' => 'bar']]); }, + $expected = << "bar", + ] + -denormalizationContext: [] + -groups: [] +} +DUMP + ]; + + yield 'constructor: with normalization context arg' => [ + function () { return new Context([], [], ['foo' => 'bar']); }, + $expected, + ]; + + yield 'doctrine-style: with denormalization context option' => [ + function () { return new Context(['denormalizationContext' => ['foo' => 'bar']]); }, + $expected = << "bar", + ] + -groups: [] +} +DUMP + ]; + + yield 'constructor: with denormalization context arg' => [ + function () { return new Context([], [], [], ['foo' => 'bar']); }, + $expected, + ]; + + yield 'doctrine-style: with groups option as string' => [ + function () { return new Context(['context' => ['foo' => 'bar'], 'groups' => 'a']); }, + << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] + -groups: [ + "a", + ] +} +DUMP + ]; + + yield 'doctrine-style: with groups option as array' => [ + function () { return new Context(['context' => ['foo' => 'bar'], 'groups' => ['a', 'b']]); }, + $expected = << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] + -groups: [ + "a", + "b", + ] +} +DUMP + ]; + + yield 'constructor: with groups arg' => [ + function () { return new Context([], ['foo' => 'bar'], [], [], ['a', 'b']); }, + $expected, + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php new file mode 100644 index 0000000000000..77b3884de5cb1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class BadMethodContextDummy extends ContextDummyParent +{ + /** + * @Context({ "foo" = "bar" }) + */ + public function badMethod() + { + return 'bad_method'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php new file mode 100644 index 0000000000000..804df290f0295 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummy extends ContextDummyParent +{ + /** + * @Context({ "foo" = "value", "bar" = "value", "nested" = { + * "nested_key" = "nested_value", + * }, "array": { "first", "second" } }) + * @Context({ "bar" = "value_for_group_a" }, groups = "a") + */ + public $foo; + + /** + * @Context( + * normalizationContext = { "format" = "d/m/Y" }, + * denormalizationContext = { "format" = "m-d-Y H:i" }, + * groups = {"a", "b"} + * ) + */ + public $bar; + + /** + * @Context(normalizationContext={ "prop" = "dummy_value" }) + */ + public $overriddenParentProperty; + + /** + * @Context({ "method" = "method_with_context" }) + */ + public function getMethodWithContext() + { + return 'method_with_context'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.php new file mode 100644 index 0000000000000..b7b286c372fa3 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummyParent +{ + /** + * @Context(normalizationContext={ "prop" = "dummy_parent_value" }) + */ + public $parentProperty; + + /** + * @Context(normalizationContext={ "prop" = "dummy_parent_value" }) + */ + public $overriddenParentProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php new file mode 100644 index 0000000000000..5c6c82e653603 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class BadMethodContextDummy extends ContextDummyParent +{ + #[Context([ "foo" => "bar" ])] + public function badMethod() + { + return 'bad_method'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php new file mode 100644 index 0000000000000..447b80d6a951f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummy extends ContextDummyParent +{ + #[Context(['foo' => 'value', 'bar' => 'value', 'nested' => [ + 'nested_key' => 'nested_value' + ], 'array' => ['first', 'second']])] + #[Context(context: ['bar' => 'value_for_group_a'], groups: ['a'])] + public $foo; + + #[Context( + normalizationContext: ['format' => 'd/m/Y'], + denormalizationContext: ['format' => 'm-d-Y H:i'], + groups: ['a', 'b'], + )] + public $bar; + + #[Context(normalizationContext: ['prop' => 'dummy_value'])] + public $overriddenParentProperty; + + #[Context(['method' => 'method_with_context'])] + public function getMethodWithContext() + { + return 'method_with_context'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php new file mode 100644 index 0000000000000..9480c953e78c7 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummyParent +{ + #[Context(normalizationContext: ['prop' => 'dummy_parent_value'])] + public $parentProperty; + + #[Context(normalizationContext: ['prop' => 'dummy_parent_value'])] + public $overriddenParentProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml index 635253dd8e805..da61e0acce8dc 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml @@ -39,4 +39,59 @@ + + + + dummy_parent_value + + + + + dummy_parent_value + + + + + + + + value + value + + nested_value + + + first + second + + + + a + value_for_group_a + + + + + a + b + d/m/Y + + + a + b + m-d-Y H:i + + + + + dummy_value + + + + + method_with_context + + + + diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml index 5b212c8914aea..80100e8260622 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml @@ -30,3 +30,31 @@ ignore: true ignored2: ignore: true + +Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummyParent: + attributes: + parentProperty: + contexts: + - { normalization_context: { prop: dummy_parent_value } } + overriddenParentProperty: + contexts: + - { normalization_context: { prop: dummy_parent_value } } + +Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummy: + attributes: + foo: + contexts: + - context: { foo: value, bar: value, nested: { nested_key: nested_value }, array: [first, second] } + - context: { bar: value_for_group_a } + groups: [a] + bar: + contexts: + - normalization_context: { format: 'd/m/Y' } + denormalization_context: { format: 'm-d-Y H:i' } + groups: [a, b] + overriddenParentProperty: + contexts: + - normalization_context: { prop: dummy_value } + methodWithContext: + contexts: + - context: { method: method_with_context } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php index 6b8f5864f23c1..8fc4b8b49865c 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php @@ -66,6 +66,57 @@ public function testIgnore() $this->assertTrue($attributeMetadata->isIgnored()); } + public function testSetContexts() + { + $metadata = new AttributeMetadata('a1'); + $metadata->setNormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setNormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setNormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame([ + '*' => ['foo' => 'default', 'bar' => 'default'], + 'a' => ['foo' => 'overridden'], + 'b' => ['foo' => 'overridden'], + 'c' => ['bar' => 'overridden'], + ], $metadata->getNormalizationContexts()); + + $metadata->setDenormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setDenormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setDenormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame([ + '*' => ['foo' => 'default', 'bar' => 'default'], + 'a' => ['foo' => 'overridden'], + 'b' => ['foo' => 'overridden'], + 'c' => ['bar' => 'overridden'], + ], $metadata->getDenormalizationContexts()); + } + + public function testGetContextsForGroups() + { + $metadata = new AttributeMetadata('a1'); + + $metadata->setNormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setNormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setNormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame(['foo' => 'default', 'bar' => 'default'], $metadata->getNormalizationContextForGroups([])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getNormalizationContextForGroups(['a'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getNormalizationContextForGroups(['b'])); + self::assertSame(['foo' => 'default', 'bar' => 'overridden'], $metadata->getNormalizationContextForGroups(['c'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'overridden'], $metadata->getNormalizationContextForGroups(['b', 'c'])); + + $metadata->setDenormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setDenormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setDenormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame(['foo' => 'default', 'bar' => 'default'], $metadata->getDenormalizationContextForGroups([])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getDenormalizationContextForGroups(['a'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getDenormalizationContextForGroups(['b'])); + self::assertSame(['foo' => 'default', 'bar' => 'overridden'], $metadata->getDenormalizationContextForGroups(['c'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'overridden'], $metadata->getDenormalizationContextForGroups(['b', 'c'])); + } + public function testMerge() { $attributeMetadata1 = new AttributeMetadata('a1'); @@ -77,6 +128,8 @@ public function testMerge() $attributeMetadata2->addGroup('c'); $attributeMetadata2->setMaxDepth(2); $attributeMetadata2->setSerializedName('a3'); + $attributeMetadata2->setNormalizationContextForGroups(['foo' => 'bar'], ['a']); + $attributeMetadata2->setDenormalizationContextForGroups(['baz' => 'qux'], ['c']); $attributeMetadata2->setIgnore(true); @@ -85,9 +138,27 @@ public function testMerge() $this->assertEquals(['a', 'b', 'c'], $attributeMetadata1->getGroups()); $this->assertEquals(2, $attributeMetadata1->getMaxDepth()); $this->assertEquals('a3', $attributeMetadata1->getSerializedName()); + $this->assertSame(['a' => ['foo' => 'bar']], $attributeMetadata1->getNormalizationContexts()); + $this->assertSame(['c' => ['baz' => 'qux']], $attributeMetadata1->getDenormalizationContexts()); $this->assertTrue($attributeMetadata1->isIgnored()); } + public function testContextsNotMergedIfAlreadyDefined() + { + $attributeMetadata1 = new AttributeMetadata('a1'); + $attributeMetadata1->setNormalizationContextForGroups(['foo' => 'not overridden'], ['a']); + $attributeMetadata1->setDenormalizationContextForGroups(['baz' => 'not overridden'], ['b']); + + $attributeMetadata2 = new AttributeMetadata('a2'); + $attributeMetadata2->setNormalizationContextForGroups(['foo' => 'override'], ['a']); + $attributeMetadata2->setDenormalizationContextForGroups(['baz' => 'override'], ['b']); + + $attributeMetadata1->merge($attributeMetadata2); + + self::assertSame(['a' => ['foo' => 'not overridden']], $attributeMetadata1->getNormalizationContexts()); + self::assertSame(['b' => ['baz' => 'not overridden']], $attributeMetadata1->getDenormalizationContexts()); + } + public function testSerialize() { $attributeMetadata = new AttributeMetadata('attribute'); diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php index b3bbbf812ed0d..a135bfdaab16f 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php @@ -12,11 +12,13 @@ namespace Symfony\Component\Serializer\Tests\Mapping\Loader; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -24,6 +26,8 @@ */ abstract class AnnotationLoaderTest extends TestCase { + use ContextMappingTestTrait; + /** * @var AnnotationLoader */ @@ -114,7 +118,31 @@ public function testLoadIgnore() $this->assertTrue($attributesMetadata['ignored2']->isIgnored()); } + public function testLoadContexts() + { + $this->assertLoadedContexts($this->getNamespace().'\ContextDummy', $this->getNamespace().'\ContextDummyParent'); + } + + public function testThrowsOnContextOnInvalidMethod() + { + $class = $this->getNamespace().'\BadMethodContextDummy'; + + $this->expectException(MappingException::class); + $this->expectExceptionMessage(sprintf('Context on "%s::badMethod()" cannot be added', $class)); + + $loader = $this->getLoaderForContextMapping(); + + $classMetadata = new ClassMetadata($class); + + $loader->loadClassMetadata($classMetadata); + } + abstract protected function createLoader(): AnnotationLoader; abstract protected function getNamespace(): string; + + protected function getLoaderForContextMapping(): LoaderInterface + { + return $this->loader; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php new file mode 100644 index 0000000000000..97c70c1499134 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Mapping\Loader\Features; + +use PHPUnit\Framework\Assert; +use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummyParent; + +/** + * @author Maxime Steinhausser + */ +trait ContextMappingTestTrait +{ + abstract protected function getLoaderForContextMapping(): LoaderInterface; + + public function testLoadContexts() + { + $this->assertLoadedContexts(); + } + + public function assertLoadedContexts(string $dummyClass = ContextDummy::class, string $parentClass = ContextDummyParent::class) + { + $loader = $this->getLoaderForContextMapping(); + + $classMetadata = new ClassMetadata($dummyClass); + $parentClassMetadata = new ClassMetadata($parentClass); + + $loader->loadClassMetadata($parentClassMetadata); + $classMetadata->merge($parentClassMetadata); + + $loader->loadClassMetadata($classMetadata); + + $attributes = $classMetadata->getAttributesMetadata(); + + Assert::assertEquals(['*' => ['prop' => 'dummy_parent_value']], $attributes['parentProperty']->getNormalizationContexts()); + Assert::assertEquals(['*' => ['prop' => 'dummy_value']], $attributes['overriddenParentProperty']->getNormalizationContexts()); + + Assert::assertEquals([ + '*' => [ + 'foo' => 'value', + 'bar' => 'value', + 'nested' => ['nested_key' => 'nested_value'], + 'array' => ['first', 'second'], + ], + 'a' => ['bar' => 'value_for_group_a'], + ], $attributes['foo']->getNormalizationContexts()); + Assert::assertSame( + $attributes['foo']->getNormalizationContexts(), + $attributes['foo']->getDenormalizationContexts() + ); + + Assert::assertEquals([ + 'a' => $c = ['format' => 'd/m/Y'], + 'b' => $c, + ], $attributes['bar']->getNormalizationContexts()); + Assert::assertEquals([ + 'a' => $c = ['format' => 'm-d-Y H:i'], + 'b' => $c, + ], $attributes['bar']->getDenormalizationContexts()); + + Assert::assertEquals(['*' => ['method' => 'method_with_context']], $attributes['methodWithContext']->getNormalizationContexts()); + Assert::assertEquals( + $attributes['methodWithContext']->getNormalizationContexts(), + $attributes['methodWithContext']->getDenormalizationContexts() + ); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php index d4ed487a20caa..201cb68ba8ff8 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php @@ -21,6 +21,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -28,10 +29,13 @@ */ class XmlFileLoaderTest extends TestCase { + use ContextMappingTestTrait; + /** * @var XmlFileLoader */ private $loader; + /** * @var ClassMetadata */ @@ -104,4 +108,9 @@ public function testLoadIgnore() $this->assertTrue($attributesMetadata['ignored1']->isIgnored()); $this->assertTrue($attributesMetadata['ignored2']->isIgnored()); } + + protected function getLoaderForContextMapping(): LoaderInterface + { + return $this->loader; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php index d6fb2fa598ee0..aa235762bdeb5 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -29,6 +30,8 @@ */ class YamlFileLoaderTest extends TestCase { + use ContextMappingTestTrait; + /** * @var YamlFileLoader */ @@ -126,4 +129,9 @@ public function testLoadInvalidIgnore() (new YamlFileLoader(__DIR__.'/../../Fixtures/invalid-ignore.yml'))->loadClassMetadata(new ClassMetadata(IgnoreDummy::class)); } + + protected function getLoaderForContextMapping(): LoaderInterface + { + return $this->loader; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php index ff1f85a4523c5..dfbf01806bd7c 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php @@ -13,27 +13,30 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Serializer\SerializerInterface; class ArrayDenormalizerTest extends TestCase { + use ExpectDeprecationTrait; + /** * @var ArrayDenormalizer */ private $denormalizer; /** - * @var SerializerInterface|MockObject + * @var ContextAwareDenormalizerInterface|MockObject */ private $serializer; protected function setUp(): void { - $this->serializer = $this->createMock(Serializer::class); + $this->serializer = $this->createMock(ContextAwareDenormalizerInterface::class); $this->denormalizer = new ArrayDenormalizer(); - $this->denormalizer->setSerializer($this->serializer); + $this->denormalizer->setDenormalizer($this->serializer); } public function testDenormalize() @@ -66,11 +69,51 @@ public function testDenormalize() ); } + /** + * @group legacy + */ + public function testDenormalizeLegacy() + { + $serializer = $this->createMock(Serializer::class); + + $serializer->expects($this->exactly(2)) + ->method('denormalize') + ->withConsecutive( + [['foo' => 'one', 'bar' => 'two']], + [['foo' => 'three', 'bar' => 'four']] + ) + ->willReturnOnConsecutiveCalls( + new ArrayDummy('one', 'two'), + new ArrayDummy('three', 'four') + ); + + $denormalizer = new ArrayDenormalizer(); + + $this->expectDeprecation('Since symfony/serializer 5.3: Calling "%s" is deprecated. Please call setDenormalizer() instead.'); + $denormalizer->setSerializer($serializer); + + $result = $denormalizer->denormalize( + [ + ['foo' => 'one', 'bar' => 'two'], + ['foo' => 'three', 'bar' => 'four'], + ], + __NAMESPACE__.'\ArrayDummy[]' + ); + + $this->assertEquals( + [ + new ArrayDummy('one', 'two'), + new ArrayDummy('three', 'four'), + ], + $result + ); + } + public function testSupportsValidArray() { $this->serializer->expects($this->once()) ->method('supportsDenormalization') - ->with($this->anything(), ArrayDummy::class, $this->anything()) + ->with($this->anything(), ArrayDummy::class, 'json', ['con' => 'text']) ->willReturn(true); $this->assertTrue( @@ -79,7 +122,9 @@ public function testSupportsValidArray() ['foo' => 'one', 'bar' => 'two'], ['foo' => 'three', 'bar' => 'four'], ], - __NAMESPACE__.'\ArrayDummy[]' + __NAMESPACE__.'\ArrayDummy[]', + 'json', + ['con' => 'text'] ) ); } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php new file mode 100644 index 0000000000000..374cacaf79d02 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Normalizer\Features; + +use Doctrine\Common\Annotations\AnnotationReader; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +/** + * Test context handling from Serializer metadata. + * + * @author Maxime Steinhausser + */ +trait ContextMetadataTestTrait +{ + public function testContextMetadataNormalize() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); + new Serializer([new DateTimeNormalizer(), $normalizer]); + + $dummy = new ContextMetadataDummy(); + $dummy->date = new \DateTime('2011-07-28T08:44:00.123+00:00'); + + self::assertEquals(['date' => '2011-07-28T08:44:00+00:00'], $normalizer->normalize($dummy)); + + self::assertEquals(['date' => '2011-07-28T08:44:00.123+00:00'], $normalizer->normalize($dummy, null, [ + ObjectNormalizer::GROUPS => 'extended', + ]), 'a specific normalization context is used for this group'); + + self::assertEquals(['date' => '2011-07-28T08:44:00+00:00'], $normalizer->normalize($dummy, null, [ + ObjectNormalizer::GROUPS => 'simple', + ]), 'base denormalization context is unchanged for this group'); + } + + public function testContextMetadataContextDenormalize() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); + new Serializer([new DateTimeNormalizer(), $normalizer]); + + /** @var ContextMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], ContextMetadataDummy::class); + self::assertEquals(new \DateTime('2011-07-28T08:44:00+00:00'), $dummy->date); + + /** @var ContextMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], ContextMetadataDummy::class, null, [ + ObjectNormalizer::GROUPS => 'extended', + ]); + self::assertEquals(new \DateTime('2011-07-28T08:44:00+00:00'), $dummy->date, 'base denormalization context is unchanged for this group'); + + /** @var ContextMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '28/07/2011'], ContextMetadataDummy::class, null, [ + ObjectNormalizer::GROUPS => 'simple', + ]); + self::assertEquals('2011-07-28', $dummy->date->format('Y-m-d'), 'a specific denormalization context is used for this group'); + } +} + +class ContextMetadataDummy +{ + /** + * @var \DateTime + * + * @Groups({ "extended", "simple" }) + * @Context({ DateTimeNormalizer::FORMAT_KEY = \DateTime::RFC3339 }) + * @Context( + * normalizationContext = { DateTimeNormalizer::FORMAT_KEY = \DateTime::RFC3339_EXTENDED }, + * groups = {"extended"} + * ) + * @Context( + * denormalizationContext = { DateTimeNormalizer::FORMAT_KEY = "d/m/Y" }, + * groups = {"simple"} + * ) + */ + public $date; +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index f23bedea1fb58..860c16f6036a4 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -42,6 +42,7 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\ContextMetadataTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -59,6 +60,7 @@ class ObjectNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use ContextMetadataTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/UidNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/UidNormalizerTest.php index 44a7217e0a5c6..e2e68832a92bf 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/UidNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/UidNormalizerTest.php @@ -3,6 +3,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Normalizer\UidNormalizer; use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Uid\Ulid; @@ -25,19 +26,6 @@ protected function setUp(): void $this->normalizer = new UidNormalizer(); } - public function dataProvider() - { - return [ - ['9b7541de-6f87-11ea-ab3c-9da9a81562fc', UuidV1::class], - ['e576629b-ff34-3642-9c08-1f5219f0d45b', UuidV3::class], - ['4126dbc1-488e-4f6e-aadd-775dcbac482e', UuidV4::class], - ['18cdf3d3-ea1b-5b23-a9c5-40abd0e2df22', UuidV5::class], - ['1ea6ecef-eb9a-66fe-b62b-957b45f17e43', UuidV6::class], - ['1ea6ecef-eb9a-66fe-b62b-957b45f17e43', AbstractUid::class], - ['01E4BYF64YZ97MDV6RH0HAMN6X', Ulid::class], - ]; - } - public function testSupportsNormalization() { $this->assertTrue($this->normalizer->supportsNormalization(Uuid::v1())); @@ -49,16 +37,88 @@ public function testSupportsNormalization() $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); } + public function normalizeProvider() + { + $uidFormats = [null, 'canonical', 'base58', 'base32', 'rfc4122']; + $data = [ + [ + UuidV1::fromString('9b7541de-6f87-11ea-ab3c-9da9a81562fc'), + '9b7541de-6f87-11ea-ab3c-9da9a81562fc', + '9b7541de-6f87-11ea-ab3c-9da9a81562fc', + 'LCQS8f2p5SDSiAt9V7ZYnF', + '4VEN0XWVW727NAPF4XN6M1ARQW', + '9b7541de-6f87-11ea-ab3c-9da9a81562fc', + ], + [ + UuidV3::fromString('e576629b-ff34-3642-9c08-1f5219f0d45b'), + 'e576629b-ff34-3642-9c08-1f5219f0d45b', + 'e576629b-ff34-3642-9c08-1f5219f0d45b', + 'VLRwe3qfi66uUAE3mYQ4Dp', + '75ESH9QZSM6S19R20ZA8CZ1N2V', + 'e576629b-ff34-3642-9c08-1f5219f0d45b', + ], + [ + UuidV4::fromString('4126dbc1-488e-4f6e-aadd-775dcbac482e'), + '4126dbc1-488e-4f6e-aadd-775dcbac482e', + '4126dbc1-488e-4f6e-aadd-775dcbac482e', + '93d88pS3fdrDXNR2XxU9nu', + '214VDW2J4E9XQANQBQBQ5TRJ1E', + '4126dbc1-488e-4f6e-aadd-775dcbac482e', + ], + [ + UuidV5::fromString('18cdf3d3-ea1b-5b23-a9c5-40abd0e2df22'), + '18cdf3d3-ea1b-5b23-a9c5-40abd0e2df22', + '18cdf3d3-ea1b-5b23-a9c5-40abd0e2df22', + '44epMFQYZ9byVSGis5dofo', + '0RSQSX7TGVBCHTKHA0NF8E5QS2', + '18cdf3d3-ea1b-5b23-a9c5-40abd0e2df22', + ], + [ + UuidV6::fromString('1ea6ecef-eb9a-66fe-b62b-957b45f17e43'), + '1ea6ecef-eb9a-66fe-b62b-957b45f17e43', + '1ea6ecef-eb9a-66fe-b62b-957b45f17e43', + '4nXtvo2iuyYefrqTMhvogn', + '0YMVPEZTWTCVZBCAWNFD2Z2ZJ3', + '1ea6ecef-eb9a-66fe-b62b-957b45f17e43', + ], + [ + Ulid::fromString('01E4BYF64YZ97MDV6RH0HAMN6X'), + '01E4BYF64YZ97MDV6RH0HAMN6X', + '01E4BYF64YZ97MDV6RH0HAMN6X', + '1BKuy2YWf8Yf9vSkA2wDpg', + '01E4BYF64YZ97MDV6RH0HAMN6X', + '017117e7-989e-fa4f-46ec-d88822aa54dd', + ], + ]; + + foreach ($uidFormats as $i => $uidFormat) { + foreach ($data as $uidClass => $row) { + yield [$row[$i + 1], $row[0], $uidFormat]; + } + } + } + /** - * @dataProvider dataProvider + * @dataProvider normalizeProvider */ - public function testNormalize($uuidString, $class) + public function testNormalize(string $expected, AbstractUid $uid, ?string $uidFormat) { - if (Ulid::class === $class) { - $this->assertEquals($uuidString, $this->normalizer->normalize(Ulid::fromString($uuidString))); - } else { - $this->assertEquals($uuidString, $this->normalizer->normalize(Uuid::fromString($uuidString))); - } + $this->assertSame($expected, $this->normalizer->normalize($uid, null, null !== $uidFormat ? [ + 'uid_normalization_format' => $uidFormat, + ] : [])); + } + + public function dataProvider() + { + return [ + ['9b7541de-6f87-11ea-ab3c-9da9a81562fc', UuidV1::class], + ['e576629b-ff34-3642-9c08-1f5219f0d45b', UuidV3::class], + ['4126dbc1-488e-4f6e-aadd-775dcbac482e', UuidV4::class], + ['18cdf3d3-ea1b-5b23-a9c5-40abd0e2df22', UuidV5::class], + ['1ea6ecef-eb9a-66fe-b62b-957b45f17e43', UuidV6::class], + ['1ea6ecef-eb9a-66fe-b62b-957b45f17e43', AbstractUid::class], + ['01E4BYF64YZ97MDV6RH0HAMN6X', Ulid::class], + ]; } /** @@ -85,4 +145,27 @@ public function testDenormalize($uuidString, $class) $this->assertEquals(Uuid::fromString($uuidString), $this->normalizer->denormalize($uuidString, $class)); } } + + public function testNormalizeWithNormalizationFormatPassedInConstructor() + { + $uidNormalizer = new UidNormalizer([ + 'uid_normalization_format' => 'rfc4122', + ]); + $ulid = Ulid::fromString('01ETWV01C0GYQ5N92ZK7QRGB10'); + + $this->assertSame('0176b9b0-0580-87ae-5aa4-5f99ef882c20', $uidNormalizer->normalize($ulid)); + $this->assertSame('01ETWV01C0GYQ5N92ZK7QRGB10', $uidNormalizer->normalize($ulid, null, [ + 'uid_normalization_format' => 'canonical', + ])); + } + + public function testNormalizeWithNormalizationFormatNotValid() + { + $this->expectException(LogicException::class); + $this->expectDeprecationMessage('The "ccc" format is not valid.'); + + $this->normalizer->normalize(new Ulid(), null, [ + 'uid_normalization_format' => 'ccc', + ]); + } } diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index be930136ddfb2..629747f174b33 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -34,9 +34,10 @@ "symfony/http-kernel": "^4.4|^5.0", "symfony/mime": "^4.4|^5.0", "symfony/property-access": "^4.4.9|^5.0.9", - "symfony/property-info": "^4.4|^5.0", + "symfony/property-info": "^5.3", "symfony/uid": "^5.1", "symfony/validator": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0", "symfony/var-exporter": "^4.4|^5.0", "symfony/yaml": "^4.4|^5.0" }, diff --git a/src/Symfony/Component/String/CHANGELOG.md b/src/Symfony/Component/String/CHANGELOG.md index 988671514a301..700d214832d32 100644 --- a/src/Symfony/Component/String/CHANGELOG.md +++ b/src/Symfony/Component/String/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Made `AsciiSlugger` fallback to parent locale's symbolsMap + 5.2.0 ----- diff --git a/src/Symfony/Component/String/Slugger/AsciiSlugger.php b/src/Symfony/Component/String/Slugger/AsciiSlugger.php index 55b441aca2939..f45af71421725 100644 --- a/src/Symfony/Component/String/Slugger/AsciiSlugger.php +++ b/src/Symfony/Component/String/Slugger/AsciiSlugger.php @@ -111,6 +111,8 @@ public function slug(string $string, string $separator = '-', string $locale = n } if ($this->symbolsMap instanceof \Closure) { + // If the symbols map is passed as a closure, there is no need to fallback to the parent locale + // as the closure can just provide substitutions for all locales of interest. $symbolsMap = $this->symbolsMap; array_unshift($transliterator, static function ($s) use ($symbolsMap, $locale) { return $symbolsMap($s, $locale); @@ -119,9 +121,20 @@ public function slug(string $string, string $separator = '-', string $locale = n $unicodeString = (new UnicodeString($string))->ascii($transliterator); - if (\is_array($this->symbolsMap) && isset($this->symbolsMap[$locale])) { - foreach ($this->symbolsMap[$locale] as $char => $replace) { - $unicodeString = $unicodeString->replace($char, ' '.$replace.' '); + if (\is_array($this->symbolsMap)) { + $map = null; + if (isset($this->symbolsMap[$locale])) { + $map = $this->symbolsMap[$locale]; + } else { + $parent = self::getParentLocale($locale); + if ($parent && isset($this->symbolsMap[$parent])) { + $map = $this->symbolsMap[$parent]; + } + } + if ($map) { + foreach ($map as $char => $replace) { + $unicodeString = $unicodeString->replace($char, ' '.$replace.' '); + } } } @@ -143,17 +156,28 @@ private function createTransliterator(string $locale): ?\Transliterator } // Locale not supported and no parent, fallback to any-latin - if (false === $str = strrchr($locale, '_')) { + if (!$parent = self::getParentLocale($locale)) { return $this->transliterators[$locale] = null; } // Try to use the parent locale (ie. try "de" for "de_AT") and cache both locales - $parent = substr($locale, 0, -\strlen($str)); - if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$parent] ?? null) { $transliterator = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); } return $this->transliterators[$locale] = $this->transliterators[$parent] = $transliterator ?? null; } + + private static function getParentLocale(?string $locale): ?string + { + if (!$locale) { + return null; + } + if (false === $str = strrchr($locale, '_')) { + // no parent locale + return null; + } + + return substr($locale, 0, -\strlen($str)); + } } diff --git a/src/Symfony/Component/String/Tests/SluggerTest.php b/src/Symfony/Component/String/Tests/SluggerTest.php index e838da6afb53d..4066867e1ae13 100644 --- a/src/Symfony/Component/String/Tests/SluggerTest.php +++ b/src/Symfony/Component/String/Tests/SluggerTest.php @@ -65,6 +65,37 @@ public function testSlugCharReplacementLocaleMethod() $this->assertSame('yo_y_tu_a_esta_direccion_slug_en_senal_test_es', $slug); } + public function testSlugCharReplacementLocaleConstructWithoutSymbolsMap() + { + $slugger = new AsciiSlugger('en'); + $slug = (string) $slugger->slug('you & me with this address slug@test.uk', '_'); + + $this->assertSame('you_and_me_with_this_address_slug_at_test_uk', $slug); + } + + public function testSlugCharReplacementParentLocaleConstructWithoutSymbolsMap() + { + $slugger = new AsciiSlugger('en_GB'); + $slug = (string) $slugger->slug('you & me with this address slug@test.uk', '_'); + + $this->assertSame('you_and_me_with_this_address_slug_at_test_uk', $slug); + } + + public function testSlugCharReplacementParentLocaleConstruct() + { + $slugger = new AsciiSlugger('fr_FR', ['fr' => ['&' => 'et', '@' => 'chez']]); + $slug = (string) $slugger->slug('toi & moi avec cette adresse slug@test.fr', '_'); + + $this->assertSame('toi_et_moi_avec_cette_adresse_slug_chez_test_fr', $slug); + } + + public function testSlugCharReplacementParentLocaleMethod() + { + $slugger = new AsciiSlugger(null, ['es' => ['&' => 'y', '@' => 'en senal']]); + $slug = (string) $slugger->slug('yo & tu a esta dirección slug@test.es', '_', 'es_ES'); + $this->assertSame('yo_y_tu_a_esta_direccion_slug_en_senal_test_es', $slug); + } + public function testSlugClosure() { $slugger = new AsciiSlugger(null, function ($s, $locale) { diff --git a/src/Symfony/Component/Translation/Command/XliffLintCommand.php b/src/Symfony/Component/Translation/Command/XliffLintCommand.php index c487e82e1ed49..eaf9e81cc7456 100644 --- a/src/Symfony/Component/Translation/Command/XliffLintCommand.php +++ b/src/Symfony/Component/Translation/Command/XliffLintCommand.php @@ -31,6 +31,7 @@ class XliffLintCommand extends Command { protected static $defaultName = 'lint:xliff'; + protected static $defaultDescription = 'Lints an XLIFF file and outputs encountered errors'; private $format; private $displayCorrectFiles; @@ -53,7 +54,7 @@ public function __construct(string $name = null, callable $directoryIteratorProv protected function configure() { $this - ->setDescription('Lints an XLIFF file and outputs encountered errors') + ->setDescription(self::$defaultDescription) ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') ->setHelp(<< */ abstract class AbstractUid implements \JsonSerializable @@ -37,6 +35,62 @@ abstract public static function isValid(string $uid): bool; */ abstract public static function fromString(string $uid): self; + /** + * @return static + * + * @throws \InvalidArgumentException When the passed value is not valid + */ + public static function fromBinary(string $uid): self + { + if (16 !== \strlen($uid)) { + throw new \InvalidArgumentException('Invalid binary uid provided.'); + } + + return static::fromString($uid); + } + + /** + * @return static + * + * @throws \InvalidArgumentException When the passed value is not valid + */ + public static function fromBase58(string $uid): self + { + if (22 !== \strlen($uid)) { + throw new \InvalidArgumentException('Invalid base-58 uid provided.'); + } + + return static::fromString($uid); + } + + /** + * @return static + * + * @throws \InvalidArgumentException When the passed value is not valid + */ + public static function fromBase32(string $uid): self + { + if (26 !== \strlen($uid)) { + throw new \InvalidArgumentException('Invalid base-32 uid provided.'); + } + + return static::fromString($uid); + } + + /** + * @return static + * + * @throws \InvalidArgumentException When the passed value is not valid + */ + public static function fromRfc4122(string $uid): self + { + if (36 !== \strlen($uid)) { + throw new \InvalidArgumentException('Invalid RFC4122 uid provided.'); + } + + return static::fromString($uid); + } + /** * Returns the identifier as a raw binary string. */ diff --git a/src/Symfony/Component/Uid/BinaryUtil.php b/src/Symfony/Component/Uid/BinaryUtil.php index 5e9741cbea200..131976021560f 100644 --- a/src/Symfony/Component/Uid/BinaryUtil.php +++ b/src/Symfony/Component/Uid/BinaryUtil.php @@ -119,10 +119,10 @@ public static function add(string $a, string $b): string /** * @param string $time Count of 100-nanosecond intervals since the UUID epoch 1582-10-15 00:00:00 in hexadecimal */ - public static function timeToFloat(string $time): float + public static function hexToDateTime(string $time): \DateTimeImmutable { if (\PHP_INT_SIZE >= 8) { - $time = hexdec($time) - self::TIME_OFFSET_INT; + $time = (string) (hexdec($time) - self::TIME_OFFSET_INT); } else { $time = str_pad(hex2bin($time), 8, "\0", \STR_PAD_LEFT); @@ -136,6 +136,40 @@ public static function timeToFloat(string $time): float } } - return $time / 10000000; + if (9 > \strlen($time)) { + $time = '-' === $time[0] ? '-'.str_pad(substr($time, 1), 8, '0', \STR_PAD_LEFT) : str_pad($time, 8, '0', \STR_PAD_LEFT); + } + + return \DateTimeImmutable::createFromFormat('U.u?', substr_replace($time, '.', -7, 0)); + } + + /** + * @return string Count of 100-nanosecond intervals since the UUID epoch 1582-10-15 00:00:00 in hexadecimal + */ + public static function dateTimeToHex(\DateTimeInterface $time): string + { + if (\PHP_INT_SIZE >= 8) { + if (-self::TIME_OFFSET_INT > $time = (int) $time->format('Uu0')) { + throw new \InvalidArgumentException('The given UUID date cannot be earlier than 1582-10-15.'); + } + + return str_pad(dechex(self::TIME_OFFSET_INT + $time), 16, '0', \STR_PAD_LEFT); + } + + $time = $time->format('Uu0'); + $negative = '-' === $time[0]; + if ($negative && self::TIME_OFFSET_INT < $time = substr($time, 1)) { + throw new \InvalidArgumentException('The given UUID date cannot be earlier than 1582-10-15.'); + } + $time = self::fromBase($time, self::BASE10); + $time = str_pad($time, 8, "\0", \STR_PAD_LEFT); + + if ($negative) { + $time = self::add($time, self::TIME_OFFSET_COM1) ^ "\xff\xff\xff\xff\xff\xff\xff\xff"; + } else { + $time = self::add($time, self::TIME_OFFSET_BIN); + } + + return bin2hex($time); } } diff --git a/src/Symfony/Component/Uid/CHANGELOG.md b/src/Symfony/Component/Uid/CHANGELOG.md index b93119ec20c2e..03e3e9cbdf0ca 100644 --- a/src/Symfony/Component/Uid/CHANGELOG.md +++ b/src/Symfony/Component/Uid/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +5.3 +--- + + * The component is not marked as `@experimental` anymore + * Add `AbstractUid::fromBinary()`, `AbstractUid::fromBase58()`, `AbstractUid::fromBase32()` and `AbstractUid::fromRfc4122()` + * [BC BREAK] Replace `UuidV1::getTime()`, `UuidV6::getTime()` and `Ulid::getTime()` by `UuidV1::getDateTime()`, `UuidV6::getDateTime()` and `Ulid::getDateTime()` + * Add `Uuid::NAMESPACE_*` constants from RFC4122 + * Add `UlidFactory`, `UuidFactory`, `RandomBasedUuidFactory`, `TimeBasedUuidFactory` and `NameBasedUuidFactory` + * Add commands to generate and inspect UUIDs and ULIDs + 5.2.0 ----- diff --git a/src/Symfony/Component/Uid/Command/GenerateUlidCommand.php b/src/Symfony/Component/Uid/Command/GenerateUlidCommand.php new file mode 100644 index 0000000000000..7eb1b76abf23e --- /dev/null +++ b/src/Symfony/Component/Uid/Command/GenerateUlidCommand.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Factory\UlidFactory; + +class GenerateUlidCommand extends Command +{ + protected static $defaultName = 'ulid:generate'; + protected static $defaultDescription = 'Generates a ULID'; + + private $factory; + + public function __construct(UlidFactory $factory = null) + { + $this->factory = $factory ?? new UlidFactory(); + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this + ->setDefinition([ + new InputOption('time', null, InputOption::VALUE_REQUIRED, 'The ULID timestamp: a parsable date/time string'), + new InputOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of ULID to generate', 1), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'The ULID output format: base32, base58 or rfc4122', 'base32'), + ]) + ->setDescription(self::$defaultDescription) + ->setHelp(<<<'EOF' +The %command.name% command generates a ULID. + + php %command.full_name% + +To specify the timestamp: + + php %command.full_name% --time="2021-02-16 14:09:08" + +To generate several ULIDs: + + php %command.full_name% --count=10 + +To output a specific format: + + php %command.full_name% --format=rfc4122 +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + if (null !== $time = $input->getOption('time')) { + try { + $time = new \DateTimeImmutable($time); + } catch (\Exception $e) { + $io->error(sprintf('Invalid timestamp "%s": %s', $time, str_replace('DateTimeImmutable::__construct(): ', '', $e->getMessage()))); + + return 1; + } + } + + switch ($input->getOption('format')) { + case 'base32': $format = 'toBase32'; break; + case 'base58': $format = 'toBase58'; break; + case 'rfc4122': $format = 'toRfc4122'; break; + default: + $io->error(sprintf('Invalid format "%s", did you mean "base32", "base58" or "rfc4122"?', $input->getOption('format'))); + + return 1; + } + + $count = (int) $input->getOption('count'); + try { + for ($i = 0; $i < $count; ++$i) { + $output->writeln($this->factory->create($time)->$format()); + } + } catch (\Exception $e) { + $io->error($e->getMessage()); + + return 1; + } + + return 0; + } +} diff --git a/src/Symfony/Component/Uid/Command/GenerateUuidCommand.php b/src/Symfony/Component/Uid/Command/GenerateUuidCommand.php new file mode 100644 index 0000000000000..0e602b08afed5 --- /dev/null +++ b/src/Symfony/Component/Uid/Command/GenerateUuidCommand.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\Uuid; + +class GenerateUuidCommand extends Command +{ + protected static $defaultName = 'uuid:generate'; + protected static $defaultDescription = 'Generates a UUID'; + + private $factory; + + public function __construct(UuidFactory $factory = null) + { + $this->factory = $factory ?? new UuidFactory(); + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this + ->setDefinition([ + new InputOption('time-based', null, InputOption::VALUE_REQUIRED, 'The timestamp, to generate a time-based UUID: a parsable date/time string'), + new InputOption('node', null, InputOption::VALUE_REQUIRED, 'The UUID whose node part should be used as the node of the generated UUID'), + new InputOption('name-based', null, InputOption::VALUE_REQUIRED, 'The name, to generate a name-based UUID'), + new InputOption('namespace', null, InputOption::VALUE_REQUIRED, 'The UUID to use at the namespace for named-based UUIDs'), + new InputOption('random-based', null, InputOption::VALUE_NONE, 'To generate a random-based UUID'), + new InputOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of UUID to generate', 1), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'The UUID output format: rfc4122, base58 or base32', 'rfc4122'), + ]) + ->setDescription(self::$defaultDescription) + ->setHelp(<<<'EOF' +The %command.name% generates a UUID. + + php %command.full_name% + +To generate a time-based UUID: + + php %command.full_name% --time-based=now + +To specify a time-based UUID's node: + + php %command.full_name% --time-based=@1613480254 --node=fb3502dc-137e-4849-8886-ac90d07f64a7 + +To generate a name-based UUID: + + php %command.full_name% --name-based=foo + +To specify a name-based UUID's namespace: + + php %command.full_name% --name-based=bar --namespace=fb3502dc-137e-4849-8886-ac90d07f64a7 + +To generate a random-based UUID: + + php %command.full_name% --random-based + +To generate several UUIDs: + + php %command.full_name% --count=10 + +To output a specific format: + + php %command.full_name% --format=base58 +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + $time = $input->getOption('time-based'); + $node = $input->getOption('node'); + $name = $input->getOption('name-based'); + $namespace = $input->getOption('namespace'); + $random = $input->getOption('random-based'); + + if (false !== ($time ?? $name ?? $random) && 1 < ((null !== $time) + (null !== $name) + $random)) { + $io->error('Only one of "--time-based", "--name-based" or "--random-based" can be provided at a time.'); + + return 1; + } + + if (null === $time && null !== $node) { + $io->error('Option "--node" can only be used with "--time-based".'); + + return 1; + } + + if (null === $name && null !== $namespace) { + $io->error('Option "--namespace" can only be used with "--name-based".'); + + return 1; + } + + switch (true) { + case null !== $time: + if (null !== $node) { + try { + $node = Uuid::fromString($node); + } catch (\InvalidArgumentException $e) { + $io->error(sprintf('Invalid node "%s": %s', $node, $e->getMessage())); + + return 1; + } + } + + try { + $time = new \DateTimeImmutable($time); + } catch (\Exception $e) { + $io->error(sprintf('Invalid timestamp "%s": %s', $time, str_replace('DateTimeImmutable::__construct(): ', '', $e->getMessage()))); + + return 1; + } + + $create = function () use ($node, $time): Uuid { + return $this->factory->timeBased($node)->create($time); + }; + break; + + case null !== $name: + if ($namespace) { + try { + $namespace = Uuid::fromString($namespace); + } catch (\InvalidArgumentException $e) { + $io->error(sprintf('Invalid namespace "%s": %s', $namespace, $e->getMessage())); + + return 1; + } + } + + $create = function () use ($namespace, $name): Uuid { + try { + $factory = $this->factory->nameBased($namespace); + } catch (\LogicException $e) { + throw new \InvalidArgumentException('Missing namespace: use the "--namespace" option or configure a default namespace in the underlying factory.'); + } + + return $factory->create($name); + }; + break; + + case $random: + $create = [$this->factory->randomBased(), 'create']; + break; + + default: + $create = [$this->factory, 'create']; + break; + } + + switch ($input->getOption('format')) { + case 'base32': $format = 'toBase32'; break; + case 'base58': $format = 'toBase58'; break; + case 'rfc4122': $format = 'toRfc4122'; break; + default: + $io->error(sprintf('Invalid format "%s", did you mean "base32", "base58" or "rfc4122"?', $input->getOption('format'))); + + return 1; + } + + $count = (int) $input->getOption('count'); + try { + for ($i = 0; $i < $count; ++$i) { + $output->writeln($create()->$format()); + } + } catch (\Exception $e) { + $io->error($e->getMessage()); + + return 1; + } + + return 0; + } +} diff --git a/src/Symfony/Component/Uid/Command/InspectUlidCommand.php b/src/Symfony/Component/Uid/Command/InspectUlidCommand.php new file mode 100644 index 0000000000000..ba6c45c9b8475 --- /dev/null +++ b/src/Symfony/Component/Uid/Command/InspectUlidCommand.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Ulid; + +class InspectUlidCommand extends Command +{ + protected static $defaultName = 'ulid:inspect'; + protected static $defaultDescription = 'Inspects a ULID'; + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this + ->setDefinition([ + new InputArgument('ulid', InputArgument::REQUIRED, 'The ULID to inspect'), + ]) + ->setDescription(self::$defaultDescription) + ->setHelp(<<<'EOF' +The %command.name% displays information about a ULID. + + php %command.full_name% 01EWAKBCMWQ2C94EXNN60ZBS0Q + php %command.full_name% 1BVdfLn3ERmbjYBLCdaaLW + php %command.full_name% 01771535-b29c-b898-923b-b5a981f5e417 +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + try { + $ulid = Ulid::fromString($input->getArgument('ulid')); + } catch (\InvalidArgumentException $e) { + $io->error($e->getMessage()); + + return 1; + } + + $io->table(['Label', 'Value'], [ + ['Canonical (Base 32)', (string) $ulid], + ['Base 58', $ulid->toBase58()], + ['RFC 4122', $ulid->toRfc4122()], + new TableSeparator(), + ['Timestamp', ($ulid->getDateTime())->format('Y-m-d H:i:s.v')], + ]); + + return 0; + } +} diff --git a/src/Symfony/Component/Uid/Command/InspectUuidCommand.php b/src/Symfony/Component/Uid/Command/InspectUuidCommand.php new file mode 100644 index 0000000000000..6b6bbf3ed3bcc --- /dev/null +++ b/src/Symfony/Component/Uid/Command/InspectUuidCommand.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV6; + +class InspectUuidCommand extends Command +{ + protected static $defaultName = 'uuid:inspect'; + protected static $defaultDescription = 'Inspects a UUID'; + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this + ->setDefinition([ + new InputArgument('uuid', InputArgument::REQUIRED, 'The UUID to inspect'), + ]) + ->setDescription(self::$defaultDescription) + ->setHelp(<<<'EOF' +The %command.name% displays information about a UUID. + + php %command.full_name% a7613e0a-5986-11eb-a861-2bf05af69e52 + php %command.full_name% MfnmaUvvQ1h8B14vTwt6dX + php %command.full_name% 57C4Z0MPC627NTGR9BY1DFD7JJ +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + try { + /** @var Uuid $uuid */ + $uuid = Uuid::fromString($input->getArgument('uuid')); + } catch (\InvalidArgumentException $e) { + $io->error($e->getMessage()); + + return 1; + } + + if (-1 === $version = uuid_type($uuid)) { + $version = 'nil'; + } elseif (0 === $version || 2 === $version || 6 < $version) { + $version = 'unknown'; + } + + $rows = [ + ['Version', $version], + ['Canonical (RFC 4122)', (string) $uuid], + ['Base 58', $uuid->toBase58()], + ['Base 32', $uuid->toBase32()], + ]; + + if ($uuid instanceof UuidV1 || $uuid instanceof UuidV6) { + $rows[] = new TableSeparator(); + $rows[] = ['Timestamp', $uuid->getDateTime()->format('Y-m-d H:i:s.u')]; + } + + $io->table(['Label', 'Value'], $rows); + + return 0; + } +} diff --git a/src/Symfony/Component/Uid/Factory/NameBasedUuidFactory.php b/src/Symfony/Component/Uid/Factory/NameBasedUuidFactory.php new file mode 100644 index 0000000000000..cbf080bc0b52d --- /dev/null +++ b/src/Symfony/Component/Uid/Factory/NameBasedUuidFactory.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Factory; + +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV3; +use Symfony\Component\Uid\UuidV5; + +class NameBasedUuidFactory +{ + private $class; + private $namespace; + + public function __construct(string $class, Uuid $namespace) + { + $this->class = $class; + $this->namespace = $namespace; + } + + /** + * @return UuidV5|UuidV3 + */ + public function create(string $name): Uuid + { + switch ($class = $this->class) { + case UuidV5::class: return Uuid::v5($this->namespace, $name); + case UuidV3::class: return Uuid::v3($this->namespace, $name); + } + + if (is_subclass_of($class, UuidV5::class)) { + $uuid = Uuid::v5($this->namespace, $name); + } else { + $uuid = Uuid::v3($this->namespace, $name); + } + + return new $class($uuid); + } +} diff --git a/src/Symfony/Component/Uid/Factory/RandomBasedUuidFactory.php b/src/Symfony/Component/Uid/Factory/RandomBasedUuidFactory.php new file mode 100644 index 0000000000000..83ab61fbe048d --- /dev/null +++ b/src/Symfony/Component/Uid/Factory/RandomBasedUuidFactory.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Factory; + +use Symfony\Component\Uid\UuidV4; + +class RandomBasedUuidFactory +{ + private $class; + + public function __construct(string $class) + { + $this->class = $class; + } + + public function create(): UuidV4 + { + $class = $this->class; + + return new $class(); + } +} diff --git a/src/Symfony/Component/Uid/Factory/TimeBasedUuidFactory.php b/src/Symfony/Component/Uid/Factory/TimeBasedUuidFactory.php new file mode 100644 index 0000000000000..4337dbb303fa7 --- /dev/null +++ b/src/Symfony/Component/Uid/Factory/TimeBasedUuidFactory.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Factory; + +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV6; + +class TimeBasedUuidFactory +{ + private $class; + private $node; + + public function __construct(string $class, Uuid $node = null) + { + $this->class = $class; + $this->node = $node; + } + + /** + * @return UuidV6|UuidV1 + */ + public function create(\DateTimeInterface $time = null): Uuid + { + $class = $this->class; + + if (null === $time && null === $this->node) { + return new $class(); + } + + return new $class($class::generate($time, $this->node)); + } +} diff --git a/src/Symfony/Component/Uid/Factory/UlidFactory.php b/src/Symfony/Component/Uid/Factory/UlidFactory.php new file mode 100644 index 0000000000000..40cb7837178a9 --- /dev/null +++ b/src/Symfony/Component/Uid/Factory/UlidFactory.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Factory; + +use Symfony\Component\Uid\Ulid; + +class UlidFactory +{ + public function create(\DateTimeInterface $time = null): Ulid + { + return new Ulid(null === $time ? null : Ulid::generate($time)); + } +} diff --git a/src/Symfony/Component/Uid/Factory/UuidFactory.php b/src/Symfony/Component/Uid/Factory/UuidFactory.php new file mode 100644 index 0000000000000..edf64672dafb8 --- /dev/null +++ b/src/Symfony/Component/Uid/Factory/UuidFactory.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Factory; + +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Uid\UuidV5; +use Symfony\Component\Uid\UuidV6; + +class UuidFactory +{ + private $defaultClass; + private $timeBasedClass; + private $nameBasedClass; + private $randomBasedClass; + private $timeBasedNode; + private $nameBasedNamespace; + + /** + * @param string|int $defaultClass + * @param string|int $timeBasedClass + * @param string|int $nameBasedClass + * @param string|int $randomBasedClass + * @param Uuid|string|null $timeBasedNode + * @param Uuid|string|null $nameBasedNamespace + */ + public function __construct($defaultClass = UuidV6::class, $timeBasedClass = UuidV6::class, $nameBasedClass = UuidV5::class, $randomBasedClass = UuidV4::class, $timeBasedNode = null, $nameBasedNamespace = null) + { + if (null !== $timeBasedNode && !$timeBasedNode instanceof Uuid) { + $timeBasedNode = Uuid::fromString($timeBasedNode); + } + + if (null !== $nameBasedNamespace && !$nameBasedNamespace instanceof Uuid) { + $nameBasedNamespace = Uuid::fromString($nameBasedNamespace); + } + + $this->defaultClass = is_numeric($defaultClass) ? Uuid::class.'V'.$defaultClass : $defaultClass; + $this->timeBasedClass = is_numeric($timeBasedClass) ? Uuid::class.'V'.$timeBasedClass : $timeBasedClass; + $this->nameBasedClass = is_numeric($nameBasedClass) ? Uuid::class.'V'.$nameBasedClass : $nameBasedClass; + $this->randomBasedClass = is_numeric($randomBasedClass) ? Uuid::class.'V'.$randomBasedClass : $randomBasedClass; + $this->timeBasedNode = $timeBasedNode; + $this->nameBasedNamespace = $nameBasedNamespace; + } + + /** + * @return UuidV6|UuidV4|UuidV1 + */ + public function create(): Uuid + { + $class = $this->defaultClass; + + return new $class(); + } + + public function randomBased(): RandomBasedUuidFactory + { + return new RandomBasedUuidFactory($this->randomBasedClass); + } + + /** + * @param Uuid|string|null $node + */ + public function timeBased($node = null): TimeBasedUuidFactory + { + $node ?? $node = $this->timeBasedNode; + + if (null === $node) { + $class = $this->timeBasedClass; + $node = $this->timeBasedNode = new $class(); + } elseif (!$node instanceof Uuid) { + $node = Uuid::fromString($node); + } + + return new TimeBasedUuidFactory($this->timeBasedClass, $node); + } + + /** + * @param Uuid|string|null $namespace + */ + public function nameBased($namespace = null): NameBasedUuidFactory + { + $namespace ?? $namespace = $this->nameBasedNamespace; + + if (null === $namespace) { + throw new \LogicException(sprintf('A namespace should be defined when using "%s()".', __METHOD__)); + } + + if (!$namespace instanceof Uuid) { + $namespace = Uuid::fromString($namespace); + } + + return new NameBasedUuidFactory($this->nameBasedClass, $namespace); + } +} diff --git a/src/Symfony/Component/Uid/NilUuid.php b/src/Symfony/Component/Uid/NilUuid.php index 4e24a213bd858..f514c18e3b7c2 100644 --- a/src/Symfony/Component/Uid/NilUuid.php +++ b/src/Symfony/Component/Uid/NilUuid.php @@ -12,8 +12,6 @@ namespace Symfony\Component\Uid; /** - * @experimental in 5.2 - * * @author Grégoire Pineau */ class NilUuid extends Uuid diff --git a/src/Symfony/Component/Uid/README.md b/src/Symfony/Component/Uid/README.md index f01b13a93ed59..09b075584ad34 100644 --- a/src/Symfony/Component/Uid/README.md +++ b/src/Symfony/Component/Uid/README.md @@ -3,11 +3,6 @@ Uid Component The UID component provides an object-oriented API to generate and represent UIDs. -**This Component is experimental**. -[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) -are not covered by Symfony's -[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). - Resources --------- diff --git a/src/Symfony/Component/Uid/Tests/Command/GenerateUlidCommandTest.php b/src/Symfony/Component/Uid/Tests/Command/GenerateUlidCommandTest.php new file mode 100644 index 0000000000000..e41e6fc15c94a --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/Command/GenerateUlidCommandTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Command\GenerateUlidCommand; +use Symfony\Component\Uid\Ulid; + +final class GenerateUlidCommandTest extends TestCase +{ + /** + * @group time-sensitive + */ + public function testDefaults() + { + $time = microtime(false); + $time = substr($time, 11).substr($time, 1, 4); + + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(0, $commandTester->execute([])); + + $ulid = Ulid::fromBase32(trim($commandTester->getDisplay())); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', $time), $ulid->getDateTime()); + } + + public function testUnparsableTimestamp() + { + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(1, $commandTester->execute(['--time' => 'foo'])); + $this->assertStringContainsString('Invalid timestamp "foo"', $commandTester->getDisplay()); + } + + public function testTimestampBeforeUnixEpoch() + { + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(1, $commandTester->execute(['--time' => '@-42'])); + $this->assertStringContainsString('The timestamp must be positive', $commandTester->getDisplay()); + } + + public function testTimestamp() + { + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(0, $commandTester->execute(['--time' => '2021-02-16 18:09:42.999'])); + + $ulid = Ulid::fromBase32(trim($commandTester->getDisplay())); + $this->assertEquals(new \DateTimeImmutable('2021-02-16 18:09:42.999'), $ulid->getDateTime()); + } + + public function testCount() + { + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(0, $commandTester->execute(['--count' => '10'])); + + $ulids = explode("\n", trim($commandTester->getDisplay(true))); + $this->assertCount(10, $ulids); + foreach ($ulids as $ulid) { + $this->assertTrue(Ulid::isValid($ulid)); + } + } + + public function testInvalidFormat() + { + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(1, $commandTester->execute(['--format' => 'foo'])); + $this->assertStringContainsString('Invalid format "foo"', $commandTester->getDisplay()); + } + + public function testFormat() + { + $commandTester = new CommandTester(new GenerateUlidCommand()); + + $this->assertSame(0, $commandTester->execute(['--format' => 'rfc4122'])); + + Ulid::fromRfc4122(trim($commandTester->getDisplay())); + } +} diff --git a/src/Symfony/Component/Uid/Tests/Command/GenerateUuidCommandTest.php b/src/Symfony/Component/Uid/Tests/Command/GenerateUuidCommandTest.php new file mode 100644 index 0000000000000..27e829fc2b9de --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/Command/GenerateUuidCommandTest.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Command\GenerateUuidCommand; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV3; +use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Uid\UuidV5; +use Symfony\Component\Uid\UuidV6; + +final class GenerateUuidCommandTest extends TestCase +{ + public function testDefaults() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + $this->assertSame(0, $commandTester->execute([])); + $this->assertInstanceOf(UuidV6::class, Uuid::fromRfc4122(trim($commandTester->getDisplay()))); + + $commandTester = new CommandTester(new GenerateUuidCommand(new UuidFactory(UuidV4::class))); + $this->assertSame(0, $commandTester->execute([])); + $this->assertInstanceOf(UuidV4::class, Uuid::fromRfc4122(trim($commandTester->getDisplay()))); + } + + public function testTimeBasedWithInvalidNode() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['--time-based' => 'now', '--node' => 'foo'])); + $this->assertStringContainsString('Invalid node "foo"', $commandTester->getDisplay()); + } + + public function testTimeBasedWithUnparsableTimestamp() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['--time-based' => 'foo'])); + $this->assertStringContainsString('Invalid timestamp "foo"', $commandTester->getDisplay()); + } + + public function testTimeBasedWithTimestampBeforeUUIDEpoch() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['--time-based' => '@-16807797990'])); + $this->assertStringContainsString('The given UUID date cannot be earlier than 1582-10-15.', $commandTester->getDisplay()); + } + + public function testTimeBased() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + $this->assertSame(0, $commandTester->execute(['--time-based' => 'now'])); + $this->assertInstanceOf(UuidV6::class, Uuid::fromRfc4122(trim($commandTester->getDisplay()))); + + $commandTester = new CommandTester(new GenerateUuidCommand(new UuidFactory( + UuidV6::class, + UuidV1::class, + UuidV5::class, + UuidV4::class, + 'b2ba9fa1-d84a-4d49-bb0a-691421b27a00' + ))); + $this->assertSame(0, $commandTester->execute(['--time-based' => '2000-01-02 19:09:17.871524 +00:00'])); + $uuid = Uuid::fromRfc4122(trim($commandTester->getDisplay())); + $this->assertInstanceOf(UuidV1::class, $uuid); + $this->assertStringMatchesFormat('1c31e868-c148-11d3-%s-691421b27a00', (string) $uuid); + } + + public function testNameBasedWithInvalidNamespace() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['--name-based' => 'foo', '--namespace' => 'bar'])); + $this->assertStringContainsString('Invalid namespace "bar"', $commandTester->getDisplay()); + } + + public function testNameBasedWithoutNamespace() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['--name-based' => 'foo'])); + $this->assertStringContainsString('Missing namespace', $commandTester->getDisplay()); + } + + public function testNameBased() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + $this->assertSame(0, $commandTester->execute(['--name-based' => 'foo', '--namespace' => 'bcdf2a0e-e287-4d20-a92f-103eda39b100'])); + $this->assertInstanceOf(UuidV5::class, Uuid::fromRfc4122(trim($commandTester->getDisplay()))); + + $commandTester = new CommandTester(new GenerateUuidCommand(new UuidFactory( + UuidV6::class, + UuidV1::class, + UuidV3::class, + UuidV4::class, + null, + '6fc5292a-5f9f-4ada-94a4-c4063494d657' + ))); + $this->assertSame(0, $commandTester->execute(['--name-based' => 'bar'])); + $this->assertEquals(new UuidV3('54950ff1-375c-33e8-a992-2109e384091f'), Uuid::fromRfc4122(trim($commandTester->getDisplay()))); + } + + public function testRandomBased() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + $this->assertSame(0, $commandTester->execute(['--random-based' => null])); + $this->assertInstanceOf(UuidV4::class, Uuid::fromRfc4122(trim($commandTester->getDisplay()))); + } + + /** + * @dataProvider provideInvalidCombinationOfBasedOptions + */ + public function testInvalidCombinationOfBasedOptions(array $input) + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute($input)); + $this->assertStringContainsString('Only one of "--time-based", "--name-based" or "--random-based"', $commandTester->getDisplay()); + } + + public function provideInvalidCombinationOfBasedOptions() + { + return [ + [['--time-based' => 'now', '--name-based' => 'foo']], + [['--time-based' => 'now', '--random-based' => null]], + [['--name-based' => 'now', '--random-based' => null]], + [['--time-based' => 'now', '--name-based' => 'now', '--random-based' => null]], + ]; + } + + /** + * @dataProvider provideExtraNodeOption + */ + public function testExtraNodeOption(array $input) + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute($input)); + $this->assertStringContainsString('Option "--node" can only be used with "--time-based"', $commandTester->getDisplay()); + } + + public function provideExtraNodeOption() + { + return [ + [['--node' => 'foo']], + [['--name-based' => 'now', '--node' => 'foo']], + [['--random-based' => null, '--node' => 'foo']], + ]; + } + + /** + * @dataProvider provideExtraNamespaceOption + */ + public function testExtraNamespaceOption(array $input) + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute($input)); + $this->assertStringContainsString('Option "--namespace" can only be used with "--name-based"', $commandTester->getDisplay()); + } + + public function provideExtraNamespaceOption() + { + return [ + [['--namespace' => 'foo']], + [['--time-based' => 'now', '--namespace' => 'foo']], + [['--random-based' => null, '--namespace' => 'foo']], + ]; + } + + public function testCount() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['--count' => '10'])); + + $uuids = explode("\n", trim($commandTester->getDisplay(true))); + $this->assertCount(10, $uuids); + foreach ($uuids as $uuid) { + $this->assertTrue(Uuid::isValid($uuid)); + } + } + + public function testInvalidFormat() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['--format' => 'foo'])); + $this->assertStringContainsString('Invalid format "foo"', $commandTester->getDisplay()); + } + + public function testFormat() + { + $commandTester = new CommandTester(new GenerateUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['--format' => 'base32'])); + + Uuid::fromBase32(trim($commandTester->getDisplay())); + } +} diff --git a/src/Symfony/Component/Uid/Tests/Command/InspectUlidCommandTest.php b/src/Symfony/Component/Uid/Tests/Command/InspectUlidCommandTest.php new file mode 100644 index 0000000000000..7bd48bc9ab931 --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/Command/InspectUlidCommandTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Command\InspectUlidCommand; + +final class InspectUlidCommandTest extends TestCase +{ + public function test() + { + $commandTester = new CommandTester(new InspectUlidCommand()); + + $this->assertSame(1, $commandTester->execute(['ulid' => 'foobar'])); + $this->assertStringContainsString('Invalid ULID: "foobar"', $commandTester->getDisplay()); + + foreach ([ + '01E439TP9XJZ9RPFH3T1PYBCR8', + '1BKocMc5BnrVcuq2ti4Eqm', + '0171069d-593d-97d3-8b3e-23d06de5b308', + ] as $ulid) { + $this->assertSame(0, $commandTester->execute(['ulid' => $ulid])); + $this->assertSame(<<getDisplay(true)); + } + } +} diff --git a/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php b/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php new file mode 100644 index 0000000000000..9505b3bcd463a --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Command\InspectUuidCommand; + +final class InspectUuidCommandTest extends TestCase +{ + public function testInvalid() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(1, $commandTester->execute(['uuid' => 'foobar'])); + $this->assertStringContainsString('Invalid UUID: "foobar"', $commandTester->getDisplay()); + } + + public function testNil() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '00000000-0000-0000-0000-000000000000'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testUnknown() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-0dba-91e9-33af4c63f7ec'])); + $this->assertSame(<<getDisplay(true)); + + $this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-2dba-91e9-33af4c63f7ec'])); + $this->assertSame(<<getDisplay(true)); + + $this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-7dba-91e9-33af4c63f7ec'])); + $this->assertSame(<<getDisplay(true)); + + $this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-cdba-91e9-33af4c63f7ec'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testV1() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '4c8e3a2a-5993-11eb-a861-2bf05af69e52'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testV3() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => 'd108a1a0-957e-3c77-b110-d3f912374439'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testV4() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '705c6eab-a535-4f49-bd51-436d0e81206a'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testV5() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '4ec6c3ad-de94-5f75-b5f0-ad56661a30c4'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testV6() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '1eb59937-b0a7-6288-a861-db3dc2d8d4db'])); + $this->assertSame(<<getDisplay(true)); + } +} diff --git a/src/Symfony/Component/Uid/Tests/Factory/UlidFactoryTest.php b/src/Symfony/Component/Uid/Tests/Factory/UlidFactoryTest.php new file mode 100644 index 0000000000000..195c2466d72b3 --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/Factory/UlidFactoryTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Tests\Factory; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Factory\UlidFactory; + +final class UlidFactoryTest extends TestCase +{ + public function testCreate() + { + $ulidFactory = new UlidFactory(); + + $ulidFactory->create(); + + $ulid1 = $ulidFactory->create(new \DateTime('@999999.123000')); + $this->assertSame('999999.123000', $ulid1->getDateTime()->format('U.u')); + $ulid2 = $ulidFactory->create(new \DateTime('@999999.123000')); + $this->assertSame('999999.123000', $ulid2->getDateTime()->format('U.u')); + + $this->assertFalse($ulid1->equals($ulid2)); + $this->assertSame(-1, ($ulid1->compare($ulid2))); + + $ulid3 = $ulidFactory->create(new \DateTime('@1234.162524')); + $this->assertSame('1234.162000', $ulid3->getDateTime()->format('U.u')); + } + + public function testCreateWithInvalidTimestamp() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timestamp must be positive.'); + + (new UlidFactory())->create(new \DateTime('@-1000')); + } +} diff --git a/src/Symfony/Component/Uid/Tests/Factory/UuidFactoryTest.php b/src/Symfony/Component/Uid/Tests/Factory/UuidFactoryTest.php new file mode 100644 index 0000000000000..a6a05fade23ff --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/Factory/UuidFactoryTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid\Tests\Factory; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\NilUuid; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV3; +use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Uid\UuidV5; +use Symfony\Component\Uid\UuidV6; + +final class UuidFactoryTest extends TestCase +{ + public function testCreateNamedDefaultVersion() + { + $this->assertInstanceOf(UuidV5::class, (new UuidFactory())->nameBased('6f80c216-0492-4421-bd82-c10ab929ae84')->create('foo')); + $this->assertInstanceOf(UuidV3::class, (new UuidFactory(6, 6, 3))->nameBased('6f80c216-0492-4421-bd82-c10ab929ae84')->create('foo')); + } + + public function testCreateNamed() + { + $uuidFactory = new UuidFactory(); + + // Test custom namespace + $uuid1 = $uuidFactory->nameBased('6f80c216-0492-4421-bd82-c10ab929ae84')->create('foo'); + $this->assertInstanceOf(UuidV5::class, $uuid1); + $this->assertSame('d521ceb7-3e31-5954-b873-92992c697ab9', (string) $uuid1); + + // Test default namespace override + $uuid2 = $uuidFactory->nameBased(Uuid::v4())->create('foo'); + $this->assertFalse($uuid1->equals($uuid2)); + + // Test version override + $uuidFactory = new UuidFactory(6, 6, 3, 4, new NilUuid(), '6f80c216-0492-4421-bd82-c10ab929ae84'); + $uuid3 = $uuidFactory->nameBased()->create('foo'); + $this->assertInstanceOf(UuidV3::class, $uuid3); + } + + public function testCreateTimedDefaultVersion() + { + $this->assertInstanceOf(UuidV6::class, (new UuidFactory())->timeBased()->create()); + $this->assertInstanceOf(UuidV1::class, (new UuidFactory(6, 1))->timeBased()->create()); + } + + public function testCreateTimed() + { + $uuidFactory = new UuidFactory(6, 6, 5, 4, '6f80c216-0492-4421-bd82-c10ab929ae84'); + + // Test custom timestamp + $uuid1 = $uuidFactory->timeBased()->create(new \DateTime('@1611076938.057800')); + $this->assertInstanceOf(UuidV6::class, $uuid1); + $this->assertSame('1611076938.057800', $uuid1->getDateTime()->format('U.u')); + $this->assertSame('c10ab929ae84', $uuid1->getNode()); + + // Test default node override + $uuid2 = $uuidFactory->timeBased('7c1ede70-3586-48ed-a984-23c8018d9174')->create(); + $this->assertInstanceOf(UuidV6::class, $uuid2); + $this->assertSame('23c8018d9174', $uuid2->getNode()); + + // Test version override + $uuid3 = (new UuidFactory(6, 1))->timeBased()->create(); + $this->assertInstanceOf(UuidV1::class, $uuid3); + + // Test negative timestamp and round + $uuid4 = $uuidFactory->timeBased()->create(new \DateTime('@-12219292800')); + $this->assertSame('-12219292800.000000', $uuid4->getDateTime()->format('U.u')); + } + + public function testInvalidCreateTimed() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The given UUID date cannot be earlier than 1582-10-15.'); + + (new UuidFactory())->timeBased()->create(new \DateTime('@-12219292800.001000')); + } + + public function testCreateRandom() + { + $this->assertInstanceOf(UuidV4::class, (new UuidFactory())->randomBased()->create()); + } +} diff --git a/src/Symfony/Component/Uid/Tests/UlidTest.php b/src/Symfony/Component/Uid/Tests/UlidTest.php index 2e66026b66887..3a5d58926f377 100644 --- a/src/Symfony/Component/Uid/Tests/UlidTest.php +++ b/src/Symfony/Component/Uid/Tests/UlidTest.php @@ -76,13 +76,18 @@ public function testBase58() /** * @group time-sensitive */ - public function testGetTime() + public function testGetDateTime() { $time = microtime(false); $ulid = new Ulid(); $time = substr($time, 11).substr($time, 1, 4); - $this->assertSame((float) $time, $ulid->getTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', $time), $ulid->getDateTime()); + + $this->assertEquals(new \DateTimeImmutable('@0'), (new Ulid('000000000079KA1307SR9X4MV3'))->getDateTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '0.001'), (new Ulid('000000000179KA1307SR9X4MV3'))->getDateTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '281474976710.654'), (new Ulid('7ZZZZZZZZY79KA1307SR9X4MV3'))->getDateTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '281474976710.655'), (new Ulid('7ZZZZZZZZZ79KA1307SR9X4MV3'))->getDateTime()); } public function testIsValid() @@ -120,6 +125,114 @@ public function testCompare() $this->assertGreaterThan(0, $c->compare($b)); } + public function testFromBinary() + { + $this->assertEquals( + Ulid::fromString("\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08"), + Ulid::fromBinary("\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08") + ); + } + + /** + * @dataProvider provideInvalidBinaryFormat + */ + public function testFromBinaryInvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::fromBinary($ulid); + } + + public function provideInvalidBinaryFormat() + { + return [ + ['01EW2RYKDCT2SAK454KBR2QG08'], + ['1BVXue8CnY8ogucrHX3TeF'], + ['0177058f-4dac-d0b2-a990-a49af02bc008'], + ]; + } + + public function testFromBase58() + { + $this->assertEquals( + Ulid::fromString('1BVXue8CnY8ogucrHX3TeF'), + Ulid::fromBase58('1BVXue8CnY8ogucrHX3TeF') + ); + } + + /** + * @dataProvider provideInvalidBase58Format + */ + public function testFromBase58InvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::fromBase58($ulid); + } + + public function provideInvalidBase58Format() + { + return [ + ["\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08"], + ['01EW2RYKDCT2SAK454KBR2QG08'], + ['0177058f-4dac-d0b2-a990-a49af02bc008'], + ]; + } + + public function testFromBase32() + { + $this->assertEquals( + Ulid::fromString('01EW2RYKDCT2SAK454KBR2QG08'), + Ulid::fromBase32('01EW2RYKDCT2SAK454KBR2QG08') + ); + } + + /** + * @dataProvider provideInvalidBase32Format + */ + public function testFromBase32InvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::fromBase32($ulid); + } + + public function provideInvalidBase32Format() + { + return [ + ["\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08"], + ['1BVXue8CnY8ogucrHX3TeF'], + ['0177058f-4dac-d0b2-a990-a49af02bc008'], + ]; + } + + public function testFromRfc4122() + { + $this->assertEquals( + Ulid::fromString('0177058f-4dac-d0b2-a990-a49af02bc008'), + Ulid::fromRfc4122('0177058f-4dac-d0b2-a990-a49af02bc008') + ); + } + + /** + * @dataProvider provideInvalidRfc4122Format + */ + public function testFromRfc4122InvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::fromRfc4122($ulid); + } + + public function provideInvalidRfc4122Format() + { + return [ + ["\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08"], + ['01EW2RYKDCT2SAK454KBR2QG08'], + ['1BVXue8CnY8ogucrHX3TeF'], + ]; + } + public function testFromStringOnExtendedClassReturnsStatic() { $this->assertInstanceOf(CustomUlid::class, CustomUlid::fromString((new CustomUlid())->toBinary())); diff --git a/src/Symfony/Component/Uid/Tests/UuidTest.php b/src/Symfony/Component/Uid/Tests/UuidTest.php index 6b5b27f3b5001..2903eda771a03 100644 --- a/src/Symfony/Component/Uid/Tests/UuidTest.php +++ b/src/Symfony/Component/Uid/Tests/UuidTest.php @@ -60,7 +60,7 @@ public function testV1() $uuid = new UuidV1(self::A_UUID_V1); - $this->assertSame(1583245966.746458, $uuid->getTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '1583245966.746458'), $uuid->getDateTime()); $this->assertSame('3499710062d0', $uuid->getNode()); } @@ -95,7 +95,7 @@ public function testV6() $uuid = new UuidV6(substr_replace(self::A_UUID_V1, '6', 14, 1)); - $this->assertSame(85916308548.27832, $uuid->getTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '85916308548.278321'), $uuid->getDateTime()); $this->assertSame('3499710062d0', $uuid->getNode()); } @@ -195,17 +195,127 @@ public function testNilUuid() $this->assertSame('00000000-0000-0000-0000-000000000000', (string) $uuid); } + public function testFromBinary() + { + $this->assertEquals( + Uuid::fromString("\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08"), + Uuid::fromBinary("\x01\x77\x05\x8F\x4D\xAC\xD0\xB2\xA9\x90\xA4\x9A\xF0\x2B\xC0\x08") + ); + } + + /** + * @dataProvider provideInvalidBinaryFormat + */ + public function testFromBinaryInvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Uuid::fromBinary($ulid); + } + + public function provideInvalidBinaryFormat() + { + return [ + ['01EW2RYKDCT2SAK454KBR2QG08'], + ['1BVXue8CnY8ogucrHX3TeF'], + ['0177058f-4dac-d0b2-a990-a49af02bc008'], + ]; + } + + public function testFromBase58() + { + $this->assertEquals( + UuidV1::fromString('94fSqj9oxGtsNbkfQNntwx'), + UuidV1::fromBase58('94fSqj9oxGtsNbkfQNntwx') + ); + } + + /** + * @dataProvider provideInvalidBase58Format + */ + public function testFromBase58InvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Uuid::fromBase58($ulid); + } + + public function provideInvalidBase58Format() + { + return [ + ["\x41\x4C\x08\x92\x57\x1B\x11\xEB\xBF\x70\x93\xF9\xB0\x82\x2C\x57"], + ['219G494NRV27NVYW4KZ6R84B2Q'], + ['414c0892-571b-11eb-bf70-93f9b0822c57'], + ]; + } + + public function testFromBase32() + { + $this->assertEquals( + UuidV5::fromString('2VN0S74HBDBB0AQRXAHFVG35KK'), + UuidV5::fromBase32('2VN0S74HBDBB0AQRXAHFVG35KK') + ); + } + + /** + * @dataProvider provideInvalidBase32Format + */ + public function testFromBase32InvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Uuid::fromBase32($ulid); + } + + public function provideInvalidBase32Format() + { + return [ + ["\x5B\xA8\x32\x72\x45\x6D\x5A\xC0\xAB\xE3\xAA\x8B\xF7\x01\x96\x73"], + ['CKTRYycTes6WAqSQJsTDaz'], + ['5ba83272-456d-5ac0-abe3-aa8bf7019673'], + ]; + } + + public function testFromRfc4122() + { + $this->assertEquals( + UuidV6::fromString('1eb571b4-14c0-6893-bf70-2d4c83cf755a'), + UuidV6::fromRfc4122('1eb571b4-14c0-6893-bf70-2d4c83cf755a') + ); + } + + /** + * @dataProvider provideInvalidRfc4122Format + */ + public function testFromRfc4122InvalidFormat(string $ulid) + { + $this->expectException(\InvalidArgumentException::class); + + Uuid::fromRfc4122($ulid); + } + + public function provideInvalidRfc4122Format() + { + return [ + ["\x1E\xB5\x71\xB4\x14\xC0\x68\x93\xBF\x70\x2D\x4C\x83\xCF\x75\x5A"], + ['0YPNRV8560D29VYW1D9J1WYXAT'], + ['4nwTLZ2TdMtTVDE5AwVjaR'], + ]; + } + public function testFromStringOnExtendedClassReturnsStatic() { $this->assertInstanceOf(CustomUuid::class, CustomUuid::fromString(self::A_UUID_V4)); } - public function testGetTime() + public function testGetDateTime() { - $this->assertSame(103072857660.6847, ((new UuidV1('ffffffff-ffff-1fff-a456-426655440000'))->getTime())); - $this->assertSame(0.0000001, ((new UuidV1('13814001-1dd2-11b2-a456-426655440000'))->getTime())); - $this->assertSame(0.0, (new UuidV1('13814000-1dd2-11b2-a456-426655440000'))->getTime()); - $this->assertSame(-0.0000001, (new UuidV1('13813fff-1dd2-11b2-a456-426655440000'))->getTime()); - $this->assertSame(-12219292800.0, ((new UuidV1('00000000-0000-1000-a456-426655440000'))->getTime())); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '103072857660.684697'), ((new UuidV1('ffffffff-ffff-1fff-a456-426655440000'))->getDateTime())); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '0.000001'), ((new UuidV1('1381400a-1dd2-11b2-a456-426655440000'))->getDateTime())); + $this->assertEquals(new \DateTimeImmutable('@0'), (new UuidV1('13814001-1dd2-11b2-a456-426655440000'))->getDateTime()); + $this->assertEquals(new \DateTimeImmutable('@0'), (new UuidV1('13814000-1dd2-11b2-a456-426655440000'))->getDateTime()); + $this->assertEquals(new \DateTimeImmutable('@0'), (new UuidV1('13813fff-1dd2-11b2-a456-426655440000'))->getDateTime()); + $this->assertEquals(\DateTimeImmutable::createFromFormat('U.u', '-0.000001'), ((new UuidV1('13813ff6-1dd2-11b2-a456-426655440000'))->getDateTime())); + $this->assertEquals(new \DateTimeImmutable('@-12219292800'), ((new UuidV1('00000000-0000-1000-a456-426655440000'))->getDateTime())); } } diff --git a/src/Symfony/Component/Uid/Ulid.php b/src/Symfony/Component/Uid/Ulid.php index ebde9d824df68..69a782d438e90 100644 --- a/src/Symfony/Component/Uid/Ulid.php +++ b/src/Symfony/Component/Uid/Ulid.php @@ -16,8 +16,6 @@ * * @see https://github.com/ulid/spec * - * @experimental in 5.2 - * * @author Nicolas Grekas */ class Ulid extends AbstractUid @@ -28,7 +26,7 @@ class Ulid extends AbstractUid public function __construct(string $ulid = null) { if (null === $ulid) { - $this->uid = self::generate(); + $this->uid = static::generate(); return; } @@ -104,30 +102,47 @@ public function toBase32(): string return $this->uid; } - /** - * @return float Seconds since the Unix epoch 1970-01-01 00:00:00 - */ - public function getTime(): float + public function getDateTime(): \DateTimeImmutable { $time = strtr(substr($this->uid, 0, 10), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv'); if (\PHP_INT_SIZE >= 8) { - return hexdec(base_convert($time, 32, 16)) / 1000; + $time = (string) hexdec(base_convert($time, 32, 16)); + } else { + $time = sprintf('%02s%05s%05s', + base_convert(substr($time, 0, 2), 32, 16), + base_convert(substr($time, 2, 4), 32, 16), + base_convert(substr($time, 6, 4), 32, 16) + ); + $time = BinaryUtil::toBase(hex2bin($time), BinaryUtil::BASE10); } - $time = sprintf('%02s%05s%05s', - base_convert(substr($time, 0, 2), 32, 16), - base_convert(substr($time, 2, 4), 32, 16), - base_convert(substr($time, 6, 4), 32, 16) - ); + if (4 > \strlen($time)) { + $time = str_pad($time, 4, '0', \STR_PAD_LEFT); + } + + return \DateTimeImmutable::createFromFormat('U.u', substr_replace($time, '.', -3, 0)); + } + + public static function generate(\DateTimeInterface $time = null): string + { + if (null === $time) { + return self::doGenerate(); + } - return BinaryUtil::toBase(hex2bin($time), BinaryUtil::BASE10) / 1000; + if (0 > $time = substr($time->format('Uu'), 0, -3)) { + throw new \InvalidArgumentException('The timestamp must be positive.'); + } + + return self::doGenerate($time); } - private static function generate(): string + private static function doGenerate(string $mtime = null): string { - $time = microtime(false); - $time = substr($time, 11).substr($time, 2, 3); + if (null === $time = $mtime) { + $time = microtime(false); + $time = substr($time, 11).substr($time, 2, 3); + } if ($time !== self::$time) { $r = unpack('nr1/nr2/nr3/nr4/nr', random_bytes(10)); @@ -139,9 +154,13 @@ private static function generate(): string self::$rand = array_values($r); self::$time = $time; } elseif ([0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) { - usleep(100); + if (null === $mtime) { + usleep(100); + } else { + self::$rand = [0, 0, 0, 0]; + } - return self::generate(); + return self::doGenerate($mtime); } else { for ($i = 3; $i >= 0 && 0xFFFFF === self::$rand[$i]; --$i) { self::$rand[$i] = 0; diff --git a/src/Symfony/Component/Uid/Uuid.php b/src/Symfony/Component/Uid/Uuid.php index 0a1327eedd445..8450c0e9e14a8 100644 --- a/src/Symfony/Component/Uid/Uuid.php +++ b/src/Symfony/Component/Uid/Uuid.php @@ -12,12 +12,17 @@ namespace Symfony\Component\Uid; /** - * @experimental in 5.2 - * * @author Grégoire Pineau + * + * @see https://tools.ietf.org/html/rfc4122#appendix-C for details about namespaces */ class Uuid extends AbstractUid { + public const NAMESPACE_DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + public const NAMESPACE_URL = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'; + public const NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'; + public const NAMESPACE_X500 = '6ba7b814-9dad-11d1-80b4-00c04fd430c8'; + protected const TYPE = 0; public function __construct(string $uuid) diff --git a/src/Symfony/Component/Uid/UuidV1.php b/src/Symfony/Component/Uid/UuidV1.php index bad5461ebf9a1..7621de5212146 100644 --- a/src/Symfony/Component/Uid/UuidV1.php +++ b/src/Symfony/Component/Uid/UuidV1.php @@ -14,8 +14,6 @@ /** * A v1 UUID contains a 60-bit timestamp and 62 extra unique bits. * - * @experimental in 5.2 - * * @author Grégoire Pineau */ class UuidV1 extends Uuid @@ -31,18 +29,29 @@ public function __construct(string $uuid = null) } } - /** - * @return float Seconds since the Unix epoch 1970-01-01 00:00:00 - */ - public function getTime(): float + public function getDateTime(): \DateTimeImmutable { - $time = '0'.substr($this->uid, 15, 3).substr($this->uid, 9, 4).substr($this->uid, 0, 8); - - return BinaryUtil::timeToFloat($time); + return BinaryUtil::hexToDateTime('0'.substr($this->uid, 15, 3).substr($this->uid, 9, 4).substr($this->uid, 0, 8)); } public function getNode(): string { return uuid_mac($this->uid); } + + public static function generate(\DateTimeInterface $time = null, Uuid $node = null): string + { + $uuid = uuid_create(static::TYPE); + + if (null !== $time) { + $time = BinaryUtil::dateTimeToHex($time); + $uuid = substr($time, 8).'-'.substr($time, 4, 4).'-1'.substr($time, 1, 3).substr($uuid, 18); + } + + if ($node) { + $uuid = substr($uuid, 0, 24).substr($node->uid, 24); + } + + return $uuid; + } } diff --git a/src/Symfony/Component/Uid/UuidV3.php b/src/Symfony/Component/Uid/UuidV3.php index 8962ffac6fbc6..f89f2d7bb313b 100644 --- a/src/Symfony/Component/Uid/UuidV3.php +++ b/src/Symfony/Component/Uid/UuidV3.php @@ -16,8 +16,6 @@ * * Use Uuid::v3() to compute one. * - * @experimental in 5.2 - * * @author Grégoire Pineau */ class UuidV3 extends Uuid diff --git a/src/Symfony/Component/Uid/UuidV4.php b/src/Symfony/Component/Uid/UuidV4.php index 5cdb6480f61c3..53428eeb5bf64 100644 --- a/src/Symfony/Component/Uid/UuidV4.php +++ b/src/Symfony/Component/Uid/UuidV4.php @@ -14,8 +14,6 @@ /** * A v4 UUID contains a 122-bit random number. * - * @experimental in 5.2 - * * @author Grégoire Pineau */ class UuidV4 extends Uuid diff --git a/src/Symfony/Component/Uid/UuidV5.php b/src/Symfony/Component/Uid/UuidV5.php index d6268e614a2db..f671f41250373 100644 --- a/src/Symfony/Component/Uid/UuidV5.php +++ b/src/Symfony/Component/Uid/UuidV5.php @@ -16,8 +16,6 @@ * * Use Uuid::v5() to compute one. * - * @experimental in 5.2 - * * @author Grégoire Pineau */ class UuidV5 extends Uuid diff --git a/src/Symfony/Component/Uid/UuidV6.php b/src/Symfony/Component/Uid/UuidV6.php index 15ffece6c9e95..cf231e20f2d11 100644 --- a/src/Symfony/Component/Uid/UuidV6.php +++ b/src/Symfony/Component/Uid/UuidV6.php @@ -16,8 +16,6 @@ * * Unlike UUIDv1, this implementation of UUIDv6 doesn't leak the MAC address of the host. * - * @experimental in 5.2 - * * @author Nicolas Grekas */ class UuidV6 extends Uuid @@ -29,39 +27,43 @@ class UuidV6 extends Uuid public function __construct(string $uuid = null) { if (null === $uuid) { - $uuid = uuid_create(\UUID_TYPE_TIME); - $this->uid = substr($uuid, 15, 3).substr($uuid, 9, 4).$uuid[0].'-'.substr($uuid, 1, 4).'-6'.substr($uuid, 5, 3).substr($uuid, 18, 6); - - // uuid_create() returns a stable "node" that can leak the MAC of the host, but - // UUIDv6 prefers a truly random number here, let's XOR both to preserve the entropy - - if (null === self::$seed) { - self::$seed = [random_int(0, 0xffffff), random_int(0, 0xffffff)]; - } - - $node = unpack('N2', hex2bin('00'.substr($uuid, 24, 6)).hex2bin('00'.substr($uuid, 30))); - - $this->uid .= sprintf('%06x%06x', - (self::$seed[0] ^ $node[1]) | 0x010000, - self::$seed[1] ^ $node[2] - ); + $this->uid = static::generate(); } else { parent::__construct($uuid); } } - /** - * @return float Seconds since the Unix epoch 1970-01-01 00:00:00 - */ - public function getTime(): float + public function getDateTime(): \DateTimeImmutable { - $time = '0'.substr($this->uid, 0, 8).substr($this->uid, 9, 4).substr($this->uid, 15, 3); - - return BinaryUtil::timeToFloat($time); + return BinaryUtil::hexToDateTime('0'.substr($this->uid, 0, 8).substr($this->uid, 9, 4).substr($this->uid, 15, 3)); } public function getNode(): string { return substr($this->uid, 24); } + + public static function generate(\DateTimeInterface $time = null, Uuid $node = null): string + { + $uuidV1 = UuidV1::generate($time, $node); + $uuid = substr($uuidV1, 15, 3).substr($uuidV1, 9, 4).$uuidV1[0].'-'.substr($uuidV1, 1, 4).'-6'.substr($uuidV1, 5, 3).substr($uuidV1, 18, 6); + + if ($node) { + return $uuid.substr($uuidV1, 24); + } + + // uuid_create() returns a stable "node" that can leak the MAC of the host, but + // UUIDv6 prefers a truly random number here, let's XOR both to preserve the entropy + + if (null === self::$seed) { + self::$seed = [random_int(0, 0xffffff), random_int(0, 0xffffff)]; + } + + $node = unpack('N2', hex2bin('00'.substr($uuidV1, 24, 6)).hex2bin('00'.substr($uuidV1, 30))); + + return $uuid.sprintf('%06x%06x', + (self::$seed[0] ^ $node[1]) | 0x010000, + self::$seed[1] ^ $node[2] + ); + } } diff --git a/src/Symfony/Component/Uid/composer.json b/src/Symfony/Component/Uid/composer.json index 369a3081f4e75..0eae40ea68cbb 100644 --- a/src/Symfony/Component/Uid/composer.json +++ b/src/Symfony/Component/Uid/composer.json @@ -23,6 +23,9 @@ "php": ">=7.2.5", "symfony/polyfill-uuid": "^1.15" }, + "require-dev": { + "symfony/console": "^4.4|^5.0" + }, "autoload": { "psr-4": { "Symfony\\Component\\Uid\\": "" }, "exclude-from-classmap": [ diff --git a/src/Symfony/Component/Validator/Command/DebugCommand.php b/src/Symfony/Component/Validator/Command/DebugCommand.php index 96d33cf71d2e8..f844bb5d6bd3d 100644 --- a/src/Symfony/Component/Validator/Command/DebugCommand.php +++ b/src/Symfony/Component/Validator/Command/DebugCommand.php @@ -33,6 +33,7 @@ class DebugCommand extends Command { protected static $defaultName = 'debug:validator'; + protected static $defaultDescription = 'Displays validation constraints for classes'; private $validator; @@ -48,7 +49,7 @@ protected function configure() $this ->addArgument('class', InputArgument::REQUIRED, 'A fully qualified class name or a path') ->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all classes even if they have no validation constraints') - ->setDescription('Displays validation constraints for classes') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% 'App\Entity\Dummy' command dumps the validators for the dummy class. diff --git a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php index 530348c638448..3f83da8d0517e 100644 --- a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php +++ b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php @@ -119,7 +119,8 @@ public function loadClassMetadata(ClassMetadata $metadata): bool } if (!$hasTypeConstraint) { if (1 === \count($builtinTypes)) { - if ($types[0]->isCollection() && (null !== $collectionValueType = $types[0]->getCollectionValueType())) { + if ($types[0]->isCollection() && (null !== $collectionValueType = $types[0]->getCollectionValueTypes())) { + [$collectionValueType] = $collectionValueType; $this->handleAllConstraint($property, $allConstraint, $collectionValueType, $metadata); } diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index a84291927d18f..0d01d81beb4f7 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -38,7 +38,7 @@ "symfony/cache": "^4.4|^5.0", "symfony/mime": "^4.4|^5.0", "symfony/property-access": "^4.4|^5.0", - "symfony/property-info": "^4.4|^5.0", + "symfony/property-info": "^5.3", "symfony/translation": "^4.4|^5.0", "doctrine/annotations": "^1.10.4", "doctrine/cache": "~1.0", diff --git a/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php b/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php index c8a61da98c5b6..8b50bc3c7f16e 100644 --- a/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php +++ b/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php @@ -35,6 +35,7 @@ class ServerDumpCommand extends Command { protected static $defaultName = 'server:dump'; + protected static $defaultDescription = 'Starts a dump server that collects and displays dumps in a single place'; private $server; @@ -58,7 +59,7 @@ protected function configure() $this ->addOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format (%s)', $availableFormats), 'cli') - ->setDescription('Starts a dump server that collects and displays dumps in a single place') + ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' %command.name% starts a dump server that collects and displays dumps in a single place for debugging you application: diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md index 3d44a3b6e83f8..553acdb13b8e5 100644 --- a/src/Symfony/Component/Workflow/CHANGELOG.md +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Deprecate `InvalidTokenConfigurationException` + 5.2.0 ----- diff --git a/src/Symfony/Component/Workflow/Exception/InvalidTokenConfigurationException.php b/src/Symfony/Component/Workflow/Exception/InvalidTokenConfigurationException.php index a70fd4c98ddff..b287b4a849cca 100644 --- a/src/Symfony/Component/Workflow/Exception/InvalidTokenConfigurationException.php +++ b/src/Symfony/Component/Workflow/Exception/InvalidTokenConfigurationException.php @@ -11,10 +11,14 @@ namespace Symfony\Component\Workflow\Exception; +trigger_deprecation('symfony/workflow', '5.3', sprintf('The "%s" class is deprecated.', InvalidTokenConfigurationException::class)); + /** * Thrown by GuardListener when there is no token set, but guards are placed on a transition. * * @author Matt Johnson + * + * @deprecated since Symfony 5.3 */ class InvalidTokenConfigurationException extends LogicException { diff --git a/src/Symfony/Component/Yaml/CHANGELOG.md b/src/Symfony/Component/Yaml/CHANGELOG.md index d4f2b5d781fc6..21a0225e1a0fd 100644 --- a/src/Symfony/Component/Yaml/CHANGELOG.md +++ b/src/Symfony/Component/Yaml/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +5.3 +--- + + * Added `github` format support & autodetection to render errors as annotations + when running the YAML linter command in a Github Action environment. + 5.1.0 ----- diff --git a/src/Symfony/Component/Yaml/Command/LintCommand.php b/src/Symfony/Component/Yaml/Command/LintCommand.php index 83f36a93839d2..cd0cbf246434b 100644 --- a/src/Symfony/Component/Yaml/Command/LintCommand.php +++ b/src/Symfony/Component/Yaml/Command/LintCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Yaml\Command; +use Symfony\Component\Console\CI\GithubActionReporter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\RuntimeException; @@ -32,6 +33,7 @@ class LintCommand extends Command { protected static $defaultName = 'lint:yaml'; + protected static $defaultDescription = 'Lints a YAML file and outputs encountered errors'; private $parser; private $format; @@ -53,9 +55,9 @@ public function __construct(string $name = null, callable $directoryIteratorProv protected function configure() { $this - ->setDescription('Lints a file and outputs encountered errors') + ->setDescription(self::$defaultDescription) ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') - ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format') ->addOption('parse-tags', null, InputOption::VALUE_NONE, 'Parse custom tags') ->setHelp(<<%command.name% command lints a YAML file and outputs to STDOUT @@ -84,6 +86,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $io = new SymfonyStyle($input, $output); $filenames = (array) $input->getArgument('filename'); $this->format = $input->getOption('format'); + + if ('github' === $this->format && !class_exists(GithubActionReporter::class)) { + throw new \InvalidArgumentException('The "github" format is only available since "symfony/console" >= 5.3.'); + } + + if (null === $this->format) { + // Autodetect format according to CI environment + $this->format = class_exists(GithubActionReporter::class) && GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'; + } + $this->displayCorrectFiles = $output->isVerbose(); $flags = $input->getOption('parse-tags') ? Yaml::PARSE_CUSTOM_TAGS : 0; @@ -137,17 +149,23 @@ private function display(SymfonyStyle $io, array $files): int return $this->displayTxt($io, $files); case 'json': return $this->displayJson($io, $files); + case 'github': + return $this->displayTxt($io, $files, true); default: throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format)); } } - private function displayTxt(SymfonyStyle $io, array $filesInfo): int + private function displayTxt(SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false): int { $countFiles = \count($filesInfo); $erroredFiles = 0; $suggestTagOption = false; + if ($errorAsGithubAnnotations) { + $githubReporter = new GithubActionReporter($io); + } + foreach ($filesInfo as $info) { if ($info['valid'] && $this->displayCorrectFiles) { $io->comment('OK'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); @@ -159,6 +177,10 @@ private function displayTxt(SymfonyStyle $io, array $filesInfo): int if (false !== strpos($info['message'], 'PARSE_CUSTOM_TAGS')) { $suggestTagOption = true; } + + if ($errorAsGithubAnnotations) { + $githubReporter->error($info['message'], $info['file'] ?? 'php://stdin', $info['line']); + } } } diff --git a/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php b/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php index 5ad032bfa0a2c..3eabbdc41e051 100644 --- a/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php +++ b/src/Symfony/Component/Yaml/Tests/Command/LintCommandTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; +use Symfony\Component\Console\CI\GithubActionReporter; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Yaml\Command\LintCommand; @@ -63,6 +64,57 @@ public function testLintIncorrectFile() $this->assertStringContainsString('Unable to parse at line 3 (near "bar").', trim($tester->getDisplay())); } + public function testLintIncorrectFileWithGithubFormat() + { + if (!class_exists(GithubActionReporter::class)) { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "github" format is only available since "symfony/console" >= 5.3.'); + } + + $incorrectContent = <<createCommandTester(); + $filename = $this->createFile($incorrectContent); + + $tester->execute(['filename' => $filename, '--format' => 'github'], ['decorated' => false]); + + if (!class_exists(GithubActionReporter::class)) { + return; + } + + self::assertEquals(1, $tester->getStatusCode(), 'Returns 1 in case of error'); + self::assertStringMatchesFormat('%A::error file=%s, line=2, col=0::Unable to parse at line 2 (near "bar")%A', trim($tester->getDisplay())); + } + + public function testLintAutodetectsGithubActionEnvironment() + { + if (!class_exists(GithubActionReporter::class)) { + $this->markTestSkipped('The "github" format is only available since "symfony/console" >= 5.3.'); + } + + $prev = getenv('GITHUB_ACTIONS'); + putenv('GITHUB_ACTIONS'); + + try { + putenv('GITHUB_ACTIONS=1'); + + $incorrectContent = <<createCommandTester(); + $filename = $this->createFile($incorrectContent); + + $tester->execute(['filename' => $filename], ['decorated' => false]); + + self::assertStringMatchesFormat('%A::error file=%s, line=2, col=0::Unable to parse at line 2 (near "bar")%A', trim($tester->getDisplay())); + } finally { + putenv('GITHUB_ACTIONS'.($prev ? "=$prev" : '')); + } + } + public function testConstantAsKey() { $yaml = << */ interface HttpClientInterface diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index 74997eaafcf92..38b845438dd30 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -1038,4 +1038,20 @@ public function testMaxDuration() $this->assertLessThan(10, $duration); } + + public function testWithOptions() + { + $client = $this->getHttpClient(__FUNCTION__); + if (!method_exists($client, 'withOptions')) { + $this->markTestSkipped(sprintf('Not implementing "%s::withOptions()" is deprecated.', get_debug_type($client))); + } + + $client2 = $client->withOptions(['base_uri' => 'http://localhost:8057/']); + + $this->assertNotSame($client, $client2); + $this->assertSame(\get_class($client), \get_class($client2)); + + $response = $client2->request('GET', '/'); + $this->assertSame(200, $response->getStatusCode()); + } } diff --git a/src/Symfony/Contracts/HttpClient/composer.json b/src/Symfony/Contracts/HttpClient/composer.json index 9ab9eaf76cc7a..2633785448429 100644 --- a/src/Symfony/Contracts/HttpClient/composer.json +++ b/src/Symfony/Contracts/HttpClient/composer.json @@ -27,7 +27,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-main": "2.3-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", diff --git a/src/Symfony/Contracts/Service/composer.json b/src/Symfony/Contracts/Service/composer.json index 524f6a6c6c1ec..ec22b07db6e4b 100644 --- a/src/Symfony/Contracts/Service/composer.json +++ b/src/Symfony/Contracts/Service/composer.json @@ -28,7 +28,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-main": "2.3-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", diff --git a/src/Symfony/Contracts/Translation/Test/TranslatorTest.php b/src/Symfony/Contracts/Translation/Test/TranslatorTest.php index facd9b56e1299..aac9d685956de 100644 --- a/src/Symfony/Contracts/Translation/Test/TranslatorTest.php +++ b/src/Symfony/Contracts/Translation/Test/TranslatorTest.php @@ -59,6 +59,8 @@ public function testTransChoiceWithExplicitLocale($expected, $id, $number) } /** + * @requires extension intl + * * @dataProvider getTransChoiceTests */ public function testTransChoiceWithDefaultLocale($expected, $id, $number) diff --git a/src/Symfony/Contracts/Translation/composer.json b/src/Symfony/Contracts/Translation/composer.json index 907d28f0a878e..00e27f836a6a8 100644 --- a/src/Symfony/Contracts/Translation/composer.json +++ b/src/Symfony/Contracts/Translation/composer.json @@ -27,7 +27,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-main": "2.3-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", diff --git a/src/Symfony/Contracts/composer.json b/src/Symfony/Contracts/composer.json index 595e744af584a..b424c33fc8edd 100644 --- a/src/Symfony/Contracts/composer.json +++ b/src/Symfony/Contracts/composer.json @@ -49,7 +49,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-main": "2.3-dev" + "dev-main": "2.4-dev" } } }