Merge branch '4.3' into 4.4

* 4.3:
  [Cache] replace getNsSeparator by NS_SEPARATOR on AbstractTrait
  [Cache] fix versioning with SimpleCacheAdapter
  [Messenger] fix AMQP delay queue to be per exchange
  Fix expired lock not cleaned
  [HttpClient] throw DecodingExceptionInterface when toArray() fails because of content-type error
  [HttpFoundation] Fix SA/phpdoc JsonResponse
  [DI] Show the right class autowired when providing a non-existing class in constructor
  SimpleCacheAdapter fails to cache any item if a namespace is used
  validate composite constraints in all groups
  [Serializer] Handle true and false appropriately in CSV encoder
  [Messenger] improve logs
  [Messenger] fix delay delivery for non-fanout exchanges
  Parameterize Mailgun's region
  Fix binary operation `+`, `-` or `*` on string
  [VarDumper] fix dumping objects that implement __debugInfo()
  [HttpClient] Don't use CurlHttpClient on Windows when curl.cainfo is not set
  Add statement to fileLink to ignore href code when no fileLink.
  [Routing] fix absolute url generation when scheme is not known
This commit is contained in:
Nicolas Grekas 2019-06-17 19:38:10 +02:00
commit 651de173df
55 changed files with 606 additions and 245 deletions

View File

@ -557,8 +557,11 @@ class TextDescriptor extends Descriptor
}
$fileLink = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine());
if ($fileLink) {
return sprintf('<href=%s>%s</>', $fileLink, $anchorText);
}
return sprintf('<href=%s>%s</>', $fileLink, $anchorText);
return $anchorText;
}
private function formatCallable($callable): string

View File

@ -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));
}

View File

@ -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;

View File

@ -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;

View File

@ -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)])));

View File

@ -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.');
}
}

View File

@ -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.');
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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();

View File

@ -0,0 +1,10 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
class ConstructNotExists
{
public function __construct(NotExist $notExist)
{
}
}

View File

@ -419,3 +419,10 @@ class NonAutowirableDecorator implements DecoratorInterface
{
}
}
final class ElsaAction
{
public function __construct(NotExisting $notExisting)
{
}
}

View File

@ -0,0 +1,7 @@
services:
_defaults:
public: true
autowire: true
autoconfigure: true
Symfony\Component\DependencyInjection\Tests\Fixtures\ConstructNotExists: ~

View File

@ -810,6 +810,18 @@ class YamlFileLoaderTest extends TestCase
], array_map(function (BoundArgument $v) { return $v->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();

View File

@ -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');
}

View File

@ -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',

View File

@ -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;
}
}
}
}

View File

@ -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();

View File

@ -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
{
}

View File

@ -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);

View File

@ -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'));
}

View File

@ -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": {

View File

@ -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 = [])
{

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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));
}

View File

@ -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;

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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));
}
}
}

View File

@ -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);
}
/**

View File

@ -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);
}
/**

View File

@ -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));
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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');
}

View File

@ -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']));

View File

@ -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)) {

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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]));
}
}

View File

@ -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);
}

View File

@ -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 */

View File

@ -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) {

View File

@ -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']));

View File

@ -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);
}
}
}

View File

@ -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'));

View File

@ -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) {

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface DecodingExceptionInterface extends ExceptionInterface
{
}

View File

@ -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