diff --git a/.github/patch-types.php b/.github/patch-types.php index 7302921df0..31bb7050ee 100644 --- a/.github/patch-types.php +++ b/.github/patch-types.php @@ -32,4 +32,4 @@ foreach ($loader->getClassMap() as $class => $file) { class_exists($class); } -Symfony\Component\ErrorHandler\DebugClassLoader::disable(); +Symfony\Component\ErrorHandler\DebugClassLoader::checkClasses(); diff --git a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php index 732866dc52..a81c95eea6 100644 --- a/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php +++ b/src/Symfony/Bridge/PhpUnit/DeprecationErrorHandler.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestResult; use PHPUnit\Util\ErrorHandler; use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Configuration; 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. @@ -173,6 +174,9 @@ class DeprecationErrorHandler return; } + if (method_exists(DebugClassLoader::class, 'checkClasses')) { + DebugClassLoader::checkClasses(); + } $currErrorHandler = set_error_handler('var_dump'); restore_error_handler(); diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php index 95dcb1e554..ed4128482f 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV5.php @@ -23,8 +23,6 @@ class CommandForV5 extends \PHPUnit_TextUI_Command */ protected function createRunner() { - $listener = new SymfonyTestsListenerForV5(); - $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : array(); $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) { - $this->arguments['listeners'][] = $listener; + $this->arguments['listeners'][] = new SymfonyTestsListenerForV5(); } return parent::createRunner(); diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php index f8f75bb09a..93e1ad975b 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/CommandForV6.php @@ -13,6 +13,7 @@ namespace Symfony\Bridge\PhpUnit\Legacy; use PHPUnit\TextUI\Command as BaseCommand; use PHPUnit\TextUI\TestRunner as BaseRunner; +use PHPUnit\Util\Configuration; use Symfony\Bridge\PhpUnit\SymfonyTestsListener; /** @@ -27,8 +28,6 @@ class CommandForV6 extends BaseCommand */ protected function createRunner(): BaseRunner { - $listener = new SymfonyTestsListener(); - $this->arguments['listeners'] = isset($this->arguments['listeners']) ? $this->arguments['listeners'] : []; $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) { - $this->arguments['listeners'][] = $listener; + $this->arguments['listeners'][] = new SymfonyTestsListener(); } return parent::createRunner(); diff --git a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php index 92e5caded6..cdcc423031 100644 --- a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php +++ b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php @@ -10,7 +10,7 @@ */ // 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); diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index d692bf4431..38f3ed5043 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -14,6 +14,7 @@ CHANGELOG * marked all classes extending twig as `@final` * deprecated to pass `$rootDir` and `$fileLinkFormatter` as 5th and 6th argument respectively to the `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 ----- diff --git a/src/Symfony/Bridge/Twig/Command/LintCommand.php b/src/Symfony/Bridge/Twig/Command/LintCommand.php index 1e482337f5..036362fcc7 100644 --- a/src/Symfony/Bridge/Twig/Command/LintCommand.php +++ b/src/Symfony/Bridge/Twig/Command/LintCommand.php @@ -23,6 +23,7 @@ use Symfony\Component\Finder\Finder; use Twig\Environment; use Twig\Error\Error; use Twig\Loader\ArrayLoader; +use Twig\Loader\FilesystemLoader; use Twig\Source; /** @@ -78,16 +79,27 @@ EOF $filenames = $input->getArgument('filename'); if (0 === \count($filenames)) { - if (0 !== ftell(STDIN)) { + if (0 === ftell(STDIN)) { + $template = ''; + while (!feof(STDIN)) { + $template .= fread(STDIN, 1024); + } + + 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.'); } - - $template = ''; - while (!feof(STDIN)) { - $template .= fread(STDIN, 1024); - } - - return $this->display($input, $output, $io, [$this->validate($template, uniqid('sf_', true))]); } $filesInfo = $this->getFilesInfo($filenames); diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig index 97710c79b5..ef45624c4e 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig @@ -124,7 +124,7 @@ {{- block('form_widget_simple') -}} {%- set label_attr = label_attr|merge({ class: (label_attr.class|default('') ~ ' custom-file-label')|trim }) -%} diff --git a/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php b/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php index 22f048e6bc..fe3a7231b4 100644 --- a/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php @@ -66,9 +66,18 @@ class LintCommandTest extends TestCase $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 { - $command = new LintCommand(new Environment(new FilesystemLoader())); + $command = new LintCommand(new Environment(new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/'))); $application = new Application(); $application->add($command); diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php index 2aa9b0eafa..643b252049 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php @@ -124,7 +124,7 @@ function tagged_iterator(string $tag, string $indexAttribute = null, string $def /** * 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)); } diff --git a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php index 8d4b550c93..d68098b3c9 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php @@ -707,25 +707,23 @@ class YamlFileLoader extends FileLoader if (\in_array($value->getTag(), ['tagged_iterator', 'tagged_locator'], true)) { $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 ($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))); } $argument = new TaggedIteratorArgument($argument['tag'], $argument['index_by'] ?? null, $argument['default_index_method'] ?? null, $forLocator); - - if ($forLocator) { - $argument = new ServiceLocatorArgument($argument); - } - - return $argument; + } 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)); } - 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) { + $argument = new ServiceLocatorArgument($argument); + } + + return $argument; } if ('service' === $value->getTag()) { if ($isParameter) { diff --git a/src/Symfony/Component/DependencyInjection/Tests/Dumper/YamlDumperTest.php b/src/Symfony/Component/DependencyInjection/Tests/Dumper/YamlDumperTest.php index ca41a5b339..56512e89c1 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Dumper/YamlDumperTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Dumper/YamlDumperTest.php @@ -104,6 +104,7 @@ class YamlDumperTest extends TestCase $container->register('foo_service', 'Foo')->addTag('foo'); $container->register('foo_service_tagged_iterator', 'Bar')->addArgument($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); $this->assertStringEqualsFile(self::$fixturesPath.'/yaml/services_with_tagged_argument.yml', $dumper->dump()); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_with_tagged_argument.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_with_tagged_argument.yml index b30aeb7bff..8c2cd59aaf 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_with_tagged_argument.yml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services_with_tagged_argument.yml @@ -14,6 +14,9 @@ services: foo_service_tagged_locator: class: Bar 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: alias: service_container public: false diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php index 9518d1a512..2758d4de83 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/YamlFileLoaderTest.php @@ -282,6 +282,9 @@ class YamlFileLoaderTest extends TestCase $taggedIterator = new TaggedIteratorArgument('foo', 'barfoo', 'foobar', true); $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() diff --git a/src/Symfony/Component/ErrorHandler/DebugClassLoader.php b/src/Symfony/Component/ErrorHandler/DebugClassLoader.php index 1f30cfa9f5..67a6c1500b 100644 --- a/src/Symfony/Component/ErrorHandler/DebugClassLoader.php +++ b/src/Symfony/Component/ErrorHandler/DebugClassLoader.php @@ -11,7 +11,11 @@ namespace Symfony\Component\ErrorHandler; +use Doctrine\Common\Persistence\Proxy; 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. @@ -230,22 +234,57 @@ class DebugClassLoader spl_autoload_unregister($function); } - $loader = null; - foreach ($functions as $function) { if (\is_array($function) && $function[0] instanceof self) { - $loader = $function[0]; $function = $function[0]->getClassLoader(); } spl_autoload_register($function); } + } - if (null !== $loader) { - foreach (array_merge(get_declared_interfaces(), get_declared_traits(), get_declared_classes()) as $class) { - $loader->checkClass($class); + public static function checkClasses(): bool + { + 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 diff --git a/src/Symfony/Component/ErrorHandler/ErrorHandler.php b/src/Symfony/Component/ErrorHandler/ErrorHandler.php index 5493e61a63..2064427f0a 100644 --- a/src/Symfony/Component/ErrorHandler/ErrorHandler.php +++ b/src/Symfony/Component/ErrorHandler/ErrorHandler.php @@ -592,7 +592,11 @@ class ErrorHandler } } $exceptionHandler = $this->exceptionHandler; - $this->exceptionHandler = null; + 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; + } try { if (null !== $exceptionHandler) { return $exceptionHandler($exception); @@ -696,18 +700,28 @@ class ErrorHandler private function sendPhpResponse(\Throwable $exception) { $charset = ini_get('default_charset') ?: 'UTF-8'; - - if (!headers_sent()) { - header('HTTP/1.0 500'); - header(sprintf('Content-Type: text/html; charset=%s', $charset)); - } + $statusCode = 500; + $headers = []; 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 { $message = htmlspecialchars($exception->getMessage(), ENT_COMPAT | ENT_SUBSTITUTE, $charset); - echo sprintf('%s', $charset, $message); + $response = sprintf('%s', $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; } /** diff --git a/src/Symfony/Component/ErrorHandler/Tests/ErrorHandlerTest.php b/src/Symfony/Component/ErrorHandler/Tests/ErrorHandlerTest.php index 6f8f56ef69..b536627a02 100644 --- a/src/Symfony/Component/ErrorHandler/Tests/ErrorHandlerTest.php +++ b/src/Symfony/Component/ErrorHandler/Tests/ErrorHandlerTest.php @@ -557,6 +557,18 @@ class ErrorHandlerTest extends TestCase $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 */ diff --git a/src/Symfony/Component/HttpClient/Chunk/DataChunk.php b/src/Symfony/Component/HttpClient/Chunk/DataChunk.php index 618112834d..37ca848541 100644 --- a/src/Symfony/Component/HttpClient/Chunk/DataChunk.php +++ b/src/Symfony/Component/HttpClient/Chunk/DataChunk.php @@ -20,8 +20,8 @@ use Symfony\Contracts\HttpClient\ChunkInterface; */ class DataChunk implements ChunkInterface { - private $offset; - private $content; + private $offset = 0; + private $content = ''; public function __construct(int $offset = 0, string $content = '') { @@ -53,6 +53,14 @@ class DataChunk implements ChunkInterface return false; } + /** + * {@inheritdoc} + */ + public function getInformationalStatus(): ?array + { + return null; + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php b/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php index d2a69bc38a..3792dccf6d 100644 --- a/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php +++ b/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php @@ -65,6 +65,15 @@ class ErrorChunk implements ChunkInterface 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} */ diff --git a/src/Symfony/Component/HttpClient/Chunk/InformationalChunk.php b/src/Symfony/Component/HttpClient/Chunk/InformationalChunk.php new file mode 100644 index 0000000000..c4452f15a0 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Chunk/InformationalChunk.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Chunk; + +/** + * @author Nicolas Grekas + * + * @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; + } +} diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index 9ecd62b086..c034d869d8 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -56,7 +56,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface * * @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')) { 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); $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) { 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) { - $expectedHeaders[$k][] = substr($h, 2 + \strlen($v)); - } - } - - if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) { - $this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url)); + if (self::acceptPushForRequest($method, $options, $pushedResponse)) { + $this->logger && $this->logger->debug(sprintf('Accepting pushed response: "%s %s"', $method, $url)); // Reinitialize the pushed response with request's options $pushedResponse->response->__construct($this->multi, $url, $options, $this->logger); @@ -127,14 +122,13 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface 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)); $curlopts = [ CURLOPT_URL => $url, - CURLOPT_USERAGENT => 'Symfony HttpClient/Curl', CURLOPT_TCP_NODELAY => true, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, @@ -330,7 +324,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface $active = 0; 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); } } @@ -342,17 +336,17 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface foreach ($requestHeaders as $h) { 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)); return CURL_PUSH_DENY; } - $url = $headers[':scheme'].'://'.$headers[':authority']; + $url = $headers[':scheme'][0].'://'.$headers[':authority'][0]; if ($maxPendingPushes <= \count($multi->pushedResponses)) { $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; } - $url .= $headers[':path']; + $url .= $headers[':path'][0]; $logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url)); - $multi->pushedResponses[$url] = new PushedResponse( - new CurlResponse($multi, $pushed), - [ - $headers['authorization'] ?? null, - $headers['cookie'] ?? null, - $headers['x-requested-with'] ?? null, - null, - ] - ); + $multi->pushedResponses[$url] = new PushedResponse(new CurlResponse($multi, $pushed), $headers, $multi->openHandles[(int) $parent][1] ?? []); 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. */ diff --git a/src/Symfony/Component/HttpClient/Internal/PushedResponse.php b/src/Symfony/Component/HttpClient/Internal/PushedResponse.php index 632f0c41d0..6f8e8fda3a 100644 --- a/src/Symfony/Component/HttpClient/Internal/PushedResponse.php +++ b/src/Symfony/Component/HttpClient/Internal/PushedResponse.php @@ -14,7 +14,7 @@ namespace Symfony\Component\HttpClient\Internal; use Symfony\Component\HttpClient\Response\CurlResponse; /** - * A pushed response with headers. + * A pushed response with its request headers. * * @author Alexander M. Turek * @@ -22,15 +22,17 @@ use Symfony\Component\HttpClient\Response\CurlResponse; */ final class PushedResponse { - /** @var CurlResponse */ public $response; /** @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->headers = $headers; + $this->requestHeaders = $requestHeaders; + $this->parentOptions = $parentOptions; } } diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index a064361763..0b044630f6 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -13,6 +13,7 @@ namespace Symfony\Component\HttpClient\Response; use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\Chunk\FirstChunk; +use Symfony\Component\HttpClient\Chunk\InformationalChunk; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\Internal\CurlClientState; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -120,9 +121,6 @@ final class CurlResponse implements ResponseInterface if (\in_array($waitFor, ['headers', 'destruct'], true)) { try { - if (\defined('CURLOPT_STREAM_WEIGHT')) { - curl_setopt($ch, CURLOPT_STREAM_WEIGHT, 32); - } self::stream([$response])->current(); } catch (\Throwable $e) { // Persist timeouts thrown during initialization @@ -140,7 +138,7 @@ final class CurlResponse implements ResponseInterface }; // Schedule the request in a non-blocking way - $multi->openHandles[$id] = $ch; + $multi->openHandles[$id] = [$ch, $options]; curl_multi_add_handle($multi->handle, $ch); self::perform($multi); } @@ -314,8 +312,11 @@ final class CurlResponse implements ResponseInterface 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)) { + $multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers); + 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']) { // Headers and redirects completed, time to get the response's body - $multi->handlesActivity[$id] = [new FirstChunk()]; + $multi->handlesActivity[$id][] = new FirstChunk(); if ('destruct' === $waitFor) { return 0; diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index fe94bc3436..fa8abebea4 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -45,7 +45,7 @@ class MockResponse implements ResponseInterface public function __construct($body = '', array $info = []) { $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'])) { 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); } /** diff --git a/src/Symfony/Component/HttpClient/Response/ResponseStream.php b/src/Symfony/Component/HttpClient/Response/ResponseStream.php index cf53abcded..f86d2d4077 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseStream.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseStream.php @@ -17,8 +17,6 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface; /** * @author Nicolas Grekas - * - * @internal */ final class ResponseStream implements ResponseStreamInterface { diff --git a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php index 1d1d7c10a5..4fd367fd9d 100644 --- a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php @@ -47,27 +47,22 @@ class CurlHttpClientTest extends HttpClientTestCase } }; - $client = new CurlHttpClient(); + $client = new CurlHttpClient([], 6, 2); $client->setLogger($logger); - $index = $client->request('GET', 'https://http2-push.io'); + $index = $client->request('GET', 'https://http2.akamai.com/'); $index->getContent(); - $css = $client->request('GET', 'https://http2-push.io/css/style.css'); - $js = $client->request('GET', 'https://http2-push.io/js/http2-push.js'); + $css = $client->request('GET', 'https://http2.akamai.com/resources/push.css'); $css->getHeaders(); - $js->getHeaders(); $expected = [ - 'Request: "GET https://http2-push.io/"', - 'Queueing pushed response: "https://http2-push.io/css/style.css"', - 'Queueing pushed response: "https://http2-push.io/js/http2-push.js"', - 'Response: "200 https://http2-push.io/"', - 'Connecting request to pushed response: "GET https://http2-push.io/css/style.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"', + 'Request: "GET https://http2.akamai.com/"', + 'Queueing pushed response: "https://http2.akamai.com/resources/push.css"', + 'Response: "200 https://http2.akamai.com/"', + 'Accepting pushed response: "GET https://http2.akamai.com/resources/push.css"', + 'Response: "200 https://http2.akamai.com/resources/push.css"', ]; $this->assertSame($expected, $logger->logs); } diff --git a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index 91fc21873a..542d19333b 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php @@ -15,6 +15,8 @@ use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\NativeHttpClient; 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\ResponseInterface; @@ -124,6 +126,41 @@ class MockHttpClientTest extends HttpClientTestCase $responses[] = new MockResponse($body, ['response_headers' => $headers]); break; + case 'testInformationalResponseStream': + $client = $this->createMock(HttpClientInterface::class); + $response = new MockResponse('Here the body', ['response_headers' => [ + 'HTTP/1.1 103 ', + 'Link: ; 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' => ['; rel=preload; as=style', '; 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': $mock = $this->getMockBuilder(ResponseInterface::class)->getMock(); $mock->expects($this->any()) diff --git a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php index 2d8b7b8fad..bcfab64bdc 100644 --- a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php @@ -20,4 +20,9 @@ class NativeHttpClientTest extends HttpClientTestCase { return new NativeHttpClient(); } + + public function testInformationalResponseStream() + { + $this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.'); + } } diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 9cb320049d..66d80bfb51 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -22,7 +22,7 @@ "require": { "php": "^7.2.9", "psr/log": "^1.0", - "symfony/http-client-contracts": "^1.1.6", + "symfony/http-client-contracts": "^1.1.7", "symfony/polyfill-php73": "^1.11" }, "require-dev": { diff --git a/src/Symfony/Component/Mailer/Tests/Transport/TransportsTest.php b/src/Symfony/Component/Mailer/Tests/Transport/TransportsTest.php index 93f6dee9f5..10d46ca846 100644 --- a/src/Symfony/Component/Mailer/Tests/Transport/TransportsTest.php +++ b/src/Symfony/Component/Mailer/Tests/Transport/TransportsTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Mailer\Tests\Transport; use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mailer\Transport\Transports; use Symfony\Component\Mime\Header\Headers; @@ -48,4 +49,19 @@ class TransportsTest extends TestCase $email = new Message($headers, new TextPart('...')); $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); + } } diff --git a/src/Symfony/Component/Mailer/Transport/Transports.php b/src/Symfony/Component/Mailer/Transport/Transports.php index 0158d2a1f8..983ad15018 100644 --- a/src/Symfony/Component/Mailer/Transport/Transports.php +++ b/src/Symfony/Component/Mailer/Transport/Transports.php @@ -56,7 +56,7 @@ class Transports implements TransportInterface $headers->remove('X-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); diff --git a/src/Symfony/Component/Translation/DataCollectorTranslator.php b/src/Symfony/Component/Translation/DataCollectorTranslator.php index c46e058c9e..9c51b5ad97 100644 --- a/src/Symfony/Component/Translation/DataCollectorTranslator.php +++ b/src/Symfony/Component/Translation/DataCollectorTranslator.php @@ -119,7 +119,7 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter 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) { $domain = 'messages'; diff --git a/src/Symfony/Component/Translation/Formatter/IntlFormatter.php b/src/Symfony/Component/Translation/Formatter/IntlFormatter.php index 1d5f468d46..ad4a45b95b 100644 --- a/src/Symfony/Component/Translation/Formatter/IntlFormatter.php +++ b/src/Symfony/Component/Translation/Formatter/IntlFormatter.php @@ -28,6 +28,11 @@ class IntlFormatter implements IntlFormatterInterface */ 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 (!($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.'); diff --git a/src/Symfony/Component/Translation/LoggingTranslator.php b/src/Symfony/Component/Translation/LoggingTranslator.php index 2c9e97c1e9..cac8141cc5 100644 --- a/src/Symfony/Component/Translation/LoggingTranslator.php +++ b/src/Symfony/Component/Translation/LoggingTranslator.php @@ -107,7 +107,7 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface, /** * 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) { $domain = 'messages'; diff --git a/src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php b/src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php index 45ce6d4f6e..37d982cf29 100644 --- a/src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php +++ b/src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php @@ -82,6 +82,11 @@ _MSG_; '{0,number,integer} monkeys on {1,number,integer} trees make {2,number} monkeys per tree', [4560, 123, 4560 / 123], ], + [ + '', + '', + [], + ], ]; } diff --git a/src/Symfony/Component/Translation/Tests/TranslatorTest.php b/src/Symfony/Component/Translation/Tests/TranslatorTest.php index 20c83686fa..9666ac97ea 100644 --- a/src/Symfony/Component/Translation/Tests/TranslatorTest.php +++ b/src/Symfony/Component/Translation/Tests/TranslatorTest.php @@ -382,6 +382,19 @@ class TranslatorTest extends TestCase $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() { return [ @@ -403,6 +416,7 @@ class TranslatorTest extends TestCase ['Symfony est super !', 'Symfony is great!', 'Symfony est super !', [], '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', ''], + ['', null, '', [], 'fr', ''], ]; } diff --git a/src/Symfony/Component/Translation/Translator.php b/src/Symfony/Component/Translation/Translator.php index 0ecfee378b..3e6047d672 100644 --- a/src/Symfony/Component/Translation/Translator.php +++ b/src/Symfony/Component/Translation/Translator.php @@ -189,11 +189,14 @@ class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleA */ public function trans($id, array $parameters = [], $domain = null, $locale = null) { + if ('' === $id = (string) $id) { + return ''; + } + if (null === $domain) { $domain = 'messages'; } - $id = (string) $id; $catalogue = $this->getCatalogue($locale); $locale = $catalogue->getLocale(); while (!$catalogue->defines($id, $domain)) { diff --git a/src/Symfony/Component/Validator/ConstraintValidator.php b/src/Symfony/Component/Validator/ConstraintValidator.php index 704252f402..5bcebb7181 100644 --- a/src/Symfony/Component/Validator/ConstraintValidator.php +++ b/src/Symfony/Component/Validator/ConstraintValidator.php @@ -85,12 +85,10 @@ abstract class ConstraintValidator implements ConstraintValidatorInterface */ protected function formatValue($value, int $format = 0) { - $isDateTime = $value instanceof \DateTimeInterface; - - if (($format & self::PRETTY_DATE) && $isDateTime) { + if (($format & self::PRETTY_DATE) && $value instanceof \DateTimeInterface) { if (class_exists('IntlDateFormatter')) { $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 // DateTimeImmutable as of yet diff --git a/src/Symfony/Component/Validator/Constraints/AbstractComparisonValidator.php b/src/Symfony/Component/Validator/Constraints/AbstractComparisonValidator.php index a6ade9c542..6c538fddc9 100644 --- a/src/Symfony/Component/Validator/Constraints/AbstractComparisonValidator.php +++ b/src/Symfony/Component/Validator/Constraints/AbstractComparisonValidator.php @@ -65,14 +65,14 @@ abstract class AbstractComparisonValidator extends ConstraintValidator // This allows to compare with any date/time value supported by // the DateTime constructor: // https://php.net/datetime.formats - if (\is_string($comparedValue)) { - if ($value instanceof \DateTimeImmutable) { - // If $value is immutable, convert the compared value to a - // DateTimeImmutable too - $comparedValue = new \DateTimeImmutable($comparedValue); - } elseif ($value instanceof \DateTimeInterface) { - // Otherwise use DateTime - $comparedValue = new \DateTime($comparedValue); + if (\is_string($comparedValue) && $value instanceof \DateTimeInterface) { + // If $value is immutable, convert the compared value to a DateTimeImmutable too, otherwise use DateTime + $dateTimeClass = $value instanceof \DateTimeImmutable ? \DateTimeImmutable::class : \DateTime::class; + + try { + $comparedValue = new $dateTimeClass($comparedValue); + } catch (\Exception $e) { + 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))); } } diff --git a/src/Symfony/Component/Validator/Constraints/DivisibleByValidator.php b/src/Symfony/Component/Validator/Constraints/DivisibleByValidator.php index a2def7f50c..53b8d38930 100644 --- a/src/Symfony/Component/Validator/Constraints/DivisibleByValidator.php +++ b/src/Symfony/Component/Validator/Constraints/DivisibleByValidator.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + /** * Validates that values are a multiple of the given number. * @@ -23,6 +25,14 @@ class DivisibleByValidator extends AbstractComparisonValidator */ 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)) { return false; } diff --git a/src/Symfony/Component/Validator/Constraints/RangeValidator.php b/src/Symfony/Component/Validator/Constraints/RangeValidator.php index 46c5ea8e68..44f54a63c0 100644 --- a/src/Symfony/Component/Validator/Constraints/RangeValidator.php +++ b/src/Symfony/Component/Validator/Constraints/RangeValidator.php @@ -61,12 +61,26 @@ class RangeValidator extends ConstraintValidator // the DateTime constructor: // https://php.net/datetime.formats if ($value instanceof \DateTimeInterface) { + $dateTimeClass = null; + 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)) { - $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))); + } } } diff --git a/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php b/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php new file mode 100644 index 0000000000..96af6f13eb --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php @@ -0,0 +1,65 @@ + + * + * 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'; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/AbstractComparisonValidatorTestCase.php b/src/Symfony/Component/Validator/Tests/Constraints/AbstractComparisonValidatorTestCase.php index 944195f0f3..51ea8aecd5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/AbstractComparisonValidatorTestCase.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/AbstractComparisonValidatorTestCase.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Intl\Util\IntlTestHelper; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\AbstractComparison; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; @@ -224,6 +225,31 @@ abstract class AbstractComparisonValidatorTestCase extends ConstraintValidatorTe ->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 { // The provider runs before setUp(), so we need to manually fix diff --git a/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByValidatorTest.php index f11fdce97b..25187bafc8 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/DivisibleByValidatorTest.php @@ -14,6 +14,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\DivisibleBy; use Symfony\Component\Validator\Constraints\DivisibleByValidator; +use Symfony\Component\Validator\Exception\UnexpectedValueException; /** * @author Colin O'Dell @@ -76,4 +77,25 @@ class DivisibleByValidatorTest extends AbstractComparisonValidatorTestCase ['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], + ]; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualValidatorWithPositiveOrZeroConstraintTest.php b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualValidatorWithPositiveOrZeroConstraintTest.php index 6eab44d1a0..8db8eddf7c 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualValidatorWithPositiveOrZeroConstraintTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualValidatorWithPositiveOrZeroConstraintTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\AbstractComparison; 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() diff --git a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanValidatorWithPositiveConstraintTest.php b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanValidatorWithPositiveConstraintTest.php index 4ea65a5fc5..ef10787bcc 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanValidatorWithPositiveConstraintTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/GreaterThanValidatorWithPositiveConstraintTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\AbstractComparison; 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() diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualValidatorWithNegativeOrZeroConstraintTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualValidatorWithNegativeOrZeroConstraintTest.php index 3bb1c6ea24..9e09819120 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualValidatorWithNegativeOrZeroConstraintTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LessThanOrEqualValidatorWithNegativeOrZeroConstraintTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\AbstractComparison; 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() diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LessThanValidatorWithNegativeConstraintTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LessThanValidatorWithNegativeConstraintTest.php index 3203adabb9..9150beb07a 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LessThanValidatorWithNegativeConstraintTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LessThanValidatorWithNegativeConstraintTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\AbstractComparison; 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() diff --git a/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php index 0281bc9074..ef70c16145 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php @@ -14,6 +14,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; use Symfony\Component\Intl\Util\IntlTestHelper; use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Constraints\RangeValidator; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; class RangeValidatorTest extends ConstraintValidatorTestCase @@ -390,6 +391,31 @@ class RangeValidatorTest extends ConstraintValidatorTestCase ->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() { $this->setObject(null); diff --git a/src/Symfony/Contracts/HttpClient/ChunkInterface.php b/src/Symfony/Contracts/HttpClient/ChunkInterface.php index d6fd73d894..ad5efca9e9 100644 --- a/src/Symfony/Contracts/HttpClient/ChunkInterface.php +++ b/src/Symfony/Contracts/HttpClient/ChunkInterface.php @@ -47,6 +47,13 @@ interface ChunkInterface */ 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. * diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index b5364ecab5..bd2e35d3dc 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -754,6 +754,27 @@ abstract class HttpClientTestCase extends TestCase $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(['; rel=preload; as=style', '; 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: ; rel=preload; as=style', $response->getInfo('response_headers')); + } + /** * @requires extension zlib */ diff --git a/src/Symfony/Contracts/Translation/TranslatorTrait.php b/src/Symfony/Contracts/Translation/TranslatorTrait.php index 488b4f4f23..a8267342a0 100644 --- a/src/Symfony/Contracts/Translation/TranslatorTrait.php +++ b/src/Symfony/Contracts/Translation/TranslatorTrait.php @@ -43,7 +43,9 @@ trait TranslatorTrait */ 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%'])) { return strtr($id, $parameters);