diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php index 323f9ae250..796beaee01 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php @@ -557,8 +557,11 @@ class TextDescriptor extends Descriptor } $fileLink = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine()); + if ($fileLink) { + return sprintf('%s', $fileLink, $anchorText); + } - return sprintf('%s', $fileLink, $anchorText); + return $anchorText; } private function formatCallable($callable): string diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php index d6df7f48a2..f40a9806bf 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php @@ -26,6 +26,11 @@ use Symfony\Contracts\Cache\CacheInterface; */ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface { + /** + * @internal + */ + protected const NS_SEPARATOR = ':'; + use AbstractAdapterTrait; use ContractsTrait; @@ -34,7 +39,7 @@ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, Logg protected function __construct(string $namespace = '', int $defaultLifetime = 0) { - $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':'; + $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).static::NS_SEPARATOR; if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) { throw new InvalidArgumentException(sprintf('Namespace must be %d chars max, %d given ("%s")', $this->maxIdLength - 24, \strlen($namespace), $namespace)); } diff --git a/src/Symfony/Component/Cache/Adapter/Psr16Adapter.php b/src/Symfony/Component/Cache/Adapter/Psr16Adapter.php index a14ee082be..bb38871019 100644 --- a/src/Symfony/Component/Cache/Adapter/Psr16Adapter.php +++ b/src/Symfony/Component/Cache/Adapter/Psr16Adapter.php @@ -23,6 +23,11 @@ use Symfony\Component\Cache\Traits\ProxyTrait; */ class Psr16Adapter extends AbstractAdapter implements PruneableInterface, ResettableInterface { + /** + * @internal + */ + protected const NS_SEPARATOR = '_'; + use ProxyTrait; private $miss; diff --git a/src/Symfony/Component/Cache/Simple/AbstractCache.php b/src/Symfony/Component/Cache/Simple/AbstractCache.php index 312a7dbfd0..af46bf3bc2 100644 --- a/src/Symfony/Component/Cache/Simple/AbstractCache.php +++ b/src/Symfony/Component/Cache/Simple/AbstractCache.php @@ -27,6 +27,11 @@ use Symfony\Contracts\Cache\CacheInterface; */ abstract class AbstractCache implements Psr16CacheInterface, LoggerAwareInterface, ResettableInterface { + /** + * @internal + */ + protected const NS_SEPARATOR = ':'; + use AbstractTrait { deleteItems as private; AbstractTrait::deleteItem as delete; diff --git a/src/Symfony/Component/Cache/Tests/Adapter/MaxIdLengthAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/MaxIdLengthAdapterTest.php index fec985e6da..5da80db0de 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/MaxIdLengthAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/MaxIdLengthAdapterTest.php @@ -56,7 +56,7 @@ class MaxIdLengthAdapterTest extends TestCase $reflectionProperty->setValue($cache, true); // Versioning enabled - $this->assertEquals('--------------------------:1/------------', $reflectionMethod->invokeArgs($cache, [str_repeat('-', 12)])); + $this->assertEquals('--------------------------:1:------------', $reflectionMethod->invokeArgs($cache, [str_repeat('-', 12)])); $this->assertLessThanOrEqual(50, \strlen($reflectionMethod->invokeArgs($cache, [str_repeat('-', 12)]))); $this->assertLessThanOrEqual(50, \strlen($reflectionMethod->invokeArgs($cache, [str_repeat('-', 23)]))); $this->assertLessThanOrEqual(50, \strlen($reflectionMethod->invokeArgs($cache, [str_repeat('-', 40)]))); diff --git a/src/Symfony/Component/Cache/Tests/Adapter/Psr16AdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/Psr16AdapterTest.php index 19907ddf78..09c55e60d0 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/Psr16AdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/Psr16AdapterTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\Cache\Psr16Cache; @@ -28,4 +29,14 @@ class Psr16AdapterTest extends AdapterTestCase { return new Psr16Adapter(new Psr16Cache(new FilesystemAdapter()), '', $defaultLifetime); } + + public function testValidCacheKeyWithNamespace() + { + $cache = new Psr16Adapter(new Psr16Cache(new ArrayAdapter()), 'some_namespace', 0); + $item = $cache->getItem('my_key'); + $item->set('someValue'); + $cache->save($item); + + $this->assertTrue($cache->getItem('my_key')->isHit(), 'Stored item is successfully retrieved.'); + } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/SimpleCacheAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/SimpleCacheAdapterTest.php index b11f72d5a3..788fe59d93 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/SimpleCacheAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/SimpleCacheAdapterTest.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Symfony\Component\Cache\Adapter\SimpleCacheAdapter; use Symfony\Component\Cache\Simple\FilesystemCache; +use Symfony\Component\Cache\Simple\ArrayCache; /** * @group time-sensitive @@ -28,4 +29,14 @@ class SimpleCacheAdapterTest extends AdapterTestCase { return new SimpleCacheAdapter(new FilesystemCache(), '', $defaultLifetime); } + + public function testValidCacheKeyWithNamespace() + { + $cache = new SimpleCacheAdapter(new ArrayCache(), 'some_namespace', 0); + $item = $cache->getItem('my_key'); + $item->set('someValue'); + $cache->save($item); + + $this->assertTrue($cache->getItem('my_key')->isHit(), 'Stored item is successfully retrieved.'); + } } diff --git a/src/Symfony/Component/Cache/Traits/AbstractTrait.php b/src/Symfony/Component/Cache/Traits/AbstractTrait.php index 1d6c189c81..7c8ccabfb0 100644 --- a/src/Symfony/Component/Cache/Traits/AbstractTrait.php +++ b/src/Symfony/Component/Cache/Traits/AbstractTrait.php @@ -107,9 +107,9 @@ trait AbstractTrait { $this->deferred = []; if ($cleared = $this->versioningIsEnabled) { - $namespaceVersion = substr_replace(base64_encode(pack('V', mt_rand())), ':', 5); + $namespaceVersion = substr_replace(base64_encode(pack('V', mt_rand())), static::NS_SEPARATOR, 5); try { - $cleared = $this->doSave(['/'.$this->namespace => $namespaceVersion], 0); + $cleared = $this->doSave([static::NS_SEPARATOR.$this->namespace => $namespaceVersion], 0); } catch (\Exception $e) { $cleared = false; } @@ -243,14 +243,14 @@ trait AbstractTrait { if ($this->versioningIsEnabled && '' === $this->namespaceVersion) { $this->ids = []; - $this->namespaceVersion = '1/'; + $this->namespaceVersion = '1'.static::NS_SEPARATOR; try { - foreach ($this->doFetch(['/'.$this->namespace]) as $v) { + foreach ($this->doFetch([static::NS_SEPARATOR.$this->namespace]) as $v) { $this->namespaceVersion = $v; } - if ('1:' === $this->namespaceVersion) { - $this->namespaceVersion = substr_replace(base64_encode(pack('V', time())), ':', 5); - $this->doSave(['@'.$this->namespace => $this->namespaceVersion], 0); + if ('1'.static::NS_SEPARATOR === $this->namespaceVersion) { + $this->namespaceVersion = substr_replace(base64_encode(pack('V', time())), static::NS_SEPARATOR, 5); + $this->doSave([static::NS_SEPARATOR.$this->namespace => $this->namespaceVersion], 0); } } catch (\Exception $e) { } @@ -267,7 +267,7 @@ trait AbstractTrait } if (\strlen($id = $this->namespace.$this->namespaceVersion.$key) > $this->maxIdLength) { // Use MD5 to favor speed over security, which is not an issue here - $this->ids[$key] = $id = substr_replace(base64_encode(hash('md5', $key, true)), ':', -(\strlen($this->namespaceVersion) + 2)); + $this->ids[$key] = $id = substr_replace(base64_encode(hash('md5', $key, true)), static::NS_SEPARATOR, -(\strlen($this->namespaceVersion) + 2)); $id = $this->namespace.$this->namespaceVersion.$id; } diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php index 2a1a8ab69b..3ae264f5c8 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php @@ -379,13 +379,14 @@ class AutowirePass extends AbstractRecursivePass $container->setAliases($this->container->getAliases()); $container->setDefinitions($this->container->getDefinitions()); $container->setResourceTracking(false); + $currentId = $this->currentId; - return function () use ($container, $reference, $label) { - return $this->createTypeNotFoundMessage($container, $reference, $label); + return function () use ($container, $reference, $label, $currentId) { + return $this->createTypeNotFoundMessage($container, $reference, $label, $currentId); }; } - private function createTypeNotFoundMessage(ContainerBuilder $container, TypedReference $reference, $label) + private function createTypeNotFoundMessage(ContainerBuilder $container, TypedReference $reference, $label, string $currentId) { if (!$r = $container->getReflectionClass($type = $reference->getType(), false)) { // either $type does not exist or a parent class does not exist @@ -409,7 +410,7 @@ class AutowirePass extends AbstractRecursivePass } } - $message = sprintf('Cannot autowire service "%s": %s %s', $this->currentId, $label, $message); + $message = sprintf('Cannot autowire service "%s": %s %s', $currentId, $label, $message); if (null !== $this->lastFailure) { $message = $this->lastFailure."\n".$message; diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowirePassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowirePassTest.php index 3c08425fc2..3198456444 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowirePassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowirePassTest.php @@ -50,6 +50,22 @@ class AutowirePassTest extends TestCase $this->assertEquals(Foo::class, (string) $container->getDefinition('bar')->getArgument(0)); } + /** + * @expectedException \Symfony\Component\DependencyInjection\Exception\AutowiringFailedException + * @expectedExceptionMessage Cannot autowire service "Symfony\Component\DependencyInjection\Tests\CompilerEslaAction": argument "$notExisting" of method "Symfony\Component\DependencyInjection\Tests\Compiler\ElsaAction::__construct()" has type "Symfony\Component\DependencyInjection\Tests\Compiler\NotExisting" but this class was not found. + */ + public function testProcessNotExistingActionParam() + { + $container = new ContainerBuilder(); + + $container->register(Foo::class); + $barDefinition = $container->register(__NAMESPACE__.'EslaAction', __NAMESPACE__.'\ElsaAction'); + $barDefinition->setAutowired(true); + + (new ResolveClassPass())->process($container); + (new AutowirePass())->process($container); + } + public function testProcessVariadic() { $container = new ContainerBuilder(); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/ConstructNotExists.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/ConstructNotExists.php new file mode 100644 index 0000000000..dcf6484fb5 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/ConstructNotExists.php @@ -0,0 +1,10 @@ +getValues()[0]; }, $definition->getBindings())); } + /** + * @expectedException \Symfony\Component\DependencyInjection\Exception\RuntimeException + * @expectedExceptionMessage Cannot autowire service "Symfony\Component\DependencyInjection\Tests\Fixtures\ConstructNotExists": argument "$notExist" of method "__construct()" has type "Symfony\Component\DependencyInjection\Tests\Fixtures\NotExist" but this class was not found. + */ + public function testProcessNotExistingActionParam() + { + $container = new ContainerBuilder(); + $loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml')); + $loader->load('services_not_existing.yml'); + $container->compile(); + } + public function testFqcnLazyProxy() { $container = new ContainerBuilder(); diff --git a/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php b/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php index 841bd0d85f..acb8d02b8c 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php @@ -21,7 +21,7 @@ class BirthdayType extends AbstractType */ public function configureOptions(OptionsResolver $resolver) { - $resolver->setDefault('years', range(date('Y') - 120, date('Y'))); + $resolver->setDefault('years', range((int) date('Y') - 120, date('Y'))); $resolver->setAllowedTypes('years', 'array'); } diff --git a/src/Symfony/Component/Form/Extension/Core/Type/DateType.php b/src/Symfony/Component/Form/Extension/Core/Type/DateType.php index 39002df2b6..39b0a9e51b 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/DateType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/DateType.php @@ -259,7 +259,7 @@ class DateType extends AbstractType }; $resolver->setDefaults([ - 'years' => range(date('Y') - 5, date('Y') + 5), + 'years' => range((int) date('Y') - 5, (int) date('Y') + 5), 'months' => range(1, 12), 'days' => range(1, 31), 'widget' => 'choice', diff --git a/src/Symfony/Component/Form/Extension/Validator/Constraints/FormValidator.php b/src/Symfony/Component/Form/Extension/Validator/Constraints/FormValidator.php index 755eccbd1a..4947c994fa 100644 --- a/src/Symfony/Component/Form/Extension/Validator/Constraints/FormValidator.php +++ b/src/Symfony/Component/Form/Extension/Validator/Constraints/FormValidator.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Form\Extension\Validator\Constraints; use Symfony\Component\Form\FormInterface; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Composite; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\ConstraintValidator; @@ -90,7 +91,9 @@ class FormValidator extends ConstraintValidator $validator->atPath('data')->validate($form->getData(), $constraint, $group); // Prevent duplicate validation - continue 2; + if (!$constraint instanceof Composite) { + continue 2; + } } } } diff --git a/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorTest.php b/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorTest.php index 45fe0ebd8b..ce8973d06e 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorTest.php @@ -24,6 +24,7 @@ use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\SubmitButtonBuilder; use Symfony\Component\Translation\IdentityTranslator; +use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; @@ -714,6 +715,63 @@ class FormValidatorTest extends ConstraintValidatorTestCase $this->assertSame($constraint, $context->getViolations()->get(0)->getConstraint()); } + public function testNonCompositeConstraintValidatedOnce() + { + $form = $this + ->getBuilder('form', null, [ + 'constraints' => [new NotBlank(['groups' => ['foo', 'bar']])], + 'validation_groups' => ['foo', 'bar'], + ]) + ->setCompound(false) + ->getForm(); + $form->submit(''); + + $context = new ExecutionContext(Validation::createValidator(), $form, new IdentityTranslator()); + $this->validator->initialize($context); + $this->validator->validate($form, new Form()); + + $this->assertCount(1, $context->getViolations()); + $this->assertSame('This value should not be blank.', $context->getViolations()[0]->getMessage()); + $this->assertSame('data', $context->getViolations()[0]->getPropertyPath()); + } + + public function testCompositeConstraintValidatedInEachGroup() + { + $form = $this->getBuilder('form', null, [ + 'constraints' => [ + new Collection([ + 'field1' => new NotBlank([ + 'groups' => ['field1'], + ]), + 'field2' => new NotBlank([ + 'groups' => ['field2'], + ]), + ]), + ], + 'validation_groups' => ['field1', 'field2'], + ]) + ->setData([]) + ->setCompound(true) + ->setDataMapper(new PropertyPathMapper()) + ->getForm(); + $form->add($this->getForm('field1')); + $form->add($this->getForm('field2')); + $form->submit([ + 'field1' => '', + 'field2' => '', + ]); + + $context = new ExecutionContext(Validation::createValidator(), $form, new IdentityTranslator()); + $this->validator->initialize($context); + $this->validator->validate($form, new Form()); + + $this->assertCount(2, $context->getViolations()); + $this->assertSame('This value should not be blank.', $context->getViolations()[0]->getMessage()); + $this->assertSame('data[field1]', $context->getViolations()[0]->getPropertyPath()); + $this->assertSame('This value should not be blank.', $context->getViolations()[1]->getMessage()); + $this->assertSame('data[field2]', $context->getViolations()[1]->getPropertyPath()); + } + protected function createValidator() { return new FormValidator(); diff --git a/src/Symfony/Component/HttpClient/Exception/JsonException.php b/src/Symfony/Component/HttpClient/Exception/JsonException.php index 6c825cbeb6..aec51a5970 100644 --- a/src/Symfony/Component/HttpClient/Exception/JsonException.php +++ b/src/Symfony/Component/HttpClient/Exception/JsonException.php @@ -11,7 +11,7 @@ namespace Symfony\Component\HttpClient\Exception; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; /** * Thrown by responses' toArray() method when their content cannot be JSON-decoded. @@ -20,6 +20,6 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; * * @experimental in 4.3 */ -final class JsonException extends \JsonException implements TransportExceptionInterface +final class JsonException extends \JsonException implements DecodingExceptionInterface { } diff --git a/src/Symfony/Component/HttpClient/HttpClient.php b/src/Symfony/Component/HttpClient/HttpClient.php index 25d3f6f874..f4da6ba686 100644 --- a/src/Symfony/Component/HttpClient/HttpClient.php +++ b/src/Symfony/Component/HttpClient/HttpClient.php @@ -32,7 +32,11 @@ final class HttpClient public static function create(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface { if (\extension_loaded('curl')) { - return new CurlHttpClient($defaultOptions, $maxHostConnections, $maxPendingPushes); + if ('\\' !== \DIRECTORY_SEPARATOR || ini_get('curl.cainfo') || ini_get('openssl.cafile') || ini_get('openssl.capath')) { + return new CurlHttpClient($defaultOptions, $maxHostConnections, $maxPendingPushes); + } + + @trigger_error('Configure the "curl.cainfo", "openssl.cafile" or "openssl.capath" php.ini setting to enable the CurlHttpClient', E_USER_WARNING); } return new NativeHttpClient($defaultOptions, $maxHostConnections); diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index 0c615bab04..14d3f935e0 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -336,7 +336,7 @@ final class CurlResponse implements ResponseInterface return 0; } - if ($certinfo = curl_getinfo($ch, CURLINFO_CERTINFO)) { + if (\function_exists('openssl_x509_read') && $certinfo = curl_getinfo($ch, CURLINFO_CERTINFO)) { $info['peer_certificate_chain'] = array_map('openssl_x509_read', array_column($certinfo, 'Cert')); } diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 561061087c..4351f97ff9 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -22,7 +22,7 @@ "require": { "php": "^7.1.3", "psr/log": "^1.0", - "symfony/http-client-contracts": "^1.1.3", + "symfony/http-client-contracts": "^1.1.4", "symfony/polyfill-php73": "^1.11" }, "require-dev": { diff --git a/src/Symfony/Component/HttpFoundation/JsonResponse.php b/src/Symfony/Component/HttpFoundation/JsonResponse.php index 4dae091faa..82d182a77a 100644 --- a/src/Symfony/Component/HttpFoundation/JsonResponse.php +++ b/src/Symfony/Component/HttpFoundation/JsonResponse.php @@ -55,10 +55,10 @@ class JsonResponse extends Response * * Example: * - * return JsonResponse::create($data, 200) + * return JsonResponse::create(['key' => 'value']) * ->setSharedMaxAge(300); * - * @param mixed $data The json response data + * @param mixed $data The JSON response data * @param int $status The response status code * @param array $headers An array of response headers * @@ -70,7 +70,18 @@ class JsonResponse extends Response } /** - * Make easier the creation of JsonResponse from raw json. + * Factory method for chainability. + * + * Example: + * + * return JsonResponse::fromJsonString('{"key": "value"}') + * ->setSharedMaxAge(300); + * + * @param string|null $data The JSON response string + * @param int $status The response status code + * @param array $headers An array of response headers + * + * @return static */ public static function fromJsonString($data = null, $status = 200, $headers = []) { diff --git a/src/Symfony/Component/HttpFoundation/Response.php b/src/Symfony/Component/HttpFoundation/Response.php index c3ac19d4a9..d1263ca7a1 100644 --- a/src/Symfony/Component/HttpFoundation/Response.php +++ b/src/Symfony/Component/HttpFoundation/Response.php @@ -684,7 +684,7 @@ class Response return (int) $age; } - return max(time() - $this->getDate()->format('U'), 0); + return max(time() - (int) $this->getDate()->format('U'), 0); } /** @@ -764,7 +764,7 @@ class Response } if (null !== $this->getExpires()) { - return (int) ($this->getExpires()->format('U') - $this->getDate()->format('U')); + return (int) $this->getExpires()->format('U') - (int) $this->getDate()->format('U'); } return null; diff --git a/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php b/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php index 7037c497cc..25c071c335 100644 --- a/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php +++ b/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php @@ -85,7 +85,7 @@ class ResponseCacheStrategy implements ResponseCacheStrategyInterface $this->storeRelativeAgeDirective('s-maxage', $response->headers->getCacheControlDirective('s-maxage') ?: $response->headers->getCacheControlDirective('max-age'), $age); $expires = $response->getExpires(); - $expires = null !== $expires ? $expires->format('U') - $response->getDate()->format('U') : null; + $expires = null !== $expires ? (int) $expires->format('U') - (int) $response->getDate()->format('U') : null; $this->storeRelativeAgeDirective('expires', $expires >= 0 ? $expires : null, 0); } diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php index c021ad5cbc..93c09d8b22 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/DayOfYearTransformer.php @@ -25,7 +25,7 @@ class DayOfYearTransformer extends Transformer */ public function format(\DateTime $dateTime, int $length): string { - $dayOfYear = $dateTime->format('z') + 1; + $dayOfYear = (int) $dateTime->format('z') + 1; return $this->padLeft($dayOfYear, $length); } diff --git a/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php b/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php index 6ff96ec1ac..2ae6e7a992 100644 --- a/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php +++ b/src/Symfony/Component/Intl/DateFormatter/DateFormat/FullTransformer.php @@ -315,7 +315,7 @@ class FullTransformer preg_match_all($this->regExp, $this->pattern, $matches); if (\in_array('yy', $matches[0])) { $dateTime->setTimestamp(time()); - $year = $year > $dateTime->format('y') + 20 ? 1900 + $year : 2000 + $year; + $year = $year > (int) $dateTime->format('y') + 20 ? 1900 + $year : 2000 + $year; } $dateTime->setDate($year, $month, $day); diff --git a/src/Symfony/Component/Lock/Lock.php b/src/Symfony/Component/Lock/Lock.php index 97117a8204..db185c374d 100644 --- a/src/Symfony/Component/Lock/Lock.php +++ b/src/Symfony/Component/Lock/Lock.php @@ -83,6 +83,11 @@ final class Lock implements LockInterface, LoggerAwareInterface } if ($this->key->isExpired()) { + try { + $this->release(); + } catch (\Exception $e) { + // swallow exception to not hide the original issue + } throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $this->key)); } @@ -120,6 +125,11 @@ final class Lock implements LockInterface, LoggerAwareInterface $this->dirty = true; if ($this->key->isExpired()) { + try { + $this->release(); + } catch (\Exception $e) { + // swallow exception to not hide the original issue + } throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $this->key)); } diff --git a/src/Symfony/Component/Lock/Store/CombinedStore.php b/src/Symfony/Component/Lock/Store/CombinedStore.php index 38f3f35e75..6038b3be93 100644 --- a/src/Symfony/Component/Lock/Store/CombinedStore.php +++ b/src/Symfony/Component/Lock/Store/CombinedStore.php @@ -16,7 +16,6 @@ use Psr\Log\LoggerAwareTrait; use Psr\Log\NullLogger; use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\LockConflictedException; -use Symfony\Component\Lock\Exception\LockExpiredException; use Symfony\Component\Lock\Exception\NotSupportedException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\StoreInterface; @@ -30,6 +29,7 @@ use Symfony\Component\Lock\Strategy\StrategyInterface; class CombinedStore implements StoreInterface, LoggerAwareInterface { use LoggerAwareTrait; + use ExpiringStoreTrait; /** @var StoreInterface[] */ private $stores; @@ -78,6 +78,8 @@ class CombinedStore implements StoreInterface, LoggerAwareInterface } } + $this->checkNotExpired($key); + if ($this->strategy->isMet($successCount, $storesCount)) { return; } @@ -125,9 +127,7 @@ class CombinedStore implements StoreInterface, LoggerAwareInterface } } - if ($key->isExpired()) { - throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key)); - } + $this->checkNotExpired($key); if ($this->strategy->isMet($successCount, $storesCount)) { return; diff --git a/src/Symfony/Component/Lock/Store/ExpiringStoreTrait.php b/src/Symfony/Component/Lock/Store/ExpiringStoreTrait.php new file mode 100644 index 0000000000..e22c4405e4 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/ExpiringStoreTrait.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\Lock\Store; + +use Symfony\Component\Lock\Exception\LockExpiredException; +use Symfony\Component\Lock\Key; + +trait ExpiringStoreTrait +{ + private function checkNotExpired(Key $key) + { + if ($key->isExpired()) { + try { + $this->delete($key); + } catch (\Exception $e) { + // swallow exception to not hide the original issue + } + throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key)); + } + } +} diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index 92f84450a9..9b84303fc3 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -13,7 +13,6 @@ namespace Symfony\Component\Lock\Store; use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\LockConflictedException; -use Symfony\Component\Lock\Exception\LockExpiredException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\StoreInterface; @@ -24,6 +23,8 @@ use Symfony\Component\Lock\StoreInterface; */ class MemcachedStore implements StoreInterface { + use ExpiringStoreTrait; + private $memcached; private $initialTtl; /** @var bool */ @@ -64,9 +65,7 @@ class MemcachedStore implements StoreInterface $this->putOffExpiration($key, $this->initialTtl); } - if ($key->isExpired()) { - throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key)); - } + $this->checkNotExpired($key); } public function waitAndSave(Key $key) @@ -110,9 +109,7 @@ class MemcachedStore implements StoreInterface throw new LockConflictedException(); } - if ($key->isExpired()) { - throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key)); - } + $this->checkNotExpired($key); } /** diff --git a/src/Symfony/Component/Lock/Store/RedisStore.php b/src/Symfony/Component/Lock/Store/RedisStore.php index d07a2ce27f..496ce65778 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -15,7 +15,6 @@ use Symfony\Component\Cache\Traits\RedisClusterProxy; use Symfony\Component\Cache\Traits\RedisProxy; use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\LockConflictedException; -use Symfony\Component\Lock\Exception\LockExpiredException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\StoreInterface; @@ -26,6 +25,8 @@ use Symfony\Component\Lock\StoreInterface; */ class RedisStore implements StoreInterface { + use ExpiringStoreTrait; + private $redis; private $initialTtl; @@ -67,9 +68,7 @@ class RedisStore implements StoreInterface throw new LockConflictedException(); } - if ($key->isExpired()) { - throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key)); - } + $this->checkNotExpired($key); } public function waitAndSave(Key $key) @@ -95,9 +94,7 @@ class RedisStore implements StoreInterface throw new LockConflictedException(); } - if ($key->isExpired()) { - throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key)); - } + $this->checkNotExpired($key); } /** diff --git a/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php b/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php index f523780ce1..bc96ff66f5 100644 --- a/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php +++ b/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Lock\Tests\Store; +use Symfony\Component\Lock\Exception\LockExpiredException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\StoreInterface; @@ -105,4 +106,28 @@ trait ExpiringStoreTestTrait $this->assertGreaterThanOrEqual(0, $key->getRemainingLifetime()); $this->assertLessThanOrEqual(1, $key->getRemainingLifetime()); } + + public function testExpiredLockCleaned() + { + $resource = uniqid(__METHOD__, true); + + $key1 = new Key($resource); + $key2 = new Key($resource); + + /** @var StoreInterface $store */ + $store = $this->getStore(); + $key1->reduceLifetime(0); + + $this->assertTrue($key1->isExpired()); + try { + $store->save($key1); + $this->fail('The store shouldn\'t have save an expired key'); + } catch (LockExpiredException $e) { + } + + $this->assertFalse($store->exists($key1)); + + $store->save($key2); + $this->assertTrue($store->exists($key2)); + } } diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php index 766a643edd..dac472e2b0 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/Api/MailgunTransport.php @@ -27,15 +27,17 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ class MailgunTransport extends AbstractApiTransport { - private const ENDPOINT = 'https://api.mailgun.net/v3/%domain%/messages'; + private const ENDPOINT = 'https://api.%region_dot%mailgun.net/v3/%domain%/messages'; private $key; private $domain; + private $region; - public function __construct(string $key, string $domain, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + public function __construct(string $key, string $domain, string $region = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) { $this->key = $key; $this->domain = $domain; + $this->region = $region; parent::__construct($client, $dispatcher, $logger); } @@ -48,7 +50,7 @@ class MailgunTransport extends AbstractApiTransport $headers[] = $header->toString(); } - $endpoint = str_replace('%domain%', urlencode($this->domain), self::ENDPOINT); + $endpoint = str_replace(['%domain%', '%region_dot%'], [urlencode($this->domain), 'us' !== ($this->region ?: 'us') ? $this->region.'.' : ''], self::ENDPOINT); $response = $this->client->request('POST', $endpoint, [ 'auth_basic' => 'api:'.$this->key, 'headers' => $headers, diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php index 727ca9ddee..cd7b2254ec 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/Http/MailgunTransport.php @@ -27,14 +27,16 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ class MailgunTransport extends AbstractHttpTransport { - private const ENDPOINT = 'https://api.mailgun.net/v3/%domain%/messages.mime'; + private const ENDPOINT = 'https://api.%region_dot%mailgun.net/v3/%domain%/messages.mime'; private $key; private $domain; + private $region; - public function __construct(string $key, string $domain, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + public function __construct(string $key, string $domain, string $region = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) { $this->key = $key; $this->domain = $domain; + $this->region = $region; parent::__construct($client, $dispatcher, $logger); } @@ -49,7 +51,7 @@ class MailgunTransport extends AbstractHttpTransport foreach ($body->getPreparedHeaders()->getAll() as $header) { $headers[] = $header->toString(); } - $endpoint = str_replace('%domain%', urlencode($this->domain), self::ENDPOINT); + $endpoint = str_replace(['%domain%', '%region_dot%'], [urlencode($this->domain), 'us' !== ($this->region ?: 'us') ? $this->region.'.' : ''], self::ENDPOINT); $response = $this->client->request('POST', $endpoint, [ 'auth_basic' => 'api:'.$this->key, 'headers' => $headers, diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php index 5173b0cd52..fa68465d12 100644 --- a/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/Smtp/MailgunTransport.php @@ -22,9 +22,9 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; */ class MailgunTransport extends EsmtpTransport { - public function __construct(string $username, string $password, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + public function __construct(string $username, string $password, string $region = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) { - parent::__construct('smtp.mailgun.org', 465, 'ssl', null, $dispatcher, $logger); + parent::__construct('us' !== ($region ?: 'us') ? sprintf('smtp.%s.mailgun.org', $region) : 'smtp.mailgun.org', 465, 'ssl', null, $dispatcher, $logger); $this->setUsername($username); $this->setPassword($password); diff --git a/src/Symfony/Component/Mailer/Tests/TransportTest.php b/src/Symfony/Component/Mailer/Tests/TransportTest.php index 84b59d9947..3c15480f2c 100644 --- a/src/Symfony/Component/Mailer/Tests/TransportTest.php +++ b/src/Symfony/Component/Mailer/Tests/TransportTest.php @@ -22,8 +22,10 @@ use Symfony\Component\Mailer\Bridge\Sendgrid; use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Email; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; class TransportTest extends TestCase { @@ -106,6 +108,15 @@ class TransportTest extends TestCase $this->assertEquals('pa$s', $transport->getPassword()); $this->assertProperties($transport, $dispatcher, $logger); + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, null, $logger); + $this->assertEquals('smtp.mailgun.org', $transport->getStream()->getHost()); + + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun?region=eu', $dispatcher, null, $logger); + $this->assertEquals('smtp.eu.mailgun.org', $transport->getStream()->getHost()); + + $transport = Transport::fromDsn('smtp://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun?region=us', $dispatcher, null, $logger); + $this->assertEquals('smtp.mailgun.org', $transport->getStream()->getHost()); + $client = $this->createMock(HttpClientInterface::class); $transport = Transport::fromDsn('http://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, $client, $logger); $this->assertInstanceOf(Mailgun\Http\MailgunTransport::class, $transport); @@ -115,6 +126,25 @@ class TransportTest extends TestCase 'client' => $client, ]); + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->any())->method('getStatusCode')->willReturn(200); + $message = (new Email())->from('me@me.com')->to('you@you.com')->subject('hello')->text('Hello you'); + + $client = $this->createMock(HttpClientInterface::class); + $client->expects($this->once())->method('request')->with('POST', 'https://api.mailgun.net/v3/pa%24s/messages.mime')->willReturn($response); + $transport = Transport::fromDsn('http://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, $client, $logger); + $transport->send($message); + + $client = $this->createMock(HttpClientInterface::class); + $client->expects($this->once())->method('request')->with('POST', 'https://api.eu.mailgun.net/v3/pa%24s/messages.mime')->willReturn($response); + $transport = Transport::fromDsn('http://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun?region=eu', $dispatcher, $client, $logger); + $transport->send($message); + + $client = $this->createMock(HttpClientInterface::class); + $client->expects($this->once())->method('request')->with('POST', 'https://api.mailgun.net/v3/pa%24s/messages.mime')->willReturn($response); + $transport = Transport::fromDsn('http://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun?region=us', $dispatcher, $client, $logger); + $transport->send($message); + $transport = Transport::fromDsn('api://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, $client, $logger); $this->assertInstanceOf(Mailgun\Http\Api\MailgunTransport::class, $transport); $this->assertProperties($transport, $dispatcher, $logger, [ @@ -123,6 +153,21 @@ class TransportTest extends TestCase 'client' => $client, ]); + $client = $this->createMock(HttpClientInterface::class); + $client->expects($this->once())->method('request')->with('POST', 'https://api.mailgun.net/v3/pa%24s/messages')->willReturn($response); + $transport = Transport::fromDsn('api://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun', $dispatcher, $client, $logger); + $transport->send($message); + + $client = $this->createMock(HttpClientInterface::class); + $client->expects($this->once())->method('request')->with('POST', 'https://api.eu.mailgun.net/v3/pa%24s/messages')->willReturn($response); + $transport = Transport::fromDsn('api://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun?region=eu', $dispatcher, $client, $logger); + $transport->send($message); + + $client = $this->createMock(HttpClientInterface::class); + $client->expects($this->once())->method('request')->with('POST', 'https://api.mailgun.net/v3/pa%24s/messages')->willReturn($response); + $transport = Transport::fromDsn('api://'.urlencode('u$er').':'.urlencode('pa$s').'@mailgun?region=us', $dispatcher, $client, $logger); + $transport->send($message); + $this->expectException(LogicException::class); Transport::fromDsn('foo://mailgun'); } diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index cc90eef0d8..690608c9f3 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -101,13 +101,13 @@ class Transport } if ('smtp' === $parsedDsn['scheme']) { - return new Mailgun\Smtp\MailgunTransport($user, $pass, $dispatcher, $logger); + return new Mailgun\Smtp\MailgunTransport($user, $pass, $query['region'] ?? null, $dispatcher, $logger); } if ('http' === $parsedDsn['scheme']) { - return new Mailgun\Http\MailgunTransport($user, $pass, $client, $dispatcher, $logger); + return new Mailgun\Http\MailgunTransport($user, $pass, $query['region'] ?? null, $client, $dispatcher, $logger); } if ('api' === $parsedDsn['scheme']) { - return new Mailgun\Http\Api\MailgunTransport($user, $pass, $client, $dispatcher, $logger); + return new Mailgun\Http\Api\MailgunTransport($user, $pass, $query['region'] ?? null, $client, $dispatcher, $logger); } throw new LogicException(sprintf('The "%s" scheme is not supported for mailer "%s".', $parsedDsn['scheme'], $parsedDsn['host'])); diff --git a/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php b/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php index f018d5b6a1..e9d0e897d2 100644 --- a/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php +++ b/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php @@ -64,7 +64,7 @@ class HandleMessageMiddleware implements MiddlewareInterface $handler = $handlerDescriptor->getHandler(); $handledStamp = HandledStamp::fromDescriptor($handlerDescriptor, $handler($message)); $envelope = $envelope->with($handledStamp); - $this->logger->info('Message "{class}" handled by "{handler}"', $context + ['handler' => $handledStamp->getHandlerName()]); + $this->logger->info('Message {class} handled by {handler}', $context + ['handler' => $handledStamp->getHandlerName()]); } catch (\Throwable $e) { $exceptions[] = $e; } @@ -75,7 +75,7 @@ class HandleMessageMiddleware implements MiddlewareInterface throw new NoHandlerForMessageException(sprintf('No handler for message "%s".', $context['class'])); } - $this->logger->info('No handler for message "{class}"', $context); + $this->logger->info('No handler for message {class}', $context); } if (\count($exceptions)) { diff --git a/src/Symfony/Component/Messenger/Middleware/SendMessageMiddleware.php b/src/Symfony/Component/Messenger/Middleware/SendMessageMiddleware.php index 2d401bc850..969a120cbf 100644 --- a/src/Symfony/Component/Messenger/Middleware/SendMessageMiddleware.php +++ b/src/Symfony/Component/Messenger/Middleware/SendMessageMiddleware.php @@ -54,37 +54,30 @@ class SendMessageMiddleware implements MiddlewareInterface $sender = null; - try { - if ($envelope->all(ReceivedStamp::class)) { - // it's a received message, do not send it back - $this->logger->info('Received message "{class}"', $context); - } else { - /** @var RedeliveryStamp|null $redeliveryStamp */ - $redeliveryStamp = $envelope->last(RedeliveryStamp::class); + if ($envelope->all(ReceivedStamp::class)) { + // it's a received message, do not send it back + $this->logger->info('Received message {class}', $context); + } else { + /** @var RedeliveryStamp|null $redeliveryStamp */ + $redeliveryStamp = $envelope->last(RedeliveryStamp::class); - // dispatch event unless this is a redelivery - $shouldDispatchEvent = null === $redeliveryStamp; - foreach ($this->getSenders($envelope, $redeliveryStamp) as $alias => $sender) { - if (null !== $this->eventDispatcher && $shouldDispatchEvent) { - $event = new SendMessageToTransportsEvent($envelope); - $this->eventDispatcher->dispatch($event); - $envelope = $event->getEnvelope(); - $shouldDispatchEvent = false; - } - - $this->logger->info('Sending message "{class}" with "{sender}"', $context + ['sender' => \get_class($sender)]); - $envelope = $sender->send($envelope->with(new SentStamp(\get_class($sender), \is_string($alias) ? $alias : null))); + // dispatch event unless this is a redelivery + $shouldDispatchEvent = null === $redeliveryStamp; + foreach ($this->getSenders($envelope, $redeliveryStamp) as $alias => $sender) { + if (null !== $this->eventDispatcher && $shouldDispatchEvent) { + $event = new SendMessageToTransportsEvent($envelope); + $this->eventDispatcher->dispatch($event); + $envelope = $event->getEnvelope(); + $shouldDispatchEvent = false; } - } - if (null === $sender) { - return $stack->next()->handle($envelope, $stack); + $this->logger->info('Sending message {class} with {sender}', $context + ['sender' => \get_class($sender)]); + $envelope = $sender->send($envelope->with(new SentStamp(\get_class($sender), \is_string($alias) ? $alias : null))); } - } catch (\Throwable $e) { - $context['exception'] = $e; - $this->logger->warning('An exception occurred while handling message "{class}": '.$e->getMessage(), $context); + } - throw $e; + if (null === $sender) { + return $stack->next()->handle($envelope, $stack); } // message should only be sent and not be handled by the next middleware diff --git a/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php b/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php index e883b3ffd9..8a44f5fa84 100644 --- a/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php +++ b/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php @@ -70,14 +70,14 @@ class MultiplierRetryStrategy implements RetryStrategyInterface return true; } - $retries = $this->getCurrentRetryCount($message); + $retries = RedeliveryStamp::getRetryCountFromEnvelope($message); return $retries < $this->maxRetries; } public function getWaitingTime(Envelope $message): int { - $retries = $this->getCurrentRetryCount($message); + $retries = RedeliveryStamp::getRetryCountFromEnvelope($message); $delay = $this->delayMilliseconds * pow($this->multiplier, $retries); @@ -87,12 +87,4 @@ class MultiplierRetryStrategy implements RetryStrategyInterface return $delay; } - - private function getCurrentRetryCount(Envelope $message): int - { - /** @var RedeliveryStamp|null $retryMessageStamp */ - $retryMessageStamp = $message->last(RedeliveryStamp::class); - - return $retryMessageStamp ? $retryMessageStamp->getRetryCount() : 0; - } } diff --git a/src/Symfony/Component/Messenger/Stamp/RedeliveryStamp.php b/src/Symfony/Component/Messenger/Stamp/RedeliveryStamp.php index 265ee1a628..2895ad9b83 100644 --- a/src/Symfony/Component/Messenger/Stamp/RedeliveryStamp.php +++ b/src/Symfony/Component/Messenger/Stamp/RedeliveryStamp.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Messenger\Stamp; use Symfony\Component\Debug\Exception\FlattenException; +use Symfony\Component\Messenger\Envelope; /** * Stamp applied when a messages needs to be redelivered. @@ -38,6 +39,14 @@ class RedeliveryStamp implements StampInterface $this->redeliveredAt = new \DateTimeImmutable(); } + public static function getRetryCountFromEnvelope(Envelope $envelope): int + { + /** @var self|null $retryMessageStamp */ + $retryMessageStamp = $envelope->last(self::class); + + return $retryMessageStamp ? $retryMessageStamp->getRetryCount() : 0; + } + public function getRetryCount(): int { return $this->retryCount; diff --git a/src/Symfony/Component/Messenger/Tests/Transport/AmqpExt/ConnectionTest.php b/src/Symfony/Component/Messenger/Tests/Transport/AmqpExt/ConnectionTest.php index 549ff1f8ec..e7dee84219 100644 --- a/src/Symfony/Component/Messenger/Tests/Transport/AmqpExt/ConnectionTest.php +++ b/src/Symfony/Component/Messenger/Tests/Transport/AmqpExt/ConnectionTest.php @@ -23,16 +23,18 @@ use Symfony\Component\Messenger\Transport\AmqpExt\Connection; */ class ConnectionTest extends TestCase { + private const DEFAULT_EXCHANGE_NAME = 'messages'; + /** * @expectedException \InvalidArgumentException - * @expectedExceptionMessage The given AMQP DSN "amqp://" is invalid. + * @expectedExceptionMessage The given AMQP DSN "amqp://:" is invalid. */ public function testItCannotBeConstructedWithAWrongDsn() { - Connection::fromDsn('amqp://'); + Connection::fromDsn('amqp://:'); } - public function testItGetsParametersFromTheDsn() + public function testItCanBeConstructedWithDefaults() { $this->assertEquals( new Connection([ @@ -40,11 +42,27 @@ class ConnectionTest extends TestCase 'port' => 5672, 'vhost' => '/', ], [ - 'name' => 'messages', + 'name' => self::DEFAULT_EXCHANGE_NAME, ], [ - 'messages' => [], + self::DEFAULT_EXCHANGE_NAME => [], ]), - Connection::fromDsn('amqp://localhost/%2f/messages') + Connection::fromDsn('amqp://') + ); + } + + public function testItGetsParametersFromTheDsn() + { + $this->assertEquals( + new Connection([ + 'host' => 'host', + 'port' => 5672, + 'vhost' => '/', + ], [ + 'name' => 'custom', + ], [ + 'custom' => [], + ]), + Connection::fromDsn('amqp://host/%2f/custom') ); } @@ -52,9 +70,9 @@ class ConnectionTest extends TestCase { $this->assertEquals( new Connection([ - 'host' => 'redis', + 'host' => 'localhost', 'port' => 1234, - 'vhost' => '/', + 'vhost' => 'vhost', 'login' => 'guest', 'password' => 'password', ], [ @@ -62,7 +80,7 @@ class ConnectionTest extends TestCase ], [ 'queueName' => [], ]), - Connection::fromDsn('amqp://guest:password@redis:1234/%2f/queue?exchange[name]=exchangeName&queues[queueName]') + Connection::fromDsn('amqp://guest:password@localhost:1234/vhost/queue?exchange[name]=exchangeName&queues[queueName]') ); } @@ -70,18 +88,16 @@ class ConnectionTest extends TestCase { $this->assertEquals( new Connection([ - 'host' => 'redis', - 'port' => 1234, + 'host' => 'localhost', + 'port' => 5672, 'vhost' => '/', - 'login' => 'guest', - 'password' => 'password', 'persistent' => 'true', ], [ 'name' => 'exchangeName', ], [ 'queueName' => [], ]), - Connection::fromDsn('amqp://guest:password@redis:1234/%2f/queue?exchange[name]=exchangeName&queues[queueName]', [ + Connection::fromDsn('amqp://localhost/%2f/queue?exchange[name]=exchangeName&queues[queueName]', [ 'persistent' => 'true', 'exchange' => ['name' => 'toBeOverwritten'], ]) @@ -182,7 +198,7 @@ class ConnectionTest extends TestCase $amqpChannel->expects($this->once())->method('isConnected')->willReturn(true); $amqpConnection->expects($this->once())->method('connect'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', [], $factory); + $connection = Connection::fromDsn('amqp://localhost', [], $factory); $connection->publish('body'); } @@ -199,7 +215,7 @@ class ConnectionTest extends TestCase $amqpChannel->expects($this->once())->method('isConnected')->willReturn(true); $amqpConnection->expects($this->once())->method('pconnect'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages?persistent=true', [], $factory); + $connection = Connection::fromDsn('amqp://localhost?persistent=true', [], $factory); $connection->publish('body'); } @@ -212,13 +228,12 @@ class ConnectionTest extends TestCase $amqpExchange = $this->createMock(\AMQPExchange::class) ); - $amqpExchange->method('getName')->willReturn('exchange_name'); $amqpExchange->expects($this->once())->method('declareExchange'); $amqpExchange->expects($this->once())->method('publish')->with('body', null, AMQP_NOPARAM, ['headers' => []]); $amqpQueue->expects($this->once())->method('declareQueue'); - $amqpQueue->expects($this->once())->method('bind')->with('exchange_name', null); + $amqpQueue->expects($this->once())->method('bind')->with(self::DEFAULT_EXCHANGE_NAME, null); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', [], $factory); + $connection = Connection::fromDsn('amqp://localhost', [], $factory); $connection->publish('body'); } @@ -236,21 +251,20 @@ class ConnectionTest extends TestCase $factory->method('createExchange')->willReturn($amqpExchange); $factory->method('createQueue')->will($this->onConsecutiveCalls($amqpQueue0, $amqpQueue1)); - $amqpExchange->method('getName')->willReturn('exchange_name'); $amqpExchange->expects($this->once())->method('declareExchange'); $amqpExchange->expects($this->once())->method('publish')->with('body', 'routing_key', AMQP_NOPARAM, ['headers' => []]); $amqpQueue0->expects($this->once())->method('declareQueue'); $amqpQueue0->expects($this->exactly(2))->method('bind')->withConsecutive( - ['exchange_name', 'binding_key0'], - ['exchange_name', 'binding_key1'] + [self::DEFAULT_EXCHANGE_NAME, 'binding_key0'], + [self::DEFAULT_EXCHANGE_NAME, 'binding_key1'] ); $amqpQueue1->expects($this->once())->method('declareQueue'); $amqpQueue1->expects($this->exactly(2))->method('bind')->withConsecutive( - ['exchange_name', 'binding_key2'], - ['exchange_name', 'binding_key3'] + [self::DEFAULT_EXCHANGE_NAME, 'binding_key2'], + [self::DEFAULT_EXCHANGE_NAME, 'binding_key3'] ); - $dsn = 'amqp://localhost/%2f/messages?'. + $dsn = 'amqp://localhost?'. 'exchange[default_publish_routing_key]=routing_key&'. 'queues[queue0][binding_keys][0]=binding_key0&'. 'queues[queue0][binding_keys][1]=binding_key1&'. @@ -270,18 +284,17 @@ class ConnectionTest extends TestCase $amqpExchange = $this->createMock(\AMQPExchange::class) ); - $amqpExchange->method('getName')->willReturn('exchange_name'); $amqpExchange->expects($this->never())->method('declareExchange'); $amqpQueue->expects($this->never())->method('declareQueue'); $amqpQueue->expects($this->never())->method('bind'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', ['auto_setup' => 'false'], $factory); + $connection = Connection::fromDsn('amqp://localhost', ['auto_setup' => 'false'], $factory); $connection->publish('body'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', ['auto_setup' => false], $factory); + $connection = Connection::fromDsn('amqp://localhost', ['auto_setup' => false], $factory); $connection->publish('body'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages?auto_setup=false', [], $factory); + $connection = Connection::fromDsn('amqp://localhost?auto_setup=false', [], $factory); $connection->publish('body'); } @@ -298,9 +311,9 @@ class ConnectionTest extends TestCase $amqpChannel->expects($this->exactly(2))->method('isConnected')->willReturn(true); $amqpChannel->expects($this->exactly(2))->method('setPrefetchCount')->with(2); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages?prefetch_count=2', [], $factory); + $connection = Connection::fromDsn('amqp://localhost?prefetch_count=2', [], $factory); $connection->setup(); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', ['prefetch_count' => 2], $factory); + $connection = Connection::fromDsn('amqp://localhost', ['prefetch_count' => 2], $factory); $connection->setup(); } @@ -315,29 +328,29 @@ class ConnectionTest extends TestCase $factory->method('createChannel')->willReturn($amqpChannel); $factory->method('createQueue')->willReturn($delayQueue); $factory->method('createExchange')->will($this->onConsecutiveCalls( - $delayExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock(), - $amqpExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock() + $amqpExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock(), + $delayExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock() )); - $amqpExchange->expects($this->once())->method('setName')->with('messages'); - $amqpExchange->method('getName')->willReturn('messages'); + $amqpExchange->expects($this->once())->method('setName')->with(self::DEFAULT_EXCHANGE_NAME); + $amqpExchange->expects($this->once())->method('declareExchange'); $delayExchange->expects($this->once())->method('setName')->with('delay'); $delayExchange->expects($this->once())->method('declareExchange'); - $delayExchange->method('getName')->willReturn('delay'); - $delayQueue->expects($this->once())->method('setName')->with('delay_queue__5000'); + $delayQueue->expects($this->once())->method('setName')->with('delay_queue_messages__5000'); $delayQueue->expects($this->once())->method('setArguments')->with([ 'x-message-ttl' => 5000, - 'x-dead-letter-exchange' => 'messages', + 'x-dead-letter-exchange' => self::DEFAULT_EXCHANGE_NAME, + 'x-dead-letter-routing-key' => '', ]); $delayQueue->expects($this->once())->method('declareQueue'); - $delayQueue->expects($this->once())->method('bind')->with('delay', 'delay__5000'); + $delayQueue->expects($this->once())->method('bind')->with('delay', 'delay_messages__5000'); - $delayExchange->expects($this->once())->method('publish')->with('{}', 'delay__5000', AMQP_NOPARAM, ['headers' => ['x-some-headers' => 'foo']]); + $delayExchange->expects($this->once())->method('publish')->with('{}', 'delay_messages__5000', AMQP_NOPARAM, ['headers' => ['x-some-headers' => 'foo']]); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', [], $factory); + $connection = Connection::fromDsn('amqp://localhost', [], $factory); $connection->publish('{}', ['x-some-headers' => 'foo'], 5000); } @@ -352,16 +365,15 @@ class ConnectionTest extends TestCase $factory->method('createChannel')->willReturn($amqpChannel); $factory->method('createQueue')->willReturn($delayQueue); $factory->method('createExchange')->will($this->onConsecutiveCalls( - $delayExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock(), - $amqpExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock() + $amqpExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock(), + $delayExchange = $this->getMockBuilder(\AMQPExchange::class)->disableOriginalConstructor()->getMock() )); - $amqpExchange->expects($this->once())->method('setName')->with('messages'); - $amqpExchange->method('getName')->willReturn('messages'); + $amqpExchange->expects($this->once())->method('setName')->with(self::DEFAULT_EXCHANGE_NAME); + $amqpExchange->expects($this->once())->method('declareExchange'); $delayExchange->expects($this->once())->method('setName')->with('delay'); $delayExchange->expects($this->once())->method('declareExchange'); - $delayExchange->method('getName')->willReturn('delay'); $connectionOptions = [ 'retry' => [ @@ -369,24 +381,25 @@ class ConnectionTest extends TestCase ], ]; - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', $connectionOptions, $factory); + $connection = Connection::fromDsn('amqp://localhost', $connectionOptions, $factory); - $delayQueue->expects($this->once())->method('setName')->with('delay_queue__120000'); + $delayQueue->expects($this->once())->method('setName')->with('delay_queue_messages__120000'); $delayQueue->expects($this->once())->method('setArguments')->with([ 'x-message-ttl' => 120000, - 'x-dead-letter-exchange' => 'messages', + 'x-dead-letter-exchange' => self::DEFAULT_EXCHANGE_NAME, + 'x-dead-letter-routing-key' => '', ]); $delayQueue->expects($this->once())->method('declareQueue'); - $delayQueue->expects($this->once())->method('bind')->with('delay', 'delay__120000'); + $delayQueue->expects($this->once())->method('bind')->with('delay', 'delay_messages__120000'); - $delayExchange->expects($this->once())->method('publish')->with('{}', 'delay__120000', AMQP_NOPARAM, ['headers' => []]); + $delayExchange->expects($this->once())->method('publish')->with('{}', 'delay_messages__120000', AMQP_NOPARAM, ['headers' => []]); $connection->publish('{}', [], 120000); } /** * @expectedException \AMQPException - * @expectedExceptionMessage Could not connect to the AMQP server. Please verify the provided DSN. ({"delay":{"routing_key_pattern":"delay_%routing_key%_%delay%","exchange_name":"delay","queue_name_pattern":"delay_queue_%routing_key%_%delay%"},"host":"localhost","port":5672,"vhost":"\/","login":"user","password":"********"}) + * @expectedExceptionMessage Could not connect to the AMQP server. Please verify the provided DSN. ({"host":"localhost","port":5672,"vhost":"\/","login":"user","password":"********"}) */ public function testObfuscatePasswordInDsn() { @@ -401,7 +414,7 @@ class ConnectionTest extends TestCase new \AMQPConnectionException('Oups.') ); - $connection = Connection::fromDsn('amqp://user:secretpassword@localhost/%2f/messages', [], $factory); + $connection = Connection::fromDsn('amqp://user:secretpassword@localhost', [], $factory); $connection->channel(); } @@ -416,7 +429,7 @@ class ConnectionTest extends TestCase $amqpExchange->expects($this->once())->method('publish')->with('body', 'routing_key'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages?exchange[default_publish_routing_key]=routing_key', [], $factory); + $connection = Connection::fromDsn('amqp://localhost?exchange[default_publish_routing_key]=routing_key', [], $factory); $connection->publish('body'); } @@ -431,7 +444,7 @@ class ConnectionTest extends TestCase $amqpExchange->expects($this->once())->method('publish')->with('body', 'routing_key'); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages?exchange[default_publish_routing_key]=default_routing_key', [], $factory); + $connection = Connection::fromDsn('amqp://localhost?exchange[default_publish_routing_key]=default_routing_key', [], $factory); $connection->publish('body', [], 0, new AmqpStamp('routing_key')); } @@ -446,16 +459,15 @@ class ConnectionTest extends TestCase $factory->method('createChannel')->willReturn($amqpChannel); $factory->method('createQueue')->willReturn($delayQueue); $factory->method('createExchange')->will($this->onConsecutiveCalls( - $delayExchange = $this->createMock(\AMQPExchange::class), - $amqpExchange = $this->createMock(\AMQPExchange::class) + $amqpExchange = $this->createMock(\AMQPExchange::class), + $delayExchange = $this->createMock(\AMQPExchange::class) )); - $amqpExchange->expects($this->once())->method('setName')->with('messages'); - $amqpExchange->method('getName')->willReturn('messages'); + $amqpExchange->expects($this->once())->method('setName')->with(self::DEFAULT_EXCHANGE_NAME); + $amqpExchange->expects($this->once())->method('declareExchange'); $delayExchange->expects($this->once())->method('setName')->with('delay'); $delayExchange->expects($this->once())->method('declareExchange'); - $delayExchange->method('getName')->willReturn('delay'); $connectionOptions = [ 'retry' => [ @@ -463,22 +475,19 @@ class ConnectionTest extends TestCase ], ]; - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', $connectionOptions, $factory); + $connection = Connection::fromDsn('amqp://localhost', $connectionOptions, $factory); - $delayQueue->expects($this->once())->method('setName')->with('delay_queue_routing_key_120000'); + $delayQueue->expects($this->once())->method('setName')->with('delay_queue_messages_routing_key_120000'); $delayQueue->expects($this->once())->method('setArguments')->with([ 'x-message-ttl' => 120000, - 'x-dead-letter-exchange' => 'messages', + 'x-dead-letter-exchange' => self::DEFAULT_EXCHANGE_NAME, + 'x-dead-letter-routing-key' => 'routing_key', ]); - $delayQueue->expects($this->once())->method('setArgument')->with( - 'x-dead-letter-routing-key', - 'routing_key' - ); $delayQueue->expects($this->once())->method('declareQueue'); - $delayQueue->expects($this->once())->method('bind')->with('delay', 'delay_routing_key_120000'); + $delayQueue->expects($this->once())->method('bind')->with('delay', 'delay_messages_routing_key_120000'); - $delayExchange->expects($this->once())->method('publish')->with('{}', 'delay_routing_key_120000', AMQP_NOPARAM, ['headers' => []]); + $delayExchange->expects($this->once())->method('publish')->with('{}', 'delay_messages_routing_key_120000', AMQP_NOPARAM, ['headers' => []]); $connection->publish('{}', [], 120000, new AmqpStamp('routing_key')); } @@ -498,7 +507,7 @@ class ConnectionTest extends TestCase ['delivery_mode' => 2, 'headers' => ['type' => DummyMessage::class]] ); - $connection = Connection::fromDsn('amqp://localhost/%2f/messages', [], $factory); + $connection = Connection::fromDsn('amqp://localhost', [], $factory); $connection->publish('body', ['type' => DummyMessage::class], 0, new AmqpStamp('routing_key', AMQP_IMMEDIATE, ['delivery_mode' => 2])); } } diff --git a/src/Symfony/Component/Messenger/Transport/AmqpExt/Connection.php b/src/Symfony/Component/Messenger/Transport/AmqpExt/Connection.php index 721949c069..6f6b8b22a6 100644 --- a/src/Symfony/Component/Messenger/Transport/AmqpExt/Connection.php +++ b/src/Symfony/Component/Messenger/Transport/AmqpExt/Connection.php @@ -58,8 +58,22 @@ class Connection */ private $amqpDelayExchange; + public function __construct(array $connectionOptions, array $exchangeOptions, array $queuesOptions, AmqpFactory $amqpFactory = null) + { + $this->connectionOptions = array_replace_recursive([ + 'delay' => [ + 'routing_key_pattern' => 'delay_%exchange_name%_%routing_key%_%delay%', + 'exchange_name' => 'delay', + 'queue_name_pattern' => 'delay_queue_%exchange_name%_%routing_key%_%delay%', + ], + ], $connectionOptions); + $this->exchangeOptions = $exchangeOptions; + $this->queuesOptions = $queuesOptions; + $this->amqpFactory = $amqpFactory ?: new AmqpFactory(); + } + /** - * Constructor. + * Creates a connection based on the DSN and options. * * Available options: * @@ -79,31 +93,21 @@ class Connection * * flags: Exchange flags (Default: AMQP_DURABLE) * * arguments: Extra arguments * * delay: - * * routing_key_pattern: The pattern of the routing key (Default: "delay_%routing_key%_%delay%") - * * queue_name_pattern: Pattern to use to create the queues (Default: "delay_queue_%routing_key%_%delay%") - * * exchange_name: Name of the exchange to be used for the retried messages (Default: "retry") + * * routing_key_pattern: The pattern of the routing key (Default: "delay_%exchange_name%_%routing_key%_%delay%") + * * queue_name_pattern: Pattern to use to create the queues (Default: "delay_queue_%exchange_name%_%routing_key%_%delay%") + * * exchange_name: Name of the exchange to be used for the retried messages (Default: "delay") * * auto_setup: Enable or not the auto-setup of queues and exchanges (Default: true) - * * loop_sleep: Amount of micro-seconds to wait if no message are available (Default: 200000) * * prefetch_count: set channel prefetch count */ - public function __construct(array $connectionOptions, array $exchangeOptions, array $queuesOptions, AmqpFactory $amqpFactory = null) - { - $this->connectionOptions = array_replace_recursive([ - 'delay' => [ - 'routing_key_pattern' => 'delay_%routing_key%_%delay%', - 'exchange_name' => 'delay', - 'queue_name_pattern' => 'delay_queue_%routing_key%_%delay%', - ], - ], $connectionOptions); - $this->exchangeOptions = $exchangeOptions; - $this->queuesOptions = $queuesOptions; - $this->amqpFactory = $amqpFactory ?: new AmqpFactory(); - } - public static function fromDsn(string $dsn, array $options = [], AmqpFactory $amqpFactory = null): self { if (false === $parsedUrl = parse_url($dsn)) { - throw new InvalidArgumentException(sprintf('The given AMQP DSN "%s" is invalid.', $dsn)); + // this is a valid URI that parse_url cannot handle when you want to pass all parameters as options + if ('amqp://' !== $dsn) { + throw new InvalidArgumentException(sprintf('The given AMQP DSN "%s" is invalid.', $dsn)); + } + + $parsedUrl = []; } $pathParts = isset($parsedUrl['path']) ? explode('/', trim($parsedUrl['path'], '/')) : []; @@ -242,12 +246,12 @@ class Connection $this->clear(); } - $exchange = $this->getDelayExchange(); - $exchange->declareExchange(); + $this->exchange()->declareExchange(); // setup normal exchange for delay queue to DLX messages to + $this->getDelayExchange()->declareExchange(); $queue = $this->createDelayQueue($delay, $routingKey); $queue->declareQueue(); - $queue->bind($exchange->getName(), $this->getRoutingKeyForDelay($delay, $routingKey)); + $queue->bind($this->connectionOptions['delay']['exchange_name'], $this->getRoutingKeyForDelay($delay, $routingKey)); } private function getDelayExchange(): \AMQPExchange @@ -274,28 +278,26 @@ class Connection { $queue = $this->amqpFactory->createQueue($this->channel()); $queue->setName(str_replace( - ['%delay%', '%routing_key%'], - [$delay, $routingKey ?: ''], + ['%delay%', '%exchange_name%', '%routing_key%'], + [$delay, $this->exchangeOptions['name'], $routingKey ?? ''], $this->connectionOptions['delay']['queue_name_pattern'] - )); + )); $queue->setArguments([ 'x-message-ttl' => $delay, - 'x-dead-letter-exchange' => $this->exchange()->getName(), + 'x-dead-letter-exchange' => $this->exchangeOptions['name'], + // after being released from to DLX, make sure the original routing key will be used + // we must use an empty string instead of null for the argument to be picked up + 'x-dead-letter-routing-key' => $routingKey ?? '', ]); - if (null !== $routingKey) { - // after being released from to DLX, this routing key will be used - $queue->setArgument('x-dead-letter-routing-key', $routingKey); - } - return $queue; } private function getRoutingKeyForDelay(int $delay, ?string $finalRoutingKey): string { return str_replace( - ['%delay%', '%routing_key%'], - [$delay, $finalRoutingKey ?: ''], + ['%delay%', '%exchange_name%', '%routing_key%'], + [$delay, $this->exchangeOptions['name'], $finalRoutingKey ?? ''], $this->connectionOptions['delay']['routing_key_pattern'] ); } @@ -350,7 +352,7 @@ class Connection foreach ($this->queuesOptions as $queueName => $queueConfig) { $this->queue($queueName)->declareQueue(); foreach ($queueConfig['binding_keys'] ?? [null] as $bindingKey) { - $this->queue($queueName)->bind($this->exchange()->getName(), $bindingKey); + $this->queue($queueName)->bind($this->exchangeOptions['name'], $bindingKey); } } } @@ -374,6 +376,7 @@ class Connection } catch (\AMQPConnectionException $e) { $credentials = $this->connectionOptions; $credentials['password'] = '********'; + unset($credentials['delay']); throw new \AMQPException(sprintf('Could not connect to the AMQP server. Please verify the provided DSN. (%s)', json_encode($credentials)), 0, $e); } diff --git a/src/Symfony/Component/Messenger/Worker.php b/src/Symfony/Component/Messenger/Worker.php index 4d52eb63ba..b54382bd71 100644 --- a/src/Symfony/Component/Messenger/Worker.php +++ b/src/Symfony/Component/Messenger/Worker.php @@ -140,14 +140,16 @@ class Worker implements WorkerInterface $this->dispatchEvent(new WorkerMessageFailedEvent($envelope, $transportName, $throwable, $shouldRetry)); + $retryCount = RedeliveryStamp::getRetryCountFromEnvelope($envelope); if ($shouldRetry) { - $retryCount = $this->getRetryCount($envelope) + 1; + ++$retryCount; + $delay = $retryStrategy->getWaitingTime($envelope); if (null !== $this->logger) { - $this->logger->error('Retrying {class} - retry #{retryCount}.', $context + ['retryCount' => $retryCount, 'error' => $throwable]); + $this->logger->error('Error thrown while handling message {class}. Dispatching 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 + remove ReceivedStamp - $retryEnvelope = $envelope->with(new DelayStamp($retryStrategy->getWaitingTime($envelope))) + $retryEnvelope = $envelope->with(new DelayStamp($delay)) ->with(new RedeliveryStamp($retryCount, $this->getSenderClassOrAlias($envelope))) ->withoutAll(ReceivedStamp::class); @@ -157,7 +159,7 @@ class Worker implements WorkerInterface $receiver->ack($envelope); } else { if (null !== $this->logger) { - $this->logger->critical('Rejecting {class} (removing from transport).', $context + ['error' => $throwable]); + $this->logger->critical('Error thrown while handling message {class}. Removing from transport after {retryCount} retries. Error: "{error}"', $context + ['retryCount' => $retryCount, 'error' => $throwable->getMessage(), 'exception' => $throwable]); } $receiver->reject($envelope); @@ -207,14 +209,6 @@ class Worker implements WorkerInterface return $retryStrategy->isRetryable($envelope); } - private function getRetryCount(Envelope $envelope): int - { - /** @var RedeliveryStamp|null $retryMessageStamp */ - $retryMessageStamp = $envelope->last(RedeliveryStamp::class); - - return $retryMessageStamp ? $retryMessageStamp->getRetryCount() : 0; - } - private function getSenderClassOrAlias(Envelope $envelope): string { /** @var SentStamp|null $sentStamp */ diff --git a/src/Symfony/Component/Routing/Generator/UrlGenerator.php b/src/Symfony/Component/Routing/Generator/UrlGenerator.php index 1bafee4738..e5811c73a8 100644 --- a/src/Symfony/Component/Routing/Generator/UrlGenerator.php +++ b/src/Symfony/Component/Routing/Generator/UrlGenerator.php @@ -257,16 +257,18 @@ class UrlGenerator implements UrlGeneratorInterface, ConfigurableRequirementsInt } } - if ((self::ABSOLUTE_URL === $referenceType || self::NETWORK_PATH === $referenceType) && !empty($host)) { - $port = ''; - if ('http' === $scheme && 80 != $this->context->getHttpPort()) { - $port = ':'.$this->context->getHttpPort(); - } elseif ('https' === $scheme && 443 != $this->context->getHttpsPort()) { - $port = ':'.$this->context->getHttpsPort(); - } + if (self::ABSOLUTE_URL === $referenceType || self::NETWORK_PATH === $referenceType) { + if ('' !== $host || ('' !== $scheme && 'http' !== $scheme && 'https' !== $scheme)) { + $port = ''; + if ('http' === $scheme && 80 !== $this->context->getHttpPort()) { + $port = ':'.$this->context->getHttpPort(); + } elseif ('https' === $scheme && 443 !== $this->context->getHttpsPort()) { + $port = ':'.$this->context->getHttpsPort(); + } - $schemeAuthority = self::NETWORK_PATH === $referenceType ? '//' : "$scheme://"; - $schemeAuthority .= $host.$port; + $schemeAuthority = self::NETWORK_PATH === $referenceType || '' === $scheme ? '//' : "$scheme://"; + $schemeAuthority .= $host.$port; + } } if (self::RELATIVE_PATH === $referenceType) { diff --git a/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php b/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php index 8c2963f6f7..dc5e92fc45 100644 --- a/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php +++ b/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php @@ -600,28 +600,27 @@ class UrlGeneratorTest extends TestCase public function testDefaultHostIsUsedWhenContextHostIsEmpty() { - $routes = $this->getRoutes('test', new Route('/route', ['domain' => 'my.fallback.host'], ['domain' => '.+'], [], '{domain}', ['http'])); + $routes = $this->getRoutes('test', new Route('/path', ['domain' => 'my.fallback.host'], ['domain' => '.+'], [], '{domain}')); $generator = $this->getGenerator($routes); $generator->getContext()->setHost(''); - $this->assertSame('http://my.fallback.host/app.php/route', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); + $this->assertSame('http://my.fallback.host/app.php/path', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); } - public function testDefaultHostIsUsedWhenContextHostIsEmptyAndSchemeIsNot() + public function testDefaultHostIsUsedWhenContextHostIsEmptyAndPathReferenceType() { - $routes = $this->getRoutes('test', new Route('/route', ['domain' => 'my.fallback.host'], ['domain' => '.+'], [], '{domain}', ['http', 'https'])); + $routes = $this->getRoutes('test', new Route('/path', ['domain' => 'my.fallback.host'], ['domain' => '.+'], [], '{domain}')); $generator = $this->getGenerator($routes); $generator->getContext()->setHost(''); - $generator->getContext()->setScheme('https'); - $this->assertSame('https://my.fallback.host/app.php/route', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); + $this->assertSame('//my.fallback.host/app.php/path', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_PATH)); } - public function testAbsoluteUrlFallbackToRelativeIfHostIsEmptyAndSchemeIsNot() + public function testAbsoluteUrlFallbackToPathIfHostIsEmptyAndSchemeIsHttp() { - $routes = $this->getRoutes('test', new Route('/route', [], [], [], '', ['http', 'https'])); + $routes = $this->getRoutes('test', new Route('/route')); $generator = $this->getGenerator($routes); $generator->getContext()->setHost(''); @@ -630,6 +629,39 @@ class UrlGeneratorTest extends TestCase $this->assertSame('/app.php/route', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); } + public function testAbsoluteUrlFallbackToNetworkIfSchemeIsEmptyAndHostIsNot() + { + $routes = $this->getRoutes('test', new Route('/path')); + + $generator = $this->getGenerator($routes); + $generator->getContext()->setHost('example.com'); + $generator->getContext()->setScheme(''); + + $this->assertSame('//example.com/app.php/path', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); + } + + public function testAbsoluteUrlFallbackToPathIfSchemeAndHostAreEmpty() + { + $routes = $this->getRoutes('test', new Route('/path')); + + $generator = $this->getGenerator($routes); + $generator->getContext()->setHost(''); + $generator->getContext()->setScheme(''); + + $this->assertSame('/app.php/path', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); + } + + public function testAbsoluteUrlWithNonHttpSchemeAndEmptyHost() + { + $routes = $this->getRoutes('test', new Route('/path', [], [], [], '', ['file'])); + + $generator = $this->getGenerator($routes); + $generator->getContext()->setBaseUrl(''); + $generator->getContext()->setHost(''); + + $this->assertSame('file:///path', $generator->generate('test', [], UrlGeneratorInterface::ABSOLUTE_URL)); + } + public function testGenerateNetworkPath() { $routes = $this->getRoutes('test', new Route('/{name}', [], [], [], '{locale}.example.com', ['http'])); diff --git a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php index 123ecf7dd1..123b447b5f 100644 --- a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php @@ -216,10 +216,11 @@ class CsvEncoder implements EncoderInterface, DecoderInterface if (\is_array($value)) { $this->flatten($value, $result, $keySeparator, $parentKey.$key.$keySeparator, $escapeFormulas); } else { - if ($escapeFormulas && \in_array(substr($value, 0, 1), $this->formulasStartCharacters, true)) { + if ($escapeFormulas && \in_array(substr((string) $value, 0, 1), $this->formulasStartCharacters, true)) { $result[$parentKey.$key] = "\t".$value; } else { - $result[$parentKey.$key] = $value; + // Ensures an actual value is used when dealing with true and false + $result[$parentKey.$key] = false === $value ? 0 : (true === $value ? 1 : $value); } } } diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php index 74235c46c1..490cc16a62 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php @@ -29,6 +29,24 @@ class CsvEncoderTest extends TestCase $this->encoder = new CsvEncoder(); } + public function testTrueFalseValues() + { + $data = [ + 'string' => 'foo', + 'int' => 2, + 'false' => false, + 'true' => true, + ]; + + // Check that true and false are appropriately handled + $this->assertEquals(<<<'CSV' +string,int,false,true +foo,2,0,1 + +CSV + , $this->encoder->encode($data, 'csv')); + } + public function testSupportEncoding() { $this->assertTrue($this->encoder->supportsEncoding('csv')); diff --git a/src/Symfony/Component/Validator/Constraints/IssnValidator.php b/src/Symfony/Component/Validator/Constraints/IssnValidator.php index 90dceba1fa..21b6403494 100644 --- a/src/Symfony/Component/Validator/Constraints/IssnValidator.php +++ b/src/Symfony/Component/Validator/Constraints/IssnValidator.php @@ -121,7 +121,7 @@ class IssnValidator extends ConstraintValidator for ($i = 0; $i < 7; ++$i) { // Multiply the first digit by 8, the second by 7, etc. - $checkSum += (8 - $i) * $canonical[$i]; + $checkSum += (8 - $i) * (int) $canonical[$i]; } if (0 !== $checkSum % 11) { diff --git a/src/Symfony/Component/Validator/Constraints/LuhnValidator.php b/src/Symfony/Component/Validator/Constraints/LuhnValidator.php index c69057caa6..4a515c74cb 100644 --- a/src/Symfony/Component/Validator/Constraints/LuhnValidator.php +++ b/src/Symfony/Component/Validator/Constraints/LuhnValidator.php @@ -84,7 +84,7 @@ class LuhnValidator extends ConstraintValidator // ^ ^ ^ ^ ^ // = 1+8 + 4 + 6 + 1+6 + 2 for ($i = $length - 2; $i >= 0; $i -= 2) { - $checkSum += array_sum(str_split($value[$i] * 2)); + $checkSum += array_sum(str_split((int) $value[$i] * 2)); } if (0 === $checkSum || 0 !== $checkSum % 10) { diff --git a/src/Symfony/Component/VarDumper/Caster/Caster.php b/src/Symfony/Component/VarDumper/Caster/Caster.php index 292bd67eb4..bf9e3cf356 100644 --- a/src/Symfony/Component/VarDumper/Caster/Caster.php +++ b/src/Symfony/Component/VarDumper/Caster/Caster.php @@ -48,13 +48,8 @@ class Caster */ public static function castObject($obj, $class, $hasDebugInfo = false) { - if ($hasDebugInfo) { - $a = $obj->__debugInfo(); - } elseif ($obj instanceof \Closure) { - $a = []; - } else { - $a = (array) $obj; - } + $a = $obj instanceof \Closure ? [] : (array) $obj; + if ($obj instanceof \__PHP_Incomplete_Class) { return $a; } @@ -88,6 +83,17 @@ class Caster } } + if ($hasDebugInfo && \is_array($debugInfo = $obj->__debugInfo())) { + foreach ($debugInfo as $k => $v) { + if (!isset($k[0]) || "\0" !== $k[0]) { + $k = self::PREFIX_VIRTUAL.$k; + } + + unset($a[$k]); + $a[$k] = $v; + } + } + return $a; } diff --git a/src/Symfony/Component/VarDumper/Caster/DateCaster.php b/src/Symfony/Component/VarDumper/Caster/DateCaster.php index 29c123d6ee..f4c3c26c41 100644 --- a/src/Symfony/Component/VarDumper/Caster/DateCaster.php +++ b/src/Symfony/Component/VarDumper/Caster/DateCaster.php @@ -88,7 +88,7 @@ class DateCaster if (self::PERIOD_LIMIT === $i) { $now = new \DateTimeImmutable(); $dates[] = sprintf('%s more', ($end = $p->getEndDate()) - ? ceil(($end->format('U.u') - $d->format('U.u')) / ($now->add($p->getDateInterval())->format('U.u') - $now->format('U.u'))) + ? ceil(($end->format('U.u') - $d->format('U.u')) / ((int) $now->add($p->getDateInterval())->format('U.u') - (int) $now->format('U.u'))) : $p->recurrences - $i ); break; diff --git a/src/Symfony/Contracts/HttpClient/Exception/DecodingExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/DecodingExceptionInterface.php new file mode 100644 index 0000000000..709db2189e --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/DecodingExceptionInterface.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\Contracts\HttpClient\Exception; + +/** + * When a content-type cannot be decoded to the expected representation. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface DecodingExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/ResponseInterface.php b/src/Symfony/Contracts/HttpClient/ResponseInterface.php index a7e01be84c..791be4bc61 100644 --- a/src/Symfony/Contracts/HttpClient/ResponseInterface.php +++ b/src/Symfony/Contracts/HttpClient/ResponseInterface.php @@ -12,6 +12,7 @@ namespace Symfony\Contracts\HttpClient; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; @@ -64,7 +65,8 @@ interface ResponseInterface * * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes * - * @throws TransportExceptionInterface When the body cannot be decoded or when a network error occurs + * @throws DecodingExceptionInterface When the body cannot be decoded to an array + * @throws TransportExceptionInterface When a network error occurs * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached * @throws ClientExceptionInterface On a 4xx when $throw is true * @throws ServerExceptionInterface On a 5xx when $throw is true