Merge branch '4.4'

* 4.4:
  [HttpClient] Fix a bug preventing Server Pushes to be handled properly
  [HttpClient] fix support for 103 Early Hints and other informational status codes
  fix typo
  [DI] fix failure
  [Validator] Add ConstraintValidator::formatValue() tests
  [HttpClient] improve handling of HTTP/2 PUSH
  Fix #33427
  lint all templates from configured Twig paths if no argument was provided
  Nullable message id?
  [Validator] Only handle numeric values in DivisibleBy
  [Validator] Sync string to date behavior and throw a better exception
  Check phpunit configuration for listeners
  registering basic exception handler for late failures
  [DI] fix support for "!tagged_locator foo"
  [Mailer] Add a more precise exception
  [ErrorHandler][Bridge/PhpUnit] display deprecations for not-autoloaded classes
This commit is contained in:
Nicolas Grekas 2019-09-03 23:48:54 +02:00
commit 2ec3e47001
52 changed files with 597 additions and 134 deletions

View File

@ -32,4 +32,4 @@ foreach ($loader->getClassMap() as $class => $file) {
class_exists($class); class_exists($class);
} }
Symfony\Component\ErrorHandler\DebugClassLoader::disable(); Symfony\Component\ErrorHandler\DebugClassLoader::checkClasses();

View File

@ -15,6 +15,7 @@ use PHPUnit\Framework\TestResult;
use PHPUnit\Util\ErrorHandler; use PHPUnit\Util\ErrorHandler;
use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Configuration; use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Configuration;
use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Deprecation; use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Deprecation;
use Symfony\Component\ErrorHandler\DebugClassLoader;
/** /**
* Catch deprecation notices and print a summary report at the end of the test suite. * Catch deprecation notices and print a summary report at the end of the test suite.
@ -173,6 +174,9 @@ class DeprecationErrorHandler
return; return;
} }
if (method_exists(DebugClassLoader::class, 'checkClasses')) {
DebugClassLoader::checkClasses();
}
$currErrorHandler = set_error_handler('var_dump'); $currErrorHandler = set_error_handler('var_dump');
restore_error_handler(); restore_error_handler();

View File

@ -23,8 +23,6 @@ class CommandForV5 extends \PHPUnit_TextUI_Command
*/ */
protected function createRunner() protected function createRunner()
{ {
$listener = new SymfonyTestsListenerForV5();
$this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : array(); $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : array();
$registeredLocally = false; $registeredLocally = false;
@ -37,8 +35,21 @@ class CommandForV5 extends \PHPUnit_TextUI_Command
} }
} }
if (isset($this->arguments['configuration'])) {
$configuration = $this->arguments['configuration'];
if (!$configuration instanceof \PHPUnit_Util_Configuration) {
$configuration = \PHPUnit_Util_Configuration::getInstance($this->arguments['configuration']);
}
foreach ($configuration->getListenerConfiguration() as $registeredListener) {
if ('Symfony\Bridge\PhpUnit\SymfonyTestsListener' === ltrim($registeredListener['class'], '\\')) {
$registeredLocally = true;
break;
}
}
}
if (!$registeredLocally) { if (!$registeredLocally) {
$this->arguments['listeners'][] = $listener; $this->arguments['listeners'][] = new SymfonyTestsListenerForV5();
} }
return parent::createRunner(); return parent::createRunner();

View File

@ -13,6 +13,7 @@ namespace Symfony\Bridge\PhpUnit\Legacy;
use PHPUnit\TextUI\Command as BaseCommand; use PHPUnit\TextUI\Command as BaseCommand;
use PHPUnit\TextUI\TestRunner as BaseRunner; use PHPUnit\TextUI\TestRunner as BaseRunner;
use PHPUnit\Util\Configuration;
use Symfony\Bridge\PhpUnit\SymfonyTestsListener; use Symfony\Bridge\PhpUnit\SymfonyTestsListener;
/** /**
@ -27,8 +28,6 @@ class CommandForV6 extends BaseCommand
*/ */
protected function createRunner(): BaseRunner protected function createRunner(): BaseRunner
{ {
$listener = new SymfonyTestsListener();
$this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : []; $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : [];
$registeredLocally = false; $registeredLocally = false;
@ -41,8 +40,21 @@ class CommandForV6 extends BaseCommand
} }
} }
if (isset($this->arguments['configuration'])) {
$configuration = $this->arguments['configuration'];
if (!$configuration instanceof Configuration) {
$configuration = Configuration::getInstance($this->arguments['configuration']);
}
foreach ($configuration->getListenerConfiguration() as $registeredListener) {
if ('Symfony\Bridge\PhpUnit\SymfonyTestsListener' === ltrim($registeredListener['class'], '\\')) {
$registeredLocally = true;
break;
}
}
}
if (!$registeredLocally) { if (!$registeredLocally) {
$this->arguments['listeners'][] = $listener; $this->arguments['listeners'][] = new SymfonyTestsListener();
} }
return parent::createRunner(); return parent::createRunner();

View File

@ -10,7 +10,7 @@
*/ */
// Please update when phpunit needs to be reinstalled with fresh deps: // Please update when phpunit needs to be reinstalled with fresh deps:
// Cache-Id: 2019-08-09 13:00 UTC // Cache-Id: 2019-09-02 16:00 UTC
error_reporting(-1); error_reporting(-1);

View File

@ -14,6 +14,7 @@ CHANGELOG
* marked all classes extending twig as `@final` * marked all classes extending twig as `@final`
* deprecated to pass `$rootDir` and `$fileLinkFormatter` as 5th and 6th argument respectively to the * deprecated to pass `$rootDir` and `$fileLinkFormatter` as 5th and 6th argument respectively to the
`DebugCommand::__construct()` method, swap the variables position. `DebugCommand::__construct()` method, swap the variables position.
* the `LintCommand` lints all the templates stored in all configured Twig paths if none argument is provided
4.3.0 4.3.0
----- -----

View File

@ -23,6 +23,7 @@ use Symfony\Component\Finder\Finder;
use Twig\Environment; use Twig\Environment;
use Twig\Error\Error; use Twig\Error\Error;
use Twig\Loader\ArrayLoader; use Twig\Loader\ArrayLoader;
use Twig\Loader\FilesystemLoader;
use Twig\Source; use Twig\Source;
/** /**
@ -78,10 +79,7 @@ EOF
$filenames = $input->getArgument('filename'); $filenames = $input->getArgument('filename');
if (0 === \count($filenames)) { if (0 === \count($filenames)) {
if (0 !== ftell(STDIN)) { if (0 === ftell(STDIN)) {
throw new RuntimeException('Please provide a filename or pipe template content to STDIN.');
}
$template = ''; $template = '';
while (!feof(STDIN)) { while (!feof(STDIN)) {
$template .= fread(STDIN, 1024); $template .= fread(STDIN, 1024);
@ -90,6 +88,20 @@ EOF
return $this->display($input, $output, $io, [$this->validate($template, uniqid('sf_', true))]); return $this->display($input, $output, $io, [$this->validate($template, uniqid('sf_', true))]);
} }
$loader = $this->twig->getLoader();
if ($loader instanceof FilesystemLoader) {
$paths = [];
foreach ($loader->getNamespaces() as $namespace) {
$paths[] = $loader->getPaths($namespace);
}
$filenames = array_merge(...$paths);
}
if (0 === \count($filenames)) {
throw new RuntimeException('Please provide a filename or pipe template content to STDIN.');
}
}
$filesInfo = $this->getFilesInfo($filenames); $filesInfo = $this->getFilesInfo($filenames);
return $this->display($input, $output, $io, $filesInfo); return $this->display($input, $output, $io, $filesInfo);

View File

@ -124,7 +124,7 @@
{{- block('form_widget_simple') -}} {{- block('form_widget_simple') -}}
{%- set label_attr = label_attr|merge({ class: (label_attr.class|default('') ~ ' custom-file-label')|trim }) -%} {%- set label_attr = label_attr|merge({ class: (label_attr.class|default('') ~ ' custom-file-label')|trim }) -%}
<label for="{{ form.vars.id }}" {% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}> <label for="{{ form.vars.id }}" {% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}>
{%- if attr.placeholder is defined -%} {%- if attr.placeholder is defined and attr.placeholder is not none -%}
{{- translation_domain is same as(false) ? attr.placeholder : attr.placeholder|trans({}, translation_domain) -}} {{- translation_domain is same as(false) ? attr.placeholder : attr.placeholder|trans({}, translation_domain) -}}
{%- endif -%} {%- endif -%}
</label> </label>

View File

@ -66,9 +66,18 @@ class LintCommandTest extends TestCase
$this->assertRegExp('/ERROR in \S+ \(line /', trim($tester->getDisplay())); $this->assertRegExp('/ERROR in \S+ \(line /', trim($tester->getDisplay()));
} }
public function testLintDefaultPaths()
{
$tester = $this->createCommandTester();
$ret = $tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]);
$this->assertEquals(0, $ret, 'Returns 0 in case of success');
self::assertStringContainsString('OK in', trim($tester->getDisplay()));
}
private function createCommandTester(): CommandTester private function createCommandTester(): CommandTester
{ {
$command = new LintCommand(new Environment(new FilesystemLoader())); $command = new LintCommand(new Environment(new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/')));
$application = new Application(); $application = new Application();
$application->add($command); $application->add($command);

View File

@ -124,7 +124,7 @@ function tagged_iterator(string $tag, string $indexAttribute = null, string $def
/** /**
* Creates a service locator by tag name. * Creates a service locator by tag name.
*/ */
function tagged_locator(string $tag, string $indexAttribute, string $defaultIndexMethod = null): ServiceLocatorArgument function tagged_locator(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null): ServiceLocatorArgument
{ {
return new ServiceLocatorArgument(new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, true)); return new ServiceLocatorArgument(new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, true));
} }

View File

@ -707,16 +707,17 @@ class YamlFileLoader extends FileLoader
if (\in_array($value->getTag(), ['tagged_iterator', 'tagged_locator'], true)) { if (\in_array($value->getTag(), ['tagged_iterator', 'tagged_locator'], true)) {
$forLocator = 'tagged_locator' === $value->getTag(); $forLocator = 'tagged_locator' === $value->getTag();
if (\is_string($argument) && $argument) {
return new TaggedIteratorArgument($argument, null, null, $forLocator);
}
if (\is_array($argument) && isset($argument['tag']) && $argument['tag']) { if (\is_array($argument) && isset($argument['tag']) && $argument['tag']) {
if ($diff = array_diff(array_keys($argument), ['tag', 'index_by', 'default_index_method'])) { if ($diff = array_diff(array_keys($argument), ['tag', 'index_by', 'default_index_method'])) {
throw new InvalidArgumentException(sprintf('"!%s" tag contains unsupported key "%s"; supported ones are "tag", "index_by" and "default_index_method".', $value->getTag(), implode('"", "', $diff))); throw new InvalidArgumentException(sprintf('"!%s" tag contains unsupported key "%s"; supported ones are "tag", "index_by" and "default_index_method".', $value->getTag(), implode('"", "', $diff)));
} }
$argument = new TaggedIteratorArgument($argument['tag'], $argument['index_by'] ?? null, $argument['default_index_method'] ?? null, $forLocator); $argument = new TaggedIteratorArgument($argument['tag'], $argument['index_by'] ?? null, $argument['default_index_method'] ?? null, $forLocator);
} elseif (\is_string($argument) && $argument) {
$argument = new TaggedIteratorArgument($argument, null, null, $forLocator);
} else {
throw new InvalidArgumentException(sprintf('"!%s" tags only accept a non empty string or an array with a key "tag" in "%s".', $value->getTag(), $file));
}
if ($forLocator) { if ($forLocator) {
$argument = new ServiceLocatorArgument($argument); $argument = new ServiceLocatorArgument($argument);
@ -724,9 +725,6 @@ class YamlFileLoader extends FileLoader
return $argument; return $argument;
} }
throw new InvalidArgumentException(sprintf('"!%s" tags only accept a non empty string or an array with a key "tag" in "%s".', $value->getTag(), $file));
}
if ('service' === $value->getTag()) { if ('service' === $value->getTag()) {
if ($isParameter) { if ($isParameter) {
throw new InvalidArgumentException(sprintf('Using an anonymous service in a parameter is not allowed in "%s".', $file)); throw new InvalidArgumentException(sprintf('Using an anonymous service in a parameter is not allowed in "%s".', $file));

View File

@ -104,6 +104,7 @@ class YamlDumperTest extends TestCase
$container->register('foo_service', 'Foo')->addTag('foo'); $container->register('foo_service', 'Foo')->addTag('foo');
$container->register('foo_service_tagged_iterator', 'Bar')->addArgument($taggedIterator); $container->register('foo_service_tagged_iterator', 'Bar')->addArgument($taggedIterator);
$container->register('foo_service_tagged_locator', 'Bar')->addArgument(new ServiceLocatorArgument($taggedIterator)); $container->register('foo_service_tagged_locator', 'Bar')->addArgument(new ServiceLocatorArgument($taggedIterator));
$container->register('bar_service_tagged_locator', 'Bar')->addArgument(new ServiceLocatorArgument(new TaggedIteratorArgument('foo')));
$dumper = new YamlDumper($container); $dumper = new YamlDumper($container);
$this->assertStringEqualsFile(self::$fixturesPath.'/yaml/services_with_tagged_argument.yml', $dumper->dump()); $this->assertStringEqualsFile(self::$fixturesPath.'/yaml/services_with_tagged_argument.yml', $dumper->dump());

View File

@ -14,6 +14,9 @@ services:
foo_service_tagged_locator: foo_service_tagged_locator:
class: Bar class: Bar
arguments: [!tagged_locator { tag: foo, index_by: barfoo, default_index_method: foobar }] arguments: [!tagged_locator { tag: foo, index_by: barfoo, default_index_method: foobar }]
bar_service_tagged_locator:
class: Bar
arguments: [!tagged_locator foo]
Psr\Container\ContainerInterface: Psr\Container\ContainerInterface:
alias: service_container alias: service_container
public: false public: false

View File

@ -282,6 +282,9 @@ class YamlFileLoaderTest extends TestCase
$taggedIterator = new TaggedIteratorArgument('foo', 'barfoo', 'foobar', true); $taggedIterator = new TaggedIteratorArgument('foo', 'barfoo', 'foobar', true);
$this->assertEquals(new ServiceLocatorArgument($taggedIterator), $container->getDefinition('foo_service_tagged_locator')->getArgument(0)); $this->assertEquals(new ServiceLocatorArgument($taggedIterator), $container->getDefinition('foo_service_tagged_locator')->getArgument(0));
$taggedIterator = new TaggedIteratorArgument('foo', null, null, true);
$this->assertEquals(new ServiceLocatorArgument($taggedIterator), $container->getDefinition('bar_service_tagged_locator')->getArgument(0));
} }
public function testNameOnlyTagsAreAllowedAsString() public function testNameOnlyTagsAreAllowedAsString()

View File

@ -11,7 +11,11 @@
namespace Symfony\Component\ErrorHandler; namespace Symfony\Component\ErrorHandler;
use Doctrine\Common\Persistence\Proxy;
use PHPUnit\Framework\MockObject\Matcher\StatelessInvocation; use PHPUnit\Framework\MockObject\Matcher\StatelessInvocation;
use PHPUnit\Framework\MockObject\MockObject;
use Prophecy\Prophecy\ProphecySubjectInterface;
use ProxyManager\Proxy\ProxyInterface;
/** /**
* Autoloader checking if the class is really defined in the file found. * Autoloader checking if the class is really defined in the file found.
@ -230,22 +234,57 @@ class DebugClassLoader
spl_autoload_unregister($function); spl_autoload_unregister($function);
} }
$loader = null;
foreach ($functions as $function) { foreach ($functions as $function) {
if (\is_array($function) && $function[0] instanceof self) { if (\is_array($function) && $function[0] instanceof self) {
$loader = $function[0];
$function = $function[0]->getClassLoader(); $function = $function[0]->getClassLoader();
} }
spl_autoload_register($function); spl_autoload_register($function);
} }
}
if (null !== $loader) { public static function checkClasses(): bool
foreach (array_merge(get_declared_interfaces(), get_declared_traits(), get_declared_classes()) as $class) { {
$loader->checkClass($class); if (!\is_array($functions = spl_autoload_functions())) {
return false;
}
$loader = null;
foreach ($functions as $function) {
if (\is_array($function) && $function[0] instanceof self) {
$loader = $function[0];
break;
} }
} }
if (null === $loader) {
return false;
}
static $offsets = [
'get_declared_interfaces' => 0,
'get_declared_traits' => 0,
'get_declared_classes' => 0,
];
foreach ($offsets as $getSymbols => $i) {
$symbols = $getSymbols();
for (; $i < \count($symbols); ++$i) {
if (!is_subclass_of($symbols[$i], MockObject::class)
&& !is_subclass_of($symbols[$i], ProphecySubjectInterface::class)
&& !is_subclass_of($symbols[$i], Proxy::class)
&& !is_subclass_of($symbols[$i], ProxyInterface::class)
) {
$loader->checkClass($symbols[$i]);
}
}
$offsets[$getSymbols] = $i;
}
return true;
} }
public function findFile(string $class): ?string public function findFile(string $class): ?string

View File

@ -592,7 +592,11 @@ class ErrorHandler
} }
} }
$exceptionHandler = $this->exceptionHandler; $exceptionHandler = $this->exceptionHandler;
if ((!\is_array($exceptionHandler) || !$exceptionHandler[0] instanceof self || 'sendPhpResponse' !== $exceptionHandler[1]) && !\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
$this->exceptionHandler = [$this, 'sendPhpResponse'];
} else {
$this->exceptionHandler = null; $this->exceptionHandler = null;
}
try { try {
if (null !== $exceptionHandler) { if (null !== $exceptionHandler) {
return $exceptionHandler($exception); return $exceptionHandler($exception);
@ -696,18 +700,28 @@ class ErrorHandler
private function sendPhpResponse(\Throwable $exception) private function sendPhpResponse(\Throwable $exception)
{ {
$charset = ini_get('default_charset') ?: 'UTF-8'; $charset = ini_get('default_charset') ?: 'UTF-8';
$statusCode = 500;
if (!headers_sent()) { $headers = [];
header('HTTP/1.0 500');
header(sprintf('Content-Type: text/html; charset=%s', $charset));
}
if (class_exists(HtmlErrorRenderer::class)) { if (class_exists(HtmlErrorRenderer::class)) {
echo (new HtmlErrorRenderer(true))->render(FlattenException::createFromThrowable($exception)); $exception = FlattenException::createFromThrowable($exception);
$statusCode = $exception->getStatusCode();
$headers = $exception->getHeaders();
$response = (new HtmlErrorRenderer(true))->render($exception);
} else { } else {
$message = htmlspecialchars($exception->getMessage(), ENT_COMPAT | ENT_SUBSTITUTE, $charset); $message = htmlspecialchars($exception->getMessage(), ENT_COMPAT | ENT_SUBSTITUTE, $charset);
echo sprintf('<!DOCTYPE html><html><head><meta charset="%s" /><meta name="robots" content="noindex,nofollow" /></head><body>%s</body></html>', $charset, $message); $response = sprintf('<!DOCTYPE html><html><head><meta charset="%s" /><meta name="robots" content="noindex,nofollow" /></head><body>%s</body></html>', $charset, $message);
} }
if (!headers_sent()) {
header(sprintf('HTTP/1.0 %s', $statusCode));
foreach ($headers as $name => $value) {
header($name.': '.$value, false);
}
header('Content-Type: text/html; charset='.$charset);
}
echo $response;
} }
/** /**

View File

@ -557,6 +557,18 @@ class ErrorHandlerTest extends TestCase
$handler->handleException(new \Exception()); $handler->handleException(new \Exception());
} }
public function testSendPhpResponse()
{
$handler = new ErrorHandler();
$handler->setExceptionHandler([$handler, 'sendPhpResponse']);
ob_start();
$handler->handleException(new \RuntimeException('Class Foo not found'));
$response = ob_get_clean();
self::assertStringContainsString('Class Foo not found', $response);
}
/** /**
* @dataProvider errorHandlerWhenLoggingProvider * @dataProvider errorHandlerWhenLoggingProvider
*/ */

View File

@ -20,8 +20,8 @@ use Symfony\Contracts\HttpClient\ChunkInterface;
*/ */
class DataChunk implements ChunkInterface class DataChunk implements ChunkInterface
{ {
private $offset; private $offset = 0;
private $content; private $content = '';
public function __construct(int $offset = 0, string $content = '') public function __construct(int $offset = 0, string $content = '')
{ {
@ -53,6 +53,14 @@ class DataChunk implements ChunkInterface
return false; return false;
} }
/**
* {@inheritdoc}
*/
public function getInformationalStatus(): ?array
{
return null;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@ -65,6 +65,15 @@ class ErrorChunk implements ChunkInterface
throw new TransportException($this->errorMessage, 0, $this->error); throw new TransportException($this->errorMessage, 0, $this->error);
} }
/**
* {@inheritdoc}
*/
public function getInformationalStatus(): ?array
{
$this->didThrow = true;
throw new TransportException($this->errorMessage, 0, $this->error);
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@ -0,0 +1,35 @@
<?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\HttpClient\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class InformationalChunk extends DataChunk
{
private $status;
public function __construct(int $statusCode, array $headers)
{
$this->status = [$statusCode, $headers];
}
/**
* {@inheritdoc}
*/
public function getInformationalStatus(): ?array
{
return $this->status;
}
}

View File

@ -56,7 +56,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
* *
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options * @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/ */
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50) public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 0)
{ {
if (!\extension_loaded('curl')) { if (!\extension_loaded('curl')) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.'); throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.');
@ -106,20 +106,15 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
$host = parse_url($authority, PHP_URL_HOST); $host = parse_url($authority, PHP_URL_HOST);
$url = implode('', $url); $url = implode('', $url);
if (!isset($options['normalized_headers']['user-agent'])) {
$options['normalized_headers']['user-agent'][] = $options['headers'][] = 'User-Agent: Symfony HttpClient/Curl';
}
if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) { if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
unset($this->multi->pushedResponses[$url]); unset($this->multi->pushedResponses[$url]);
// Accept pushed responses only if their headers related to authentication match the request
$expectedHeaders = ['authorization', 'cookie', 'x-requested-with', 'range'];
foreach ($expectedHeaders as $k => $v) {
$expectedHeaders[$k] = null;
foreach ($options['normalized_headers'][$v] ?? [] as $h) { if (self::acceptPushForRequest($method, $options, $pushedResponse)) {
$expectedHeaders[$k][] = substr($h, 2 + \strlen($v)); $this->logger && $this->logger->debug(sprintf('Accepting pushed response: "%s %s"', $method, $url));
}
}
if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) {
$this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url));
// Reinitialize the pushed response with request's options // Reinitialize the pushed response with request's options
$pushedResponse->response->__construct($this->multi, $url, $options, $this->logger); $pushedResponse->response->__construct($this->multi, $url, $options, $this->logger);
@ -127,14 +122,13 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
return $pushedResponse->response; return $pushedResponse->response;
} }
$this->logger && $this->logger->debug(sprintf('Rejecting pushed response for "%s": authorization headers don\'t match the request', $url)); $this->logger && $this->logger->debug(sprintf('Rejecting pushed response: "%s".', $url));
} }
$this->logger && $this->logger->info(sprintf('Request: "%s %s"', $method, $url)); $this->logger && $this->logger->info(sprintf('Request: "%s %s"', $method, $url));
$curlopts = [ $curlopts = [
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_USERAGENT => 'Symfony HttpClient/Curl',
CURLOPT_TCP_NODELAY => true, CURLOPT_TCP_NODELAY => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
@ -330,7 +324,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
$active = 0; $active = 0;
while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)); while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active));
foreach ($this->multi->openHandles as $ch) { foreach ($this->multi->openHandles as [$ch]) {
curl_setopt($ch, CURLOPT_VERBOSE, false); curl_setopt($ch, CURLOPT_VERBOSE, false);
} }
} }
@ -342,17 +336,17 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
foreach ($requestHeaders as $h) { foreach ($requestHeaders as $h) {
if (false !== $i = strpos($h, ':', 1)) { if (false !== $i = strpos($h, ':', 1)) {
$headers[substr($h, 0, $i)] = substr($h, 1 + $i); $headers[substr($h, 0, $i)][] = substr($h, 1 + $i);
} }
} }
if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path']) || 'GET' !== $headers[':method'] || isset($headers['range'])) { if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path'])) {
$logger && $logger->debug(sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin)); $logger && $logger->debug(sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin));
return CURL_PUSH_DENY; return CURL_PUSH_DENY;
} }
$url = $headers[':scheme'].'://'.$headers[':authority']; $url = $headers[':scheme'][0].'://'.$headers[':authority'][0];
if ($maxPendingPushes <= \count($multi->pushedResponses)) { if ($maxPendingPushes <= \count($multi->pushedResponses)) {
$logger && $logger->debug(sprintf('Rejecting pushed response from "%s" for "%s": the queue is full', $origin, $url)); $logger && $logger->debug(sprintf('Rejecting pushed response from "%s" for "%s": the queue is full', $origin, $url));
@ -369,22 +363,43 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
return CURL_PUSH_DENY; return CURL_PUSH_DENY;
} }
$url .= $headers[':path']; $url .= $headers[':path'][0];
$logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url)); $logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url));
$multi->pushedResponses[$url] = new PushedResponse( $multi->pushedResponses[$url] = new PushedResponse(new CurlResponse($multi, $pushed), $headers, $multi->openHandles[(int) $parent][1] ?? []);
new CurlResponse($multi, $pushed),
[
$headers['authorization'] ?? null,
$headers['cookie'] ?? null,
$headers['x-requested-with'] ?? null,
null,
]
);
return CURL_PUSH_OK; return CURL_PUSH_OK;
} }
/**
* Accepts pushed responses only if their headers related to authentication match the request.
*/
private static function acceptPushForRequest(string $method, array $options, PushedResponse $pushedResponse): bool
{
if ($options['body'] || $method !== $pushedResponse->requestHeaders[':method'][0]) {
return false;
}
foreach (['proxy', 'no_proxy', 'bindto'] as $k) {
if ($options[$k] !== $pushedResponse->parentOptions[$k]) {
return false;
}
}
foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
$normalizedHeaders = $options['normalized_headers'][$k] ?? [];
foreach ($normalizedHeaders as $i => $v) {
$normalizedHeaders[$i] = substr($v, \strlen($k) + 2);
}
if (($pushedResponse->requestHeaders[$k] ?? []) !== $normalizedHeaders) {
return false;
}
}
return true;
}
/** /**
* Wraps the request's body callback to allow it to return strings longer than curl requested. * Wraps the request's body callback to allow it to return strings longer than curl requested.
*/ */

View File

@ -14,7 +14,7 @@ namespace Symfony\Component\HttpClient\Internal;
use Symfony\Component\HttpClient\Response\CurlResponse; use Symfony\Component\HttpClient\Response\CurlResponse;
/** /**
* A pushed response with headers. * A pushed response with its request headers.
* *
* @author Alexander M. Turek <me@derrabus.de> * @author Alexander M. Turek <me@derrabus.de>
* *
@ -22,15 +22,17 @@ use Symfony\Component\HttpClient\Response\CurlResponse;
*/ */
final class PushedResponse final class PushedResponse
{ {
/** @var CurlResponse */
public $response; public $response;
/** @var string[] */ /** @var string[] */
public $headers; public $requestHeaders;
public function __construct(CurlResponse $response, array $headers) public $parentOptions = [];
public function __construct(CurlResponse $response, array $requestHeaders, array $parentOptions)
{ {
$this->response = $response; $this->response = $response;
$this->headers = $headers; $this->requestHeaders = $requestHeaders;
$this->parentOptions = $parentOptions;
} }
} }

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\HttpClient\Response;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk; use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\InformationalChunk;
use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\CurlClientState; use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
@ -120,9 +121,6 @@ final class CurlResponse implements ResponseInterface
if (\in_array($waitFor, ['headers', 'destruct'], true)) { if (\in_array($waitFor, ['headers', 'destruct'], true)) {
try { try {
if (\defined('CURLOPT_STREAM_WEIGHT')) {
curl_setopt($ch, CURLOPT_STREAM_WEIGHT, 32);
}
self::stream([$response])->current(); self::stream([$response])->current();
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Persist timeouts thrown during initialization // Persist timeouts thrown during initialization
@ -140,7 +138,7 @@ final class CurlResponse implements ResponseInterface
}; };
// Schedule the request in a non-blocking way // Schedule the request in a non-blocking way
$multi->openHandles[$id] = $ch; $multi->openHandles[$id] = [$ch, $options];
curl_multi_add_handle($multi->handle, $ch); curl_multi_add_handle($multi->handle, $ch);
self::perform($multi); self::perform($multi);
} }
@ -314,8 +312,11 @@ final class CurlResponse implements ResponseInterface
return \strlen($data); return \strlen($data);
} }
// End of headers: handle redirects and add to the activity list // End of headers: handle informational responses, redirects, etc.
if (200 > $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE)) { if (200 > $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE)) {
$multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers);
return \strlen($data); return \strlen($data);
} }
@ -342,7 +343,7 @@ final class CurlResponse implements ResponseInterface
if ($statusCode < 300 || 400 <= $statusCode || curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) { if ($statusCode < 300 || 400 <= $statusCode || curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
// Headers and redirects completed, time to get the response's body // Headers and redirects completed, time to get the response's body
$multi->handlesActivity[$id] = [new FirstChunk()]; $multi->handlesActivity[$id][] = new FirstChunk();
if ('destruct' === $waitFor) { if ('destruct' === $waitFor) {
return 0; return 0;

View File

@ -45,7 +45,7 @@ class MockResponse implements ResponseInterface
public function __construct($body = '', array $info = []) public function __construct($body = '', array $info = [])
{ {
$this->body = is_iterable($body) ? $body : (string) $body; $this->body = is_iterable($body) ? $body : (string) $body;
$this->info = $info + $this->info; $this->info = $info + ['http_code' => 200] + $this->info;
if (!isset($info['response_headers'])) { if (!isset($info['response_headers'])) {
return; return;
@ -59,7 +59,8 @@ class MockResponse implements ResponseInterface
} }
} }
$this->info['response_headers'] = $responseHeaders; $this->info['response_headers'] = [];
self::addResponseHeaders($responseHeaders, $this->info, $this->headers);
} }
/** /**

View File

@ -17,8 +17,6 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/** /**
* @author Nicolas Grekas <p@tchwork.com> * @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/ */
final class ResponseStream implements ResponseStreamInterface final class ResponseStream implements ResponseStreamInterface
{ {

View File

@ -47,27 +47,22 @@ class CurlHttpClientTest extends HttpClientTestCase
} }
}; };
$client = new CurlHttpClient(); $client = new CurlHttpClient([], 6, 2);
$client->setLogger($logger); $client->setLogger($logger);
$index = $client->request('GET', 'https://http2-push.io'); $index = $client->request('GET', 'https://http2.akamai.com/');
$index->getContent(); $index->getContent();
$css = $client->request('GET', 'https://http2-push.io/css/style.css'); $css = $client->request('GET', 'https://http2.akamai.com/resources/push.css');
$js = $client->request('GET', 'https://http2-push.io/js/http2-push.js');
$css->getHeaders(); $css->getHeaders();
$js->getHeaders();
$expected = [ $expected = [
'Request: "GET https://http2-push.io/"', 'Request: "GET https://http2.akamai.com/"',
'Queueing pushed response: "https://http2-push.io/css/style.css"', 'Queueing pushed response: "https://http2.akamai.com/resources/push.css"',
'Queueing pushed response: "https://http2-push.io/js/http2-push.js"', 'Response: "200 https://http2.akamai.com/"',
'Response: "200 https://http2-push.io/"', 'Accepting pushed response: "GET https://http2.akamai.com/resources/push.css"',
'Connecting request to pushed response: "GET https://http2-push.io/css/style.css"', 'Response: "200 https://http2.akamai.com/resources/push.css"',
'Connecting request to pushed response: "GET https://http2-push.io/js/http2-push.js"',
'Response: "200 https://http2-push.io/css/style.css"',
'Response: "200 https://http2-push.io/js/http2-push.js"',
]; ];
$this->assertSame($expected, $logger->logs); $this->assertSame($expected, $logger->logs);
} }

View File

@ -15,6 +15,8 @@ use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Component\HttpClient\NativeHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
@ -124,6 +126,41 @@ class MockHttpClientTest extends HttpClientTestCase
$responses[] = new MockResponse($body, ['response_headers' => $headers]); $responses[] = new MockResponse($body, ['response_headers' => $headers]);
break; break;
case 'testInformationalResponseStream':
$client = $this->createMock(HttpClientInterface::class);
$response = new MockResponse('Here the body', ['response_headers' => [
'HTTP/1.1 103 ',
'Link: </style.css>; rel=preload; as=style',
'HTTP/1.1 200 ',
'Date: foo',
'Content-Length: 13',
]]);
$client->method('request')->willReturn($response);
$client->method('stream')->willReturn(new ResponseStream((function () use ($response) {
$chunk = $this->createMock(ChunkInterface::class);
$chunk->method('getInformationalStatus')
->willReturn([103, ['link' => ['</style.css>; rel=preload; as=style', '</script.js>; rel=preload; as=script']]]);
yield $response => $chunk;
$chunk = $this->createMock(ChunkInterface::class);
$chunk->method('isFirst')->willReturn(true);
yield $response => $chunk;
$chunk = $this->createMock(ChunkInterface::class);
$chunk->method('getContent')->willReturn('Here the body');
yield $response => $chunk;
$chunk = $this->createMock(ChunkInterface::class);
$chunk->method('isLast')->willReturn(true);
yield $response => $chunk;
})()));
return $client;
case 'testMaxDuration': case 'testMaxDuration':
$mock = $this->getMockBuilder(ResponseInterface::class)->getMock(); $mock = $this->getMockBuilder(ResponseInterface::class)->getMock();
$mock->expects($this->any()) $mock->expects($this->any())

View File

@ -20,4 +20,9 @@ class NativeHttpClientTest extends HttpClientTestCase
{ {
return new NativeHttpClient(); return new NativeHttpClient();
} }
public function testInformationalResponseStream()
{
$this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.');
}
} }

View File

@ -22,7 +22,7 @@
"require": { "require": {
"php": "^7.2.9", "php": "^7.2.9",
"psr/log": "^1.0", "psr/log": "^1.0",
"symfony/http-client-contracts": "^1.1.6", "symfony/http-client-contracts": "^1.1.7",
"symfony/polyfill-php73": "^1.11" "symfony/polyfill-php73": "^1.11"
}, },
"require-dev": { "require-dev": {

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Mailer\Tests\Transport; namespace Symfony\Component\Mailer\Tests\Transport;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Exception\InvalidArgumentException;
use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mailer\Transport\Transports; use Symfony\Component\Mailer\Transport\Transports;
use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\Header\Headers;
@ -48,4 +49,19 @@ class TransportsTest extends TestCase
$email = new Message($headers, new TextPart('...')); $email = new Message($headers, new TextPart('...'));
$transport->send($email); $transport->send($email);
} }
public function testTransportDoesNotExist()
{
$transport = new Transports([
'foo' => $this->createMock(TransportInterface::class),
'bar' => $this->createMock(TransportInterface::class),
]);
$headers = (new Headers())->addTextHeader('X-Transport', 'foobar');
$email = new Message($headers, new TextPart('...'));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The "foobar" transport does not exist (available transports: "foo", "bar").');
$transport->send($email);
}
} }

View File

@ -56,7 +56,7 @@ class Transports implements TransportInterface
$headers->remove('X-Transport'); $headers->remove('X-Transport');
if (!isset($this->transports[$transport])) { if (!isset($this->transports[$transport])) {
throw new InvalidArgumentException(sprintf('The "%s" transport does not exist.', $transport)); throw new InvalidArgumentException(sprintf('The "%s" transport does not exist (available transports: "%s").', $transport, implode('", "', array_keys($this->transports))));
} }
return $this->transports[$transport]->send($message, $envelope); return $this->transports[$transport]->send($message, $envelope);

View File

@ -119,7 +119,7 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
return $this->messages; return $this->messages;
} }
private function collectMessage(?string $locale, ?string $domain, string $id, string $translation, ?array $parameters = []) private function collectMessage(?string $locale, ?string $domain, ?string $id, string $translation, ?array $parameters = [])
{ {
if (null === $domain) { if (null === $domain) {
$domain = 'messages'; $domain = 'messages';

View File

@ -28,6 +28,11 @@ class IntlFormatter implements IntlFormatterInterface
*/ */
public function formatIntl(string $message, string $locale, array $parameters = []): string public function formatIntl(string $message, string $locale, array $parameters = []): string
{ {
// MessageFormatter constructor throws an exception if the message is empty
if ('' === $message) {
return '';
}
if (!$formatter = $this->cache[$locale][$message] ?? null) { if (!$formatter = $this->cache[$locale][$message] ?? null) {
if (!($this->hasMessageFormatter ?? $this->hasMessageFormatter = class_exists(\MessageFormatter::class))) { if (!($this->hasMessageFormatter ?? $this->hasMessageFormatter = class_exists(\MessageFormatter::class))) {
throw new LogicException('Cannot parse message translation: please install the "intl" PHP extension or the "symfony/polyfill-intl-messageformatter" package.'); throw new LogicException('Cannot parse message translation: please install the "intl" PHP extension or the "symfony/polyfill-intl-messageformatter" package.');

View File

@ -107,7 +107,7 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface,
/** /**
* Logs for missing translations. * Logs for missing translations.
*/ */
private function log(string $id, ?string $domain, ?string $locale) private function log(?string $id, ?string $domain, ?string $locale)
{ {
if (null === $domain) { if (null === $domain) {
$domain = 'messages'; $domain = 'messages';

View File

@ -82,6 +82,11 @@ _MSG_;
'{0,number,integer} monkeys on {1,number,integer} trees make {2,number} monkeys per tree', '{0,number,integer} monkeys on {1,number,integer} trees make {2,number} monkeys per tree',
[4560, 123, 4560 / 123], [4560, 123, 4560 / 123],
], ],
[
'',
'',
[],
],
]; ];
} }

View File

@ -382,6 +382,19 @@ class TranslatorTest extends TestCase
$this->assertEquals($expected, $translator->trans($id, [], '', 'fr')); $this->assertEquals($expected, $translator->trans($id, [], '', 'fr'));
} }
public function testTransNullId()
{
$translator = new Translator('en');
$translator->addLoader('array', new ArrayLoader());
$translator->addResource('array', ['foo' => 'foofoo'], 'en');
$this->assertSame('', $translator->trans(null));
(\Closure::bind(function () use ($translator) {
$this->assertSame([], $translator->catalogues);
}, $this, Translator::class))();
}
public function getTransFileTests() public function getTransFileTests()
{ {
return [ return [
@ -403,6 +416,7 @@ class TranslatorTest extends TestCase
['Symfony est super !', 'Symfony is great!', 'Symfony est super !', [], 'fr', ''], ['Symfony est super !', 'Symfony is great!', 'Symfony est super !', [], 'fr', ''],
['Symfony est awesome !', 'Symfony is %what%!', 'Symfony est %what% !', ['%what%' => 'awesome'], 'fr', ''], ['Symfony est awesome !', 'Symfony is %what%!', 'Symfony est %what% !', ['%what%' => 'awesome'], 'fr', ''],
['Symfony est super !', new StringClass('Symfony is great!'), 'Symfony est super !', [], 'fr', ''], ['Symfony est super !', new StringClass('Symfony is great!'), 'Symfony est super !', [], 'fr', ''],
['', null, '', [], 'fr', ''],
]; ];
} }

View File

@ -189,11 +189,14 @@ class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleA
*/ */
public function trans($id, array $parameters = [], $domain = null, $locale = null) public function trans($id, array $parameters = [], $domain = null, $locale = null)
{ {
if ('' === $id = (string) $id) {
return '';
}
if (null === $domain) { if (null === $domain) {
$domain = 'messages'; $domain = 'messages';
} }
$id = (string) $id;
$catalogue = $this->getCatalogue($locale); $catalogue = $this->getCatalogue($locale);
$locale = $catalogue->getLocale(); $locale = $catalogue->getLocale();
while (!$catalogue->defines($id, $domain)) { while (!$catalogue->defines($id, $domain)) {

View File

@ -85,12 +85,10 @@ abstract class ConstraintValidator implements ConstraintValidatorInterface
*/ */
protected function formatValue($value, int $format = 0) protected function formatValue($value, int $format = 0)
{ {
$isDateTime = $value instanceof \DateTimeInterface; if (($format & self::PRETTY_DATE) && $value instanceof \DateTimeInterface) {
if (($format & self::PRETTY_DATE) && $isDateTime) {
if (class_exists('IntlDateFormatter')) { if (class_exists('IntlDateFormatter')) {
$locale = \Locale::getDefault(); $locale = \Locale::getDefault();
$formatter = new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT); $formatter = new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, $value->getTimezone());
// neither the native nor the stub IntlDateFormatter support // neither the native nor the stub IntlDateFormatter support
// DateTimeImmutable as of yet // DateTimeImmutable as of yet

View File

@ -65,14 +65,14 @@ abstract class AbstractComparisonValidator extends ConstraintValidator
// This allows to compare with any date/time value supported by // This allows to compare with any date/time value supported by
// the DateTime constructor: // the DateTime constructor:
// https://php.net/datetime.formats // https://php.net/datetime.formats
if (\is_string($comparedValue)) { if (\is_string($comparedValue) && $value instanceof \DateTimeInterface) {
if ($value instanceof \DateTimeImmutable) { // If $value is immutable, convert the compared value to a DateTimeImmutable too, otherwise use DateTime
// If $value is immutable, convert the compared value to a $dateTimeClass = $value instanceof \DateTimeImmutable ? \DateTimeImmutable::class : \DateTime::class;
// DateTimeImmutable too
$comparedValue = new \DateTimeImmutable($comparedValue); try {
} elseif ($value instanceof \DateTimeInterface) { $comparedValue = new $dateTimeClass($comparedValue);
// Otherwise use DateTime } catch (\Exception $e) {
$comparedValue = new \DateTime($comparedValue); throw new ConstraintDefinitionException(sprintf('The compared value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $comparedValue, $dateTimeClass, \get_class($constraint)));
} }
} }

View File

@ -11,6 +11,8 @@
namespace Symfony\Component\Validator\Constraints; namespace Symfony\Component\Validator\Constraints;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
/** /**
* Validates that values are a multiple of the given number. * Validates that values are a multiple of the given number.
* *
@ -23,6 +25,14 @@ class DivisibleByValidator extends AbstractComparisonValidator
*/ */
protected function compareValues($value1, $value2) protected function compareValues($value1, $value2)
{ {
if (!is_numeric($value1)) {
throw new UnexpectedValueException($value1, 'numeric');
}
if (!is_numeric($value2)) {
throw new UnexpectedValueException($value2, 'numeric');
}
if (!$value2 = abs($value2)) { if (!$value2 = abs($value2)) {
return false; return false;
} }

View File

@ -61,12 +61,26 @@ class RangeValidator extends ConstraintValidator
// the DateTime constructor: // the DateTime constructor:
// https://php.net/datetime.formats // https://php.net/datetime.formats
if ($value instanceof \DateTimeInterface) { if ($value instanceof \DateTimeInterface) {
$dateTimeClass = null;
if (\is_string($min)) { if (\is_string($min)) {
$min = new \DateTime($min); $dateTimeClass = $value instanceof \DateTimeImmutable ? \DateTimeImmutable::class : \DateTime::class;
try {
$min = new $dateTimeClass($min);
} catch (\Exception $e) {
throw new ConstraintDefinitionException(sprintf('The min value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $min, $dateTimeClass, \get_class($constraint)));
}
} }
if (\is_string($max)) { if (\is_string($max)) {
$max = new \DateTime($max); $dateTimeClass = $dateTimeClass ?: ($value instanceof \DateTimeImmutable ? \DateTimeImmutable::class : \DateTime::class);
try {
$max = new $dateTimeClass($max);
} catch (\Exception $e) {
throw new ConstraintDefinitionException(sprintf('The max value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $max, $dateTimeClass, \get_class($constraint)));
}
} }
} }

View File

@ -0,0 +1,65 @@
<?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\Validator\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
final class ConstraintValidatorTest extends TestCase
{
/**
* @dataProvider formatValueProvider
*/
public function testFormatValue($expected, $value, $format = 0)
{
$this->assertSame($expected, (new TestFormatValueConstraintValidator())->formatValueProxy($value, $format));
}
public function formatValueProvider()
{
$data = [
['true', true],
['false', false],
['null', null],
['resource', fopen('php://memory', 'r')],
['"foo"', 'foo'],
['array', []],
['object', $toString = new TestToStringObject()],
['ccc', $toString, ConstraintValidator::OBJECT_TO_STRING],
['object', $dateTime = (new \DateTimeImmutable('@0'))->setTimezone(new \DateTimeZone('UTC'))],
[class_exists(\IntlDateFormatter::class) ? 'Jan 1, 1970, 12:00 AM' : '1970-01-01 00:00:00', $dateTime, ConstraintValidator::PRETTY_DATE],
];
return $data;
}
}
final class TestFormatValueConstraintValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
}
public function formatValueProxy($value, $format)
{
return $this->formatValue($value, $format);
}
}
final class TestToStringObject
{
public function __toString()
{
return 'ccc';
}
}

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Intl\Util\IntlTestHelper; use Symfony\Component\Intl\Util\IntlTestHelper;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\AbstractComparison;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
@ -224,6 +225,31 @@ abstract class AbstractComparisonValidatorTestCase extends ConstraintValidatorTe
->assertRaised(); ->assertRaised();
} }
/**
* @dataProvider throwsOnInvalidStringDatesProvider
*/
public function testThrowsOnInvalidStringDates(AbstractComparison $constraint, $expectedMessage, $value)
{
$this->expectException(ConstraintDefinitionException::class);
$this->expectExceptionMessage($expectedMessage);
$this->validator->validate($value, $constraint);
}
public function throwsOnInvalidStringDatesProvider(): array
{
$constraint = $this->createConstraint([
'value' => 'foo',
]);
$constraintClass = \get_class($constraint);
return [
[$constraint, sprintf('The compared value "foo" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $constraintClass), new \DateTimeImmutable()],
[$constraint, sprintf('The compared value "foo" could not be converted to a "DateTime" instance in the "%s" constraint.', $constraintClass), new \DateTime()],
];
}
public function provideAllInvalidComparisons(): array public function provideAllInvalidComparisons(): array
{ {
// The provider runs before setUp(), so we need to manually fix // The provider runs before setUp(), so we need to manually fix

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\DivisibleBy; use Symfony\Component\Validator\Constraints\DivisibleBy;
use Symfony\Component\Validator\Constraints\DivisibleByValidator; use Symfony\Component\Validator\Constraints\DivisibleByValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
/** /**
* @author Colin O'Dell <colinodell@gmail.com> * @author Colin O'Dell <colinodell@gmail.com>
@ -76,4 +77,25 @@ class DivisibleByValidatorTest extends AbstractComparisonValidatorTestCase
['22', '"22"', '10', '"10"', 'string'], ['22', '"22"', '10', '"10"', 'string'],
]; ];
} }
/**
* @dataProvider throwsOnNonNumericValuesProvider
*/
public function testThrowsOnNonNumericValues(string $expectedGivenType, $value, $comparedValue)
{
$this->expectException(UnexpectedValueException::class);
$this->expectExceptionMessage(sprintf('Expected argument of type "numeric", "%s" given', $expectedGivenType));
$this->validator->validate($value, $this->createConstraint([
'value' => $comparedValue,
]));
}
public function throwsOnNonNumericValuesProvider()
{
return [
[\stdClass::class, 2, new \stdClass()],
[\ArrayIterator::class, new \ArrayIterator(), 12],
];
}
} }

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Validator\Tests\Constraints; namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\AbstractComparison;
use Symfony\Component\Validator\Constraints\PositiveOrZero; use Symfony\Component\Validator\Constraints\PositiveOrZero;
/** /**
@ -99,11 +100,11 @@ class GreaterThanOrEqualValidatorWithPositiveOrZeroConstraintTest extends Greate
} }
/** /**
* @dataProvider provideValidComparisonsToPropertyPath * @dataProvider throwsOnInvalidStringDatesProvider
*/ */
public function testValidComparisonToPropertyPathOnArray($comparedValue) public function testThrowsOnInvalidStringDates(AbstractComparison $constraint, $expectedMessage, $value)
{ {
$this->markTestSkipped('PropertyPath option is not used in Positive constraint'); $this->markTestSkipped('The compared value cannot be an invalid string date because it is hardcoded to 0.');
} }
public function testInvalidComparisonToPropertyPathAddsPathAsParameter() public function testInvalidComparisonToPropertyPathAddsPathAsParameter()

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Validator\Tests\Constraints; namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\AbstractComparison;
use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Constraints\Positive;
/** /**
@ -102,11 +103,11 @@ class GreaterThanValidatorWithPositiveConstraintTest extends GreaterThanValidato
} }
/** /**
* @dataProvider provideValidComparisonsToPropertyPath * @dataProvider throwsOnInvalidStringDatesProvider
*/ */
public function testValidComparisonToPropertyPathOnArray($comparedValue) public function testThrowsOnInvalidStringDates(AbstractComparison $constraint, $expectedMessage, $value)
{ {
$this->markTestSkipped('PropertyPath option is not used in Positive constraint'); $this->markTestSkipped('The compared value cannot be an invalid string date because it is hardcoded to 0.');
} }
public function testInvalidComparisonToPropertyPathAddsPathAsParameter() public function testInvalidComparisonToPropertyPathAddsPathAsParameter()

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Validator\Tests\Constraints; namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\AbstractComparison;
use Symfony\Component\Validator\Constraints\NegativeOrZero; use Symfony\Component\Validator\Constraints\NegativeOrZero;
/** /**
@ -102,11 +103,11 @@ class LessThanOrEqualValidatorWithNegativeOrZeroConstraintTest extends LessThanO
} }
/** /**
* @dataProvider provideValidComparisonsToPropertyPath * @dataProvider throwsOnInvalidStringDatesProvider
*/ */
public function testValidComparisonToPropertyPathOnArray($comparedValue) public function testThrowsOnInvalidStringDates(AbstractComparison $constraint, $expectedMessage, $value)
{ {
$this->markTestSkipped('PropertyPath option is not used in NegativeOrZero constraint'); $this->markTestSkipped('The compared value cannot be an invalid string date because it is hardcoded to 0.');
} }
public function testInvalidComparisonToPropertyPathAddsPathAsParameter() public function testInvalidComparisonToPropertyPathAddsPathAsParameter()

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Validator\Tests\Constraints; namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\AbstractComparison;
use Symfony\Component\Validator\Constraints\Negative; use Symfony\Component\Validator\Constraints\Negative;
/** /**
@ -102,11 +103,11 @@ class LessThanValidatorWithNegativeConstraintTest extends LessThanValidatorTest
} }
/** /**
* @dataProvider provideValidComparisonsToPropertyPath * @dataProvider throwsOnInvalidStringDatesProvider
*/ */
public function testValidComparisonToPropertyPathOnArray($comparedValue) public function testThrowsOnInvalidStringDates(AbstractComparison $constraint, $expectedMessage, $value)
{ {
$this->markTestSkipped('PropertyPath option is not used in Positive constraint'); $this->markTestSkipped('The compared value cannot be an invalid string date because it is hardcoded to 0.');
} }
public function testInvalidComparisonToPropertyPathAddsPathAsParameter() public function testInvalidComparisonToPropertyPathAddsPathAsParameter()

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Validator\Tests\Constraints;
use Symfony\Component\Intl\Util\IntlTestHelper; use Symfony\Component\Intl\Util\IntlTestHelper;
use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\RangeValidator; use Symfony\Component\Validator\Constraints\RangeValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class RangeValidatorTest extends ConstraintValidatorTestCase class RangeValidatorTest extends ConstraintValidatorTestCase
@ -390,6 +391,31 @@ class RangeValidatorTest extends ConstraintValidatorTestCase
->assertRaised(); ->assertRaised();
} }
/**
* @dataProvider throwsOnInvalidStringDatesProvider
*/
public function testThrowsOnInvalidStringDates($expectedMessage, $value, $min, $max)
{
$this->expectException(ConstraintDefinitionException::class);
$this->expectExceptionMessage($expectedMessage);
$this->validator->validate($value, new Range([
'min' => $min,
'max' => $max,
]));
}
public function throwsOnInvalidStringDatesProvider(): array
{
return [
['The min value "foo" could not be converted to a "DateTimeImmutable" instance in the "Symfony\Component\Validator\Constraints\Range" constraint.', new \DateTimeImmutable(), 'foo', null],
['The min value "foo" could not be converted to a "DateTime" instance in the "Symfony\Component\Validator\Constraints\Range" constraint.', new \DateTime(), 'foo', null],
['The max value "foo" could not be converted to a "DateTimeImmutable" instance in the "Symfony\Component\Validator\Constraints\Range" constraint.', new \DateTimeImmutable(), null, 'foo'],
['The max value "foo" could not be converted to a "DateTime" instance in the "Symfony\Component\Validator\Constraints\Range" constraint.', new \DateTime(), null, 'foo'],
['The min value "bar" could not be converted to a "DateTimeImmutable" instance in the "Symfony\Component\Validator\Constraints\Range" constraint.', new \DateTimeImmutable(), 'bar', 'ccc'],
];
}
public function testNoViolationOnNullObjectWithPropertyPaths() public function testNoViolationOnNullObjectWithPropertyPaths()
{ {
$this->setObject(null); $this->setObject(null);

View File

@ -47,6 +47,13 @@ interface ChunkInterface
*/ */
public function isLast(): bool; public function isLast(): bool;
/**
* Returns a [status code, headers] tuple when a 1xx status code was just received.
*
* @throws TransportExceptionInterface on a network error or when the idle timeout is reached
*/
public function getInformationalStatus(): ?array;
/** /**
* Returns the content of the response chunk. * Returns the content of the response chunk.
* *

View File

@ -754,6 +754,27 @@ abstract class HttpClientTestCase extends TestCase
$this->assertSame(200, $response->getStatusCode()); $this->assertSame(200, $response->getStatusCode());
} }
public function testInformationalResponseStream()
{
$client = $this->getHttpClient(__FUNCTION__);
$response = $client->request('GET', 'http://localhost:8057/103');
$chunks = [];
foreach ($client->stream($response) as $chunk) {
$chunks[] = $chunk;
}
$this->assertSame(103, $chunks[0]->getInformationalStatus()[0]);
$this->assertSame(['</style.css>; rel=preload; as=style', '</script.js>; rel=preload; as=script'], $chunks[0]->getInformationalStatus()[1]['link']);
$this->assertTrue($chunks[1]->isFirst());
$this->assertSame('Here the body', $chunks[2]->getContent());
$this->assertTrue($chunks[3]->isLast());
$this->assertNull($chunks[3]->getInformationalStatus());
$this->assertSame(['date', 'content-length'], array_keys($response->getHeaders()));
$this->assertContains('Link: </style.css>; rel=preload; as=style', $response->getInfo('response_headers'));
}
/** /**
* @requires extension zlib * @requires extension zlib
*/ */

View File

@ -43,7 +43,9 @@ trait TranslatorTrait
*/ */
public function trans($id, array $parameters = [], $domain = null, $locale = null) public function trans($id, array $parameters = [], $domain = null, $locale = null)
{ {
$id = (string) $id; if ('' === $id = (string) $id) {
return '';
}
if (!isset($parameters['%count%']) || !is_numeric($parameters['%count%'])) { if (!isset($parameters['%count%']) || !is_numeric($parameters['%count%'])) {
return strtr($id, $parameters); return strtr($id, $parameters);